JAVA / FILES, INPUT/OUTPUT AND SERIALIZATION
Reading text with readers and buffered input
Read text line by line with a BufferedReader over a decoded Reader, and know exactly what readLine, mark/reset and lines() do at the edges.
What you will learn
- Chain FileInputStream to InputStreamReader with an explicit charset to BufferedReader
- Loop until readLine() returns null; treat an empty string as a real blank line
- Explain why buffering exists: one 8192-char fill instead of thousands of source reads
- Peek a line with mark(limit) and reset(), and let lines() feed a stream lazily
Understanding Reading text with readers and buffered input
A Reader hands you characters that have already been decoded, and the only method a concrete Reader really has to implement is read(char[], int, int); read() for a single character and everything else is built on top of it. You get readers from InputStreamReader (any InputStream plus a charset), FileReader (a file plus a charset), StringReader (characters already in memory) or Files.newBufferedReader. Nothing in Reader knows what a line is, and that is deliberate: a line is a pattern inside the character stream, not a unit the source hands out.
Reading one character at a time straight from an InputStreamReader over a FileInputStream means one call travelling down the whole chain per character, and at the bottom of that chain sits a read system call. BufferedReader keeps an 8192-character array, fills it in a single large request and then serves your reads out of memory. The buffer also buys lookahead, and lookahead is exactly what line reading needs: to decide that a carriage return ended a line, readLine has to inspect the next character and be able to un-see it when it turns out not to be a line feed. That is why readLine, mark/reset and lines() live on BufferedReader rather than on Reader.
readLine returns the line content without its terminator and never tells you which terminator it was, since a line feed, a carriage return and the pair of them all simply mean end of line. It returns null only at end of input: an empty line comes back as an empty string, a final line with no terminator is still returned, and a trailing terminator does not create a phantom empty line at the end. Each wrapper closes the object it wraps, so one try-with-resources on the BufferedReader releases the underlying file handle, and because the decoding happens in the InputStreamReader below it, the charset belongs there and not on the BufferedReader.
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class ReadText {
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("notes", ".txt");
Files.writeString(file, "alpha\nbeta\n\ngamma", StandardCharsets.UTF_8);
try (BufferedReader in = new BufferedReader(
new InputStreamReader(new FileInputStream(file.toFile()),
StandardCharsets.UTF_8))) {
String line;
int count = 0;
while ((line = in.readLine()) != null) {
count++;
System.out.println(count + ": [" + line + "] length=" + line.length());
}
System.out.println("after the last line: " + in.readLine());
System.out.println("lines read: " + count);
}
Files.delete(file);
}
}Line reading needs lookahead, so it belongs to the buffer: a BufferedReader can hold and hand back characters that a bare Reader cannot.
Worked examples
What buffering actually changes
Counts how many times the underlying reader is asked for characters, with and without a BufferedReader in front of it.
import java.io.BufferedReader;
import java.io.FilterReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
public class CountSourceCalls {
static class Counting extends FilterReader {
int calls = 0;
Counting(Reader in) { super(in); }
@Override public int read() throws IOException {
calls++;
return super.read();
}
@Override public int read(char[] cbuf, int off, int len) throws IOException {
calls++;
return super.read(cbuf, off, len);
}
}
public static void main(String[] args) throws IOException {
String text = "x".repeat(5000);
Counting slow = new Counting(new StringReader(text));
int chars = 0;
try (Reader r = slow) {
while (r.read() != -1) chars++;
}
System.out.println("no buffer: " + chars + " chars, " + slow.calls + " calls to the source");
Counting fast = new Counting(new StringReader(text));
chars = 0;
try (Reader r = new BufferedReader(fast)) {
while (r.read() != -1) chars++;
}
System.out.println("buffered: " + chars + " chars, " + fast.calls + " calls to the source");
}
}Example explained
Line 1FilterReader forwards every call to the reader it wraps, so overriding read lets the counter see exactly what the source is asked to do.
Line 2The unbuffered loop makes 5001 calls: one per character plus the final call that returns -1.
Line 3BufferedReader fills its 8192-character array with a single read(char[], off, len), so the source is touched twice: one full read and one -1.
Line 4Closing the BufferedReader in try-with-resources also closes fast, which is why one close on the outermost wrapper is enough.
Peeking with mark and reset
Reads a line to inspect it, then rewinds so the same line can be consumed again.
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class PeekLine {
public static void main(String[] args) throws IOException {
String data = "port=8080\nhost=local\n";
InputStreamReader raw = new InputStreamReader(
new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)),
StandardCharsets.UTF_8);
System.out.println("InputStreamReader markSupported: " + raw.markSupported());
try (BufferedReader in = new BufferedReader(raw)) {
System.out.println("BufferedReader markSupported: " + in.markSupported());
in.mark(200);
String peeked = in.readLine();
System.out.println("peeked: " + peeked);
if (!peeked.startsWith("#")) {
in.reset();
}
System.out.println("first real line: " + in.readLine());
}
}
}Example explained
Line 1InputStreamReader keeps no history, so markSupported() is false and rewinding is impossible until you wrap it.
Line 2mark(200) promises the buffer will remember at most 200 characters; reading further than that invalidates the mark and reset() then throws IOException.
Line 3reset() returns the position to the mark, so the peeked line is delivered again instead of being lost to the inspection.
Line 4The line was not a comment, so it is handed back; had it started with #, the code would simply have dropped it and read on.
lines() over a reader
Shows that lines() reads lazily from the same reader, produces no trailing empty element, and does not close anything.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
public class LineStream {
public static void main(String[] args) throws IOException {
String text = "first\n\n \nsecond\nthird\n";
try (BufferedReader in = new BufferedReader(new StringReader(text))) {
List<String> kept = in.lines()
.map(String::strip)
.filter(s -> !s.isEmpty())
.toList();
System.out.println(kept);
System.out.println("kept " + kept.size() + " of 5 lines");
System.out.println("reader still usable, at EOF: " + (in.readLine() == null));
}
}
}Example explained
Line 1in.lines() pulls from the reader lazily; nothing is read until the terminal operation toList() runs, which is why the stream must be consumed inside the try block.
Line 2The text holds five lines, two of which are blank or whitespace only, and the final newline adds no sixth empty element.
Line 3The stream leaves the reader open, so the readLine() afterwards returns null; on a closed reader that call would have thrown IOException instead.
Line 4toList() requires Java 16 or later; on older versions use collect(Collectors.toList()).
Important notes
readLine() cannot report which terminator ended a line, so reading lines and rejoining them with a line feed quietly converts a CRLF file into an LF one; if byte fidelity matters, read characters instead of lines.
Since Java 18 the default charset is UTF-8, so new FileReader(file) means UTF-8 on a recent JVM and something platform-dependent on Java 17 or older; pass a Charset explicitly so the code keeps the same meaning everywhere.
Common mistakes
Calling readLine() twice per iteration, once in the while condition and once in the body, which silently processes only every second line.
Writing char c = (char) in.read(); while (c != -1): the cast turns the -1 end marker into the character U+FFFF, the comparison can never be false, and the loop spins forever.
Using while (!line.isEmpty()) or a check for an empty string as the end condition, so the first blank line in the middle of the file ends the loop and the rest of the file is silently dropped.
Try it yourself
Change, predict, then run
Put the text a, empty line, b, c separated as "a\n\nb\r\nc" (no final newline) into a String, read it through a BufferedReader wrapping a StringReader, and print each line's number, the line in brackets and its length. Confirm you get exactly four lines and that readLine() returns null only after c.
Open the Java workspaceCheck your understanding
Why does readLine() live on BufferedReader instead of on Reader itself?
- Because readLine() is faster than read(), and BufferedReader is where the performance-oriented methods are grouped.
- Because detecting the end of a line means reading one character past it, to see whether a carriage return is followed by a line feed, so those characters need somewhere they can be held and handed back.
- Because only BufferedReader knows which charset turns the underlying bytes into characters.
- Because Reader is abstract, and abstract classes cannot declare methods that return String.
Show answer
Line detection requires lookahead: the reader must consume characters the caller has not asked for and possibly give one back, which is only possible with a buffer behind the method. Option 0 is tempting because buffering really does make reading faster, but that is a side effect of the same buffer, not the reason the method must live there; a plain Reader already offers a fast read(char[], off, len) and still cannot support readLine. Decoding happens in the InputStreamReader below, so option 2 has the layers the wrong way round.