skip to content
everything.io.ioStreams

i/o streams

InputStream, Reader, and the decorator onion.

chapter
chapter 10.01
reading time
10 min read

an InputStream hands you bytes, a Reader hands you chars (bytes run through a charset). real code never uses just one: it stacks them, each layer wrapping the next and adding exactly one thing (a buffer, a charset, a way to read an int). that stack is the decorator pattern, and it's the whole design of java.io.

the picture

you know that line everyone copies from stack overflow, new BufferedReader(new InputStreamReader(new FileInputStream(f)))? three constructors deep, just to read a file. kinda cursed. but every layer is doing a different job, and once you see the jobs it stops looking silly.

think of plumbing. the file is the water main (raw bytes, whatever's in the pipe). FileInputStream is the tap (the only part connected to the outside world, the OS). InputStreamReader is a filter screwed onto the tap (turns bytes into chars using a charset). BufferedReader is a tank on top (fills up in big gulps so you can take a cup at a time). you drink from the outermost piece. it pulls from the one inside it, which pulls from the one inside that.

read syscalls = 0readLine() returned = ·
BufferedReaderreadLine()
buffer
······… 8192
InputStreamReaderadds a charset: bytes → chars
decoder = UTF-8
FileInputStreamadds a real file. read() = a syscall
fd → onion.txt · 0 syscalls so far
the OS · onion.txt5 bytes on disk
68690ac3a9

you call in.readLine(). you only ever talk to the outermost layer.

step 1/10
step through three readLine() calls on a real 5-byte file. watch the active layer, and watch the syscall counter not move on step 8: that line was already in the buffer.

here's the code that figure is running. it really does print what the stepper says:

Main.java
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
 
void main() throws IOException {
    Path file = Files.createTempFile("onion", ".txt");
    Files.write(file, new byte[] { 0x68, 0x69, 0x0a, (byte) 0xc3, (byte) 0xa9 });
 
    try (var in = new BufferedReader(
            new InputStreamReader(
                new FileInputStream(file.toFile()), StandardCharsets.UTF_8))) {
        String line;
        while ((line = in.readLine()) != null) {
            IO.println(line + " (" + line.length() + " chars)");
        }
    }
    Files.delete(file);
}
output, from jdk 25
hi (2 chars)
é (1 chars)

this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.

5 bytes in, 4 chars out, 2 lines. é took two bytes and became one char. that gap between bytes and chars gets its own chapter: charsets and encoding.

two families

java.io is two parallel trees. byte streams (InputStream, OutputStream) move raw bytes and don't care what they mean: images, zip files, class files, network packets. char streams (Reader, Writer) move chars and always have a charset somewhere underneath doing the translation.

  • bytes in
  • ├── FileInputStreama file on disk
  • ├── ByteArrayInputStreama byte[] in memory
  • ├── base class for decorators
  • │ ├── BufferedInputStreamadds a buffer
  • │ └── DataInputStreamadds readInt, readDouble...
  • ├── ObjectInputStreamwhole objects. see serialization
  • └── SequenceInputStreamglues streams end to end
  • chars in
  • ├── bridge: bytes → chars
  • │ └── FileReaderInputStreamReader over a file
  • ├── BufferedReaderadds a buffer and readLine()
  • └── StringReadera String in memory
  • bytes out
  • ├── FileOutputStream
  • ├── ByteArrayOutputStream
  • └──
  • ├── BufferedOutputStream
  • ├── DataOutputStream
  • └── PrintStreamSystem.out. swallows exceptions
  • chars out
  • ├── bridge: chars → bytes
  • │ └── FileWriter
  • ├── BufferedWriter
  • ├── PrintWriter
  • └── StringWriter
click a node to fold it. sources (they produce bytes from somewhere) sit at the top of each tree. everything under Filter... or with Buffered in the name is a decorator: it needs another stream to wrap. the two bridges are where bytes become chars.

the rule for picking one is short:

you haveuse
text, and you know the charsetReader / Writer (and say the charset out loud)
anything that isn't textInputStream / OutputStream
text, but you're just copying it somewherebytes. decoding and re-encoding for nothing is wasted work
source / sink
a stream that talks to something real: a file, a socket, a byte array. you can't wrap one around another stream.
decorator
a stream whose constructor takes another stream. it forwards every call and adds one behaviour on the way.
bridge
InputStreamReader and OutputStreamWriter: the only places a byte stream becomes a char stream (or back). the charset lives here.

the read loop

the most basic call there is: int read(). one byte, please. it returns an int, not a byte, and that's not an accident.

Main.java
import java.io.*;
 
void main() throws IOException {
    var in = new ByteArrayInputStream(new byte[] { 65, -1, 10 });
    int b;
    while ((b = in.read()) != -1) {
        IO.print(b + " ");
    }
    IO.println("| then " + in.read());
}
output
65 255 10 | then -1

see the 255? the byte was -1 (all eight bits set, 0xFF), but read() hands it back as an unsigned value from 0 to 255. that leaves -1 free to mean one thing only: "the stream is over". if read() returned a byte, a file containing 0xFF would look exactly like the end of the file.

predict the output
java
byte b;
while ((b = (byte) in.read()) != -1) {
    out.write(b);
}

the bulk version, read(byte[] buf), fills as much of the array as it can and returns how many bytes it actually put there. it can be fewer than you asked for, even mid-file (sockets do this all the time). so the loop always uses the count:

Copy.java
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
    out.write(buf, 0, n);   // only the first n bytes are new
}

you rarely write that loop anymore: in.transferTo(out) java 9+ does exactly this.

