Command Palette

Search for a command to run...

PHASE 12Intermediate ~31 min· topic 2 of 5

Topic 12.2

Buffered Readers and Writers

In one line

BufferedReader and BufferedWriter put an in-memory array between your code and the real source, so thousands of tiny reads or writes become a few big ones. BufferedReader also gives you readLine() and lines(), the standard way to read text line by line.

Think of it like this

Fetching water from a well. You could walk to the well for every sip, or fill a big bucket once and drink from it all day. The walk is the expensive part. A buffer is the bucket: BufferedReader fetches a big chunk of characters (8192 by default) in one trip to the source, then hands them to you one by one from memory. BufferedWriter does the reverse: it collects what you write and carries it to the destination in one trip.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Buffer
A block of memory that holds data temporarily, so it can be moved in a few big pieces instead of many small ones.
System call
A request from a program to the operating system, like "read 8 KB from this file". It is much slower than reading memory.
Line terminator
The invisible characters that end a line of text: \n on Linux and macOS, \r\n on Windows.
Flush
Push everything waiting in a buffer out to the real destination now, instead of waiting for the buffer to fill.
Lazy
Done only when needed. A lazy stream of lines reads the next line only when you ask for it.
Token
One piece of input between separators, such as one word or one number in "12 apples".
Locale
A setting for a language and country that controls things like whether a decimal number is written 3.5 or 3,5.
StringReader / StringWriter
A Reader that reads from a String, and a Writer that collects text into a string, both entirely in memory.

Step by step

01Why buffering exists

Every read() on a FileReader can go all the way down to the operating system. BufferedReader instead calls the underlying reader's read(char[], off, len) with an 8192-character array, then serves your next 8191 read() calls from that array without touching the source.

The example "Count the trips to the source" below measures it: reading 20,000 characters unbuffered makes 20,001 calls to the source; buffered, it makes 4.

Why buffering existsdiagram
Rendering diagram…

02Reading lines

The standard loop reads a line, checks it for null, and uses it, all in one expression. Call readLine() once per iteration; calling it twice skips every other line (see Break it).

readLine() strips the terminator, so you never see \r or \n in the result, whichever operating system wrote the file.

Main.javawhole filejava
try (BufferedReader reader = new BufferedReader(new StringReader(text))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println("[" + line + "]");
    }
}

03Lines as a stream (Java 8)

reader.lines() gives a Stream<String>. It is lazy: lines are read only as the stream pulls them, so limit(10) reads only about ten lines from a huge file (plus whatever the buffer pre-fetched).

The stream doesn't own the reader: close the reader with try-with-resources. Files.lines(path) (Topic 12.3) is different: that stream owns the file and must itself be closed.

Main.javawhole filejava
try (BufferedReader reader = Files.newBufferedReader(Path.of("orders.csv"))) {
    long big = reader.lines()
                     .skip(1)                                   // header
                     .map(l -> l.split(","))
                     .filter(f -> Integer.parseInt(f[2]) > 500)
                     .count();
}

04Writing text with a buffer

Stack a PrintWriter on a BufferedWriter for convenient, fast output: printf and println from the first, the 8192-character buffer from the second.

BufferedWriter.newLine() and PrintWriter.println() write the platform line separator. If the file must have \n everywhere (for example a file read by Linux tools, or a test comparing exact bytes), write "\n" yourself.

Report.javawhole filejava
try (PrintWriter out = new PrintWriter(
        Files.newBufferedWriter(Path.of("report.txt")))) {   // UTF-8, buffered
    out.printf(Locale.ROOT, "%-6s %8.2f%n", "tea", 25.0);
    out.println("total: 1");
}   // close() flushes the buffer, then closes the file
terminal
$ java Report.java
cat report.txt
── expected output ──
tea 25.00
total: 1

05What close() and flush() really do

flush() asks each layer to pass its buffered data down: PrintWriter flushes the BufferedWriter, which writes its array to the OutputStreamWriter, which encodes it and writes bytes to the FileOutputStream, which makes the system call. Even then the data may sit in the operating system's page cache; only FileChannel.force(true) or FileDescriptor.sync() forces it onto the disk.

close() flushes and then releases the file handle. Once closed, a BufferedReader throws IOException: Stream closed on the next read.

What close() and flush() really dodiagram
Rendering diagram…

06Test with StringReader and StringWriter

If a method accepts a Reader or BufferedReader instead of a file name, it can be tested with new StringReader("a\nb") and no files at all. Likewise, a method that writes to a Writer can be tested with a StringWriter and toString().

This is the dependency injection idea applied to I/O: let the caller decide where the data comes from.

WordCount.javawhole filejava
static int countWords(BufferedReader in) throws IOException {
    int words = 0;
    String line;
    while ((line = in.readLine()) != null) {
        if (!line.isBlank()) words += line.trim().split("\\s+").length;
    }
    return words;
}
// production: countWords(Files.newBufferedReader(path))
// test:       countWords(new BufferedReader(new StringReader("a b\n c")))

