File I/O & NIO.2

Buffered Streams & Performance

15 min Lesson 7 of 13

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.

Rule of thumb: wrap every file stream in a buffered stream unless you have a specific reason not to. The cost of the wrapper object is negligible; the performance gain is almost always significant.

BufferedInputStream and BufferedOutputStream

For binary data, wrap a FileInputStream or FileOutputStream with its buffered counterpart:

import java.io.*; import java.nio.file.Path; public class BufferedByteDemo { public static void copyFile(Path src, Path dst) throws IOException { // Without buffering: one system call per byte // With buffering: one system call per 8 KB block (default buffer size) try (var in = new BufferedInputStream(new FileInputStream(src.toFile()), 8_192); var out = new BufferedOutputStream(new FileOutputStream(dst.toFile()), 8_192)) { byte[] chunk = new byte[8_192]; int bytesRead; while ((bytesRead = in.read(chunk)) != -1) { out.write(chunk, 0, bytesRead); } // out.flush() is called automatically by close() via try-with-resources } } }

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.

Always flush before closing manually. Calling 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.

import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; public class BufferedTextDemo { // Read every line from a UTF-8 text file public static void printLines(Path path) throws IOException { try (var reader = new BufferedReader( new InputStreamReader( new FileInputStream(path.toFile()), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { System.out.println(line); } } } // Write lines with a platform-neutral newline public static void writeLines(Path path, Iterable<String> lines) throws IOException { try (var writer = new BufferedWriter( new OutputStreamWriter( new FileOutputStream(path.toFile()), StandardCharsets.UTF_8))) { for (String line : lines) { writer.write(line); writer.newLine(); // writes \n on Unix, \r\n on Windows } } } }

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:

import java.io.*; import java.nio.file.*; import java.util.List; public class NioBufferedDemo { public static List<String> readAllLines(Path path) throws IOException { // Files.readAllLines is fine for small files that fit in memory return Files.readAllLines(path); // UTF-8 by default } public static void processLargeFile(Path path) throws IOException { // For large files: stream line by line without loading everything try (BufferedReader reader = Files.newBufferedReader(path)) { reader.lines() .filter(line -> !line.isBlank()) .map(String::strip) .forEach(System.out::println); } } public static void writeLinesNio(Path path, List<String> lines) throws IOException { try (BufferedWriter writer = Files.newBufferedWriter(path)) { for (String line : lines) { writer.write(line); writer.newLine(); } } } }
Prefer 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:

import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; public class PrintWriterDemo { public static void writeCsv(Path path) throws IOException { // autoFlush=false: flush only on close, for best performance try (var pw = new PrintWriter(Files.newBufferedWriter(path, StandardCharsets.UTF_8))) { pw.println("name,score,passed"); pw.printf("%s,%.2f,%b%n", "Alice", 92.5, true); pw.printf("%s,%.2f,%b%n", "Bob", 58.0, false); } } }
Avoid 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:

import java.io.*; import java.nio.file.*; public class BufferBenchmark { static final int LINES = 1_000_000; public static void main(String[] args) throws IOException { Path p1 = Files.createTempFile("unbuffered", ".txt"); Path p2 = Files.createTempFile("buffered", ".txt"); long t1 = System.currentTimeMillis(); try (var fw = new FileWriter(p1.toFile())) { // no buffer for (int i = 0; i < LINES; i++) fw.write("line " + i + "\n"); } System.out.println("Unbuffered: " + (System.currentTimeMillis() - t1) + " ms"); long t2 = System.currentTimeMillis(); try (var bw = Files.newBufferedWriter(p2)) { // buffered for (int i = 0; i < LINES; i++) { bw.write("line " + i); bw.newLine(); } } System.out.println("Buffered: " + (System.currentTimeMillis() - t2) + " ms"); Files.delete(p1); Files.delete(p2); } }

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 096 bytes 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.