why buffering matters

a FileInputStream has no memory. every read() goes down into the OS as a system call, and a system call is expensive: the CPU switches into the kernel, does the work, and switches back. read a file one byte at a time with no buffer and you pay that toll once per byte.

BufferedInputStream fixes it with one array. the first read() asks the stream inside for 8192 bytes in one go, and the next 8191 read()s are just array lookups.

buffer =
the 1 MiB file, cut into the chunks each read() fetches
01,048,576 bytes
read() calls on the file129
363 ms
31 ms
5 ms
4.3 ms
4.2 ms

one call fills 8,192 bytes, then 8,191 read()s are served from memory. past a few hundred bytes the syscalls stop mattering, the per-byte loop is what's left.

pick a buffer size. the counts are real (a counting wrapper around the source) and so are the times: median of 9 runs reading a 1 MiB temp file byte by byte, jdk 25 on an M4 Pro. no buffer is about 85× slower than the default.

notice the curve flattens fast. going from 512 bytes to 64 KiB barely moves the time, because the syscalls are already rare and what's left is the cost of calling read() a million times. the fix for that is reading in chunks yourself (read(byte[]), readAllBytes()), or dropping down to buffers and channels.

don'ta syscall per byte
java
try (var in = new FileInputStream(f)) {
    int b;
    while ((b = in.read()) != -1) count(b);
}
doone syscall per 8 KiB
java
try (var in = new BufferedInputStream(
        new FileInputStream(f))) {
    int b;
    while ((b = in.read()) != -1) count(b);
}

closing and flushing

closing the outermost layer closes everything inside it. that's the other reason the onion works: you hold one reference, you close one thing. use try-with-resources and stop thinking about it.

output buffers have the opposite problem. a BufferedWriter holds your text in memory until the buffer fills, you call flush(), or you close(). until then, the file doesn't have it.

Main.java
import java.io.*;
import java.nio.file.*;
 
void main() throws IOException {
    Path file = Files.createTempFile("log", ".txt");
 
    var out = new BufferedWriter(new FileWriter(file.toFile()));
    out.write("order 42 shipped\n");
    IO.println("before flush: " + Files.size(file) + " bytes");
 
    out.flush();
    IO.println("after flush:  " + Files.size(file) + " bytes");
 
    out.close();
    Files.delete(file);
}
output, from jdk 25
before flush: 0 bytes
after flush:  17 bytes

this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.

the shortcuts you actually use

most code today never builds the onion by hand. the JDK grew one-liners for the common jobs, and they pick sane defaults (UTF-8, a buffer, closing for you):

jobcallsince
a whole file as a StringFiles.readString(path)11
a file as linesFiles.readAllLines(path) / Files.lines(path)8
a buffered reader on a fileFiles.newBufferedReader(path)8
copy a stream into anotherin.transferTo(out)9
everything left in a streamin.readAllBytes()9
a Reader over a StringReader.of(text)24
everything left in a readerreader.readAllAsString()25
a line from the consoleIO.readln("name? ")25
Main.java
import java.io.*;
 
void main() throws IOException {
    var in = new ByteArrayInputStream("hello, streams".getBytes());
    var out = new ByteArrayOutputStream();
    long n = in.transferTo(out);
    IO.println(n + " bytes: " + out);
}
output
14 bytes: hello, streams

the Files side of this (paths, walking directories, watching for changes) is in files and paths. console input and printf are in reading and printing.

one level deeper:DataOutputStream: the decorator that writes numbers+

streams only know bytes. DataOutputStream adds "write this int" on top, as 4 big-endian bytes. it's how binary formats get written by hand, and it's the bottom layer of serialization.

Main.java
import java.io.*;
 
void main() throws IOException {
    var bytes = new ByteArrayOutputStream();
    try (var out = new DataOutputStream(bytes)) {
        out.writeInt(1000);
        out.writeDouble(0.5);
        out.writeUTF("hé");
    }
    byte[] raw = bytes.toByteArray();
    IO.println(raw.length + " bytes: " + java.util.HexFormat.ofDelimiter(" ").formatHex(raw));
 
    try (var in = new DataInputStream(new ByteArrayInputStream(raw))) {
        IO.println(in.readInt() + " " + in.readDouble() + " " + in.readUTF());
    }
}
output, from jdk 25
17 bytes: 00 00 03 e8 3f e0 00 00 00 00 00 00 00 03 68 c3 a9
1000 0.5 hé

this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.

00 00 03 e8 is 1000. 3f e0 00 ... is the IEEE 754 bits of 0.5 (see floating point). writeUTF writes a 2-byte length (00 03) and then the string in a slightly modified UTF-8. there's no type information anywhere: read them back in a different order and you get garbage, not an error.

why not one big class?

because the combinations explode. buffered or not, × which charset, × reading numbers or lines, × file or socket or byte array or zip entry. one class per combination would be hundreds of classes. with decorators it's one class per feature, and you compose them like lego. the same BufferedReader works on a file, a socket, a gzip stream or a String, because all it needs is "a Reader inside me".

the price is the onion syntax, and having to know which layer does what. which is this whole chapter :)

keylines
  • InputStream / OutputStream move bytes. Reader / Writer move chars, with a charset underneath.
  • each wrapper adds one thing: a buffer, a charset, numbers. you read from the outside, it pulls from the inside.
  • read() returns an int so that -1 can mean "done" and 255 can still be a byte.
  • unbuffered byte-by-byte reads are a syscall each. wrap it, or read in chunks.
  • close the outer layer (try-with-resources). unflushed output is lost output.