07Scanner versus BufferedReader

Scanner reads tokens and parses them. It's handy for "3 apples 2.5", but each next...() call runs a regular expression, so it is several times slower than readLine() plus Integer.parseInt on large input. That's why competitive programmers and the DSA course (/dsa) use BufferedReader for fast input.

Scanner.nextDouble() uses the default locale. Call scanner.useLocale(Locale.ROOT) when parsing machine-written numbers, or you'll get InputMismatchException on machines set to a locale that writes 3,5.

Try it yourself

  1. 1

    Change the buffer size

    In "Count the trips to the source", use new BufferedReader(counted, 100). Predict the number of calls (20,000 / 100 plus one), then run it.

  2. 2

    Find where the flush happens

    In "Nothing arrives until you flush", replace new BufferedWriter(target) with new BufferedWriter(target, 16). Predict the "before flush" count now: the buffer can only hold 16 characters, so it must spill.

  3. 3

    Count lines lazily

    In the stream example, add .peek(l -> System.out.println("read: " + l)) right after lines() and replace the collector with .findFirst(). Predict how many "read:" lines appear.

Code & diagrams

readLine with mixed line endings New tab

\n, \r\n and a lone \r all end a line, and none of them appear in the result. An empty line is "", not null.

Sign in to run this example in your browser.

Expected output

1: [first] length 5
2: [second] length 6
3: [] length 0
4: [fourth] length 6
5: [last without newline] length 20
after the end: null
Count the trips to the source Java 11+ New tab

Buffered: 8192 + 8192 + 3616 characters, then one call that returns -1. With a FileReader, each source call can be a system call.

Sign in to run this example in your browser.

Expected output

unbuffered: 20000 chars, 20001 calls to the source
buffered:   20000 chars, 4 calls to the source
Nothing arrives until you flush New tab

The text is held in BufferedWriter's array until flush() or close(). PrintWriter hides I/O errors; checkError() is the only way to see them.

Sign in to run this example in your browser.

Expected output

before flush: 0 chars
after flush:  32 chars
tea   |   25.00
samosa|   12.50
checkError() after writing to a closed writer: true
Process lines as a stream Java 8+ New tab
Sign in to run this example in your browser.

Expected output

{Delhi=380, Pune=570}
Scanner parses tokens New tab

useLocale(Locale.ROOT) makes nextDouble() expect a dot as the decimal separator, whatever the machine's settings.

Sign in to run this example in your browser.

Expected output

3 x apples @ 2.5
7 x mangoes @ 1.25
total: 16.25
Real files: read and write lines (run on your machine) Java 11+java

Terminal: java Main.java prints "> buy milk" and "> call Ravi". Files.newBufferedReader/Writer default to UTF-8, unlike FileReader/FileWriter before Java 18.

import java.io.*;
import java.nio.file.*;

public class Main {
    public static void main(String[] args) throws IOException {
        Path file = Path.of("notes.txt");
        try (BufferedWriter w = Files.newBufferedWriter(file)) {     // UTF-8, creates or truncates
            w.write("buy milk");
            w.newLine();
            w.write("call Ravi");
            w.newLine();
        }
        try (BufferedReader r = Files.newBufferedReader(file)) {     // UTF-8
            String line;
            while ((line = r.readLine()) != null) {
                System.out.println("> " + line);
            }
        }
    }
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Call readLine() twice per loop

Write the loop as while (reader.readLine() != null) { System.out.println(reader.readLine()); } over the text "a\nb\nc\nd\ne".

terminal
$ java Main.java
── what you'll see ──
b
d
null

Break #2

Never close the writer

Write a report with BufferedWriter w = Files.newBufferedWriter(path); w.write("total: 42"); and let main end without close().

terminal
$ java Report.java
wc -c report.txt
── what you'll see ──
0 report.txt

Break #3

Read a UTF-8 file with the default charset

On Windows with Java 17, read a UTF-8 file containing café with new BufferedReader(new FileReader("menu.txt")).

terminal
$ java Main.java
── what you'll see ──
café

Myth vs fact

Myth

BufferedReader reads the whole file into memory.

Fact

It holds only its buffer, 8192 characters by default. It can read a 50 GB file line by line with a few kilobytes of memory, as long as you don't collect all the lines yourself.

Myth

println writes to the file immediately.

Fact

With a buffered writer, the text waits in memory until the buffer fills or you flush or close. Even after a flush the OS may cache it before writing to disk.

Myth

readLine() returns null for an empty line.

Fact

An empty line comes back as "". Only the end of the input gives null.

Myth

Bigger buffers are always faster.

Fact

Past roughly 8 to 64 KB the gain is small, because the operating system already reads ahead. Huge buffers just waste memory, especially with thousands of open readers.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    BufferedReader isn't BufferedInputStream plus decoding: it buffers chars, while the InputStreamReader underneath has its own byte buffer (8192 bytes in StreamDecoder). So new BufferedReader(new InputStreamReader(in)) already has two buffers; adding a BufferedInputStream underneath is usually wasted.

