Buffered Streams & Performance
Buffered Streams & Performance
Every time you call read() or write() on an unbuffered stream, the JVM makes a system call that crosses the boundary between user space and kernel space. On a spinning disk that costs microseconds; over a network it can cost milliseconds. Worse, these costs multiply: reading a 1 MB file one byte at a time means roughly one million system calls instead of one. Buffering eliminates almost all of that overhead by grouping many small transfers into a single large one.
Why Buffering Is a Big Deal
The OS and the storage hardware already operate on blocks — typically 4 KB to 64 KB at a time. If your Java program asks for one byte, the kernel may have to fetch a whole block anyway, but it discards the rest. A buffer lets Java hold that block in memory so subsequent reads can be served without any system call at all.
BufferedInputStream and BufferedOutputStream
For binary data, wrap a FileInputStream or FileOutputStream with its buffered counterpart:
The second constructor argument is the internal buffer size in bytes. The default is 8 192 bytes (8 KB). You can increase it to 64 KB or 128 KB for large sequential reads; shrink it for memory-constrained environments. Benchmarks rarely show a benefit beyond 64 KB on modern hardware.
close() flushes automatically, but if you reuse a stream across multiple writes and hand it to another method, call flush() explicitly to push the buffer to the OS.
BufferedReader and BufferedWriter
For text data, the character-stream wrappers are BufferedReader and BufferedWriter. They add a crucial convenience: readLine(), which handles \n, \r\n, and \r for you.
Notice the layering pattern: BufferedWriter wraps OutputStreamWriter (character → byte conversion) wraps FileOutputStream (bytes → disk). Each layer has a single responsibility.
The Modern Shortcut: Files.newBufferedReader / Files.newBufferedWriter
Since Java 7 the Files utility class provides factory methods that build this three-layer stack for you, defaulting to UTF-8:
Files.newBufferedReader and Files.newBufferedWriter over the raw three-layer stack — the intent is clearer and the charset defaults to UTF-8. Reserve the explicit stack only when you need a non-default charset or a non-file source.
PrintWriter for Formatted Text Output
PrintWriter wraps a BufferedWriter and adds println(), printf(), and format(). It is convenient for writing log files or CSV output where you mix plain strings with formatted numbers:
PrintWriter's auto-flush constructor. new PrintWriter(writer, true) flushes after every println() call — this is fine for interactive console output but catastrophic for file writing, because it turns every line into a system call and destroys the benefit of buffering. Keep autoFlush=false (the default) for file I/O.
Measuring the Difference
Here is a minimal benchmark that writes one million short lines both ways and times each run:
On a typical SSD you will see the buffered version run 5–20× faster. On a spinning disk or a network share the gap is even larger.
Choosing the Right Buffer Size
- Default (8 KB) — correct for almost every use case. Do not change it without profiling.
- 64 KB – 128 KB — can help when reading very large sequential files (video transcoding, log processing).
- Match the OS page size — on Linux
4 096bytes is the VM page size; multiples of that (8 192, 16 384) align well with kernel block I/O. - Smaller buffers — only useful in extremely memory-constrained environments (embedded devices), but Java rarely runs there.
Summary
Buffered streams are a thin wrapper with a dramatic impact. Always layer a BufferedInputStream/BufferedOutputStream around raw byte streams, or a BufferedReader/BufferedWriter around character streams. For NIO.2 paths use Files.newBufferedReader and Files.newBufferedWriter. Keep the default 8 KB buffer size unless profiling tells you otherwise, never enable auto-flush for file output, and always close (or flush) before the program exits.