  • ▸

    readLine() builds a new String per line, which is fine for most files but creates a lot of garbage on multi-gigabyte inputs. High-throughput parsers read into a char[] or use FileChannel with a ByteBuffer and parse bytes directly, avoiding decoding entirely for ASCII data.

  • ▸

    Since JDK 19, BufferedReader, BufferedWriter and similar classes lock with an internal ReentrantLock instead of synchronized when you use the JDK class itself (not a subclass), so a virtual thread blocked in readLine() doesn't pin its carrier thread (Topic 13.10). Readers are still not meant to be shared between threads without coordination.

  • ▸

    Java 10 added Reader.transferTo(Writer) and Java 11 added Reader.nullReader() and Writer.nullWriter(). StringWriter is backed by a StringBuffer (synchronized), so for single-threaded string building StringBuilder is a little faster.

Remember this

  1. 1

    The "walk to the well" is a system call: a request from your program to the operating system kernel, such as read or write on a file. A system call costs hundreds of nanoseconds to microseconds, while reading a char from an array costs about a nanosecond. An unbuffered FileReader can make a system call for every few characters; reading a 100 MB file that way can take minutes instead of a second. Buffering changes the number of calls, not the result.

  2. 2

    BufferedReader wraps any Reader. Besides read(), it adds String readLine(), which returns one line without its line terminator, and null at the end of the input. It understands all three terminators: \n (Linux, macOS), \r\n (Windows) and a lone \r. The last line is returned even if it has no terminator. An empty line comes back as "", which is different from null.

  3. 3

    BufferedReader.lines() (Java 8) returns a Stream<String> of the lines, so you can filter, map and collect them (Topic 10.4). The stream reads lazily from the reader as you consume it. Wrapping I/O errors that happen during the stream, it throws UncheckedIOException, because stream lambdas can't throw checked exceptions.

  4. 4

    BufferedWriter wraps any Writer and adds newLine(), which writes System.lineSeparator() (\r\n on Windows, \n elsewhere). Its data sits in memory until the buffer fills, or you call flush() or close(). Forgetting to close a writer is the number one cause of empty or cut-off output files. close() flushes first, so try-with-resources (Topic 7.6) fixes it.

  5. 5

    PrintWriter adds print, println and printf on top of any Writer. It never throws IOException; it records failure and you check checkError(). StringReader and StringWriter are character streams over a String in memory, perfect for tests: code that accepts a Reader can be tested without creating a file.

  6. 6

    Scanner (Java 5) is another way to read text: it splits input into tokens and parses numbers (nextInt(), nextDouble()). It is convenient for small inputs, but slower than BufferedReader (it uses regular expressions) and its number parsing depends on the locale, so 3.5 fails under a German locale that expects 3,5. For big inputs, use BufferedReader and parse yourself. In real code you get a buffered reader for a file with Files.newBufferedReader(path) (Topic 12.3), which defaults to UTF-8.

Explain it without notes

01

Why is buffered I/O faster, and what exactly does the buffer save?

02

How does readLine() treat line terminators, empty lines and the last line?

03

What's the difference between flush() and close(), and why do output files end up empty?

04

When would you choose Scanner over BufferedReader, and what are its pitfalls?

Practice

01

Read "apple\n\nbanana\ncherry\n" with a BufferedReader and print how many lines there are, how many are blank, and the longest line.

02

Write a method void writeTable(Writer out, String[] names, int[] scores) that prints aligned rows with PrintWriter (names left-aligned in 8 characters, scores right-aligned in 4). Test it with a StringWriter and print the result.

03

Using BufferedReader.lines(), count the words in a multi-line string, ignoring blank lines and extra spaces.

Trade-offs

  • ↔

    Buffering trades memory and latency for throughput: data waits in memory before it's written. For logs or network protocols where the other side must see data promptly, flush at message boundaries (or use autoflush), accepting more system calls.

  • ↔

    readLine() is simple and fast enough for most text, but it allocates a String per line and can't handle a single line larger than memory. Very large or binary-ish inputs need chunked reads on char[]/byte[].

  • ↔

    Scanner makes parsing easy at the cost of speed and locale surprises; BufferedReader plus manual parsing is faster and predictable but more code.

Done when you can

  • Done when you can explain what a buffer saves and roughly how many source calls a buffered read makes.

  • Done when you can write the readLine() loop correctly and know what null and "" mean.

  • Done when every writer you open is closed by try-with-resources, and you know when an explicit flush() is needed.

  • Done when you can process lines with lines() and streams.

  • Done when you can test I/O code with StringReader and StringWriter instead of real files.