bufferedreader: read a file line by line module (BufferedReader vs Scanner vs Files.lines vs readAllLines on a 1 GB file; heap at -Xmx64m, timings, read-call counts, one-huge-line OOM, charset/BOM/line-terminator pitfalls, Scanner swallowing IOException), 10 contract tests

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b3pDoGLiWk6LLBeHjW
This commit is contained in:
Claude
2026-11-15 01:20:37 +05:30
parent 5faa57bb2d
commit f8c9e244a3
25 changed files with 809 additions and 0 deletions
@@ -0,0 +1,76 @@
package com.ankurm.bufferedreader;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
import java.util.Scanner;
import java.util.concurrent.Callable;
/**
* The same file read five ways when its bytes are NOT valid UTF-8, and a UTF-8 BOM. Run it twice:
* once normally and once with -Dfile.encoding=COMPAT to see the pre-JDK-18 default come back.
*/
public final class CharsetDemo {
public static void main(String[] a) throws Exception {
Path dir = Files.createTempDirectory("charset");
System.out.println("Charset.defaultCharset() = " + Charset.defaultCharset());
System.out.println("native.encoding = " + System.getProperty("native.encoding"));
System.out.println("file.encoding (property) = " + System.getProperty("file.encoding"));
System.out.println();
Path latin1 = dir.resolve("latin1.txt");
Files.write(latin1, "caf\u00e9\n".getBytes(StandardCharsets.ISO_8859_1));
System.out.println("latin1.txt bytes: " + HexFormat.ofDelimiter(" ").formatHex(Files.readAllBytes(latin1)) + " (the text is \"caf\u00e9\" saved as ISO-8859-1)");
run("new FileReader(path) ", () -> firstLine(new BufferedReader(new FileReader(latin1.toFile()))));
run("new FileReader(path, UTF_8) ", () -> firstLine(new BufferedReader(new FileReader(latin1.toFile(), StandardCharsets.UTF_8))));
run("Files.newBufferedReader(path) ", () -> firstLine(Files.newBufferedReader(latin1)));
run("Files.readAllLines(path) ", () -> Files.readAllLines(latin1).toString());
run("Files.lines(path).toList() ", () -> { try (var s = Files.lines(latin1)) { return s.toList().toString(); } });
run("new Scanner(path).nextLine() ", () -> { try (Scanner sc = new Scanner(latin1)) { return Esc.of(sc.nextLine()); } });
run("readAllLines(path, ISO_8859_1) <- fix ", () -> Files.readAllLines(latin1, StandardCharsets.ISO_8859_1).toString());
System.out.println();
Path utf8 = dir.resolve("utf8.txt");
Files.writeString(utf8, "caf\u00e9 \u20b9100\n", StandardCharsets.UTF_8);
System.out.println("utf8.txt bytes: " + HexFormat.ofDelimiter(" ").formatHex(Files.readAllBytes(utf8)) + " (\"caf\u00e9 \u20b9100\" saved as UTF-8)");
run("new FileReader(path) ", () -> firstLine(new BufferedReader(new FileReader(utf8.toFile()))));
run("new FileReader(path, UTF_8) <- fix ", () -> firstLine(new BufferedReader(new FileReader(utf8.toFile(), StandardCharsets.UTF_8))));
System.out.println();
Path bom = dir.resolve("bom.csv");
Files.write(bom, concat(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}, "id,name\n1,Asha\n".getBytes(StandardCharsets.UTF_8)));
System.out.println("bom.csv bytes: " + HexFormat.ofDelimiter(" ").formatHex(Files.readAllBytes(bom)).substring(0, 41) + " ...");
try (BufferedReader br = Files.newBufferedReader(bom)) {
String header = br.readLine();
System.out.println("header line = " + Esc.of(header) + " length=" + header.length() + " (the text is 7 characters)");
System.out.println("header.equals(\"id,name\") = " + header.equals("id,name"));
String cleaned = header.startsWith("\uFEFF") ? header.substring(1) : header;
System.out.println("after stripping U+FEFF = " + Esc.of(cleaned) + " equals=" + cleaned.equals("id,name"));
}
}
static byte[] concat(byte[] x, byte[] y) {
byte[] r = new byte[x.length + y.length];
System.arraycopy(x, 0, r, 0, x.length);
System.arraycopy(y, 0, r, x.length, y.length);
return r;
}
static String firstLine(BufferedReader br) throws IOException {
try (br) { return Esc.of(br.readLine()); }
}
static void run(String label, Callable<String> c) {
String result;
try { result = c.call(); }
catch (Exception e) {
result = e.getClass().getSimpleName() + ": " + e.getMessage();
if (e.getCause() != null) result += " [cause: " + e.getCause().getClass().getSimpleName() + ": " + e.getCause().getMessage() + "]";
}
System.out.println(label + " -> " + result);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.bufferedreader;
/** Makes non-ASCII and control characters visible so transcripts show what is really in the String. */
final class Esc {
private Esc() {}
static String of(String s) {
if (s == null) return "null";
StringBuilder sb = new StringBuilder("\"");
for (char c : s.toCharArray()) {
if (c == '\n') sb.append("\\n");
else if (c == '\r') sb.append("\\r");
else if (c >= 32 && c < 127) sb.append(c);
else sb.append(String.format("\\u%04X", (int) c));
}
return sb.append('"').toString();
}
}
@@ -0,0 +1,84 @@
package com.ankurm.bufferedreader;
import java.io.BufferedReader;
import java.io.FileReader;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Scanner;
import java.util.stream.Stream;
/**
* Reads one file line by line with one of four approaches and prints lines, characters, wall time and GC cost.
* Every approach does identical work (count lines, sum their lengths) so the totals must match.
* Run each approach in its own fresh JVM: {@code java -Xmx64m LineCount reader big.log}.
*/
public final class LineCount {
public static void main(String[] a) throws Exception {
String approach = a[0];
Path p = Path.of(a[1]);
long t0 = System.nanoTime();
long[] r;
try {
r = switch (approach) {
case "reader" -> reader(p, 8192);
case "reader64k" -> reader(p, 65536);
case "scanner" -> scanner(p);
case "lines" -> lines(p);
case "readAllLines" -> readAllLines(p);
default -> throw new IllegalArgumentException("unknown approach " + approach);
};
} catch (OutOfMemoryError e) {
System.out.printf("%-13s OutOfMemoryError: %s (-Xmx%d MB)%n", approach, e.getMessage(), Runtime.getRuntime().maxMemory() >> 20);
return;
}
long ms = (System.nanoTime() - t0) / 1_000_000;
long gcCount = 0, gcMs = 0;
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
gcCount += gc.getCollectionCount();
gcMs += gc.getCollectionTime();
}
System.out.printf("%-13s lines=%,d chars=%,d time=%,d ms gc=%d collections / %d ms (-Xmx%d MB)%n",
approach, r[0], r[1], ms, gcCount, gcMs, Runtime.getRuntime().maxMemory() >> 20);
}
/** BufferedReader.readLine() in try-with-resources, charset stated explicitly. */
static long[] reader(Path p, int bufferChars) throws Exception {
long n = 0, chars = 0;
try (BufferedReader br = new BufferedReader(new FileReader(p.toFile(), StandardCharsets.UTF_8), bufferChars)) {
String line;
while ((line = br.readLine()) != null) { n++; chars += line.length(); }
}
return new long[]{n, chars};
}
/** Scanner.nextLine(). */
static long[] scanner(Path p) throws Exception {
long n = 0, chars = 0;
try (Scanner sc = new Scanner(p, StandardCharsets.UTF_8)) {
while (sc.hasNextLine()) { chars += sc.nextLine().length(); n++; }
}
return new long[]{n, chars};
}
/** Files.lines(): lazy Stream, must be closed. */
static long[] lines(Path p) throws Exception {
try (Stream<String> s = Files.lines(p, StandardCharsets.UTF_8)) {
long[] acc = new long[2];
s.forEach(l -> { acc[0]++; acc[1] += l.length(); });
return acc;
}
}
/** Files.readAllLines(): the whole file as a List before you see line one. */
static long[] readAllLines(Path p) throws Exception {
List<String> all = Files.readAllLines(p, StandardCharsets.UTF_8);
long chars = 0;
for (String l : all) chars += l.length();
return new long[]{all.size(), chars};
}
}
@@ -0,0 +1,38 @@
package com.ankurm.bufferedreader;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
/** What counts as "a line"? Terminators, a final newline, empty files, and what readLine() cannot tell you. */
public final class LineEndingsDemo {
public static void main(String[] a) throws IOException {
System.out.printf("%-16s | %-34s | %-34s | %s%n", "input", "BufferedReader.readLine()", "Scanner.nextLine()", "split(\"\\n\").length");
System.out.println("-".repeat(104));
String[] inputs = {"a\nb", "a\nb\n", "a\nb\n\n", "a\r\nb", "a\rb", "", "\n", "a\n\rb"};
for (String s : inputs) {
System.out.printf("%-16s | %-34s | %-34s | %d%n", Esc.of(s), brLines(s), scLines(s), s.split("\n").length);
}
}
static List<String> brLines(String s) throws IOException {
List<String> out = new ArrayList<>();
try (BufferedReader br = new BufferedReader(new StringReader(s))) {
String l;
while ((l = br.readLine()) != null) out.add(Esc.of(l));
}
return out;
}
static List<String> scLines(String s) {
List<String> out = new ArrayList<>();
try (Scanner sc = new Scanner(new StringReader(s))) {
while (sc.hasNextLine()) out.add(Esc.of(sc.nextLine()));
}
return out;
}
}
@@ -0,0 +1,36 @@
package com.ankurm.bufferedreader;
import java.io.BufferedReader;
import java.io.FileReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
/**
* readLine() has no maximum length. One giant line (minified JSON, a log with a stack trace flattened onto one line)
* is read into a single String. Two modes: {@code line} (the naive loop) and {@code chunks} (read(char[]) in 8 KB pieces).
*/
public final class LongLineDemo {
public static void main(String[] a) throws Exception {
String mode = a[0];
Path p = Path.of(a[1]);
System.out.printf("mode=%s -Xmx%d MB%n", mode, Runtime.getRuntime().maxMemory() >> 20);
try (BufferedReader br = new BufferedReader(new FileReader(p.toFile(), StandardCharsets.UTF_8))) {
if (mode.equals("line")) {
String line = br.readLine();
System.out.printf("readLine() returned %,d chars%n", line == null ? -1 : line.length());
} else {
char[] buf = new char[8192];
long total = 0, commas = 0;
int n;
while ((n = br.read(buf, 0, buf.length)) != -1) {
total += n;
for (int i = 0; i < n; i++) if (buf[i] == ',') commas++;
}
System.out.printf("read(char[]) processed %,d chars, %,d commas, never holding more than %d chars%n", total, commas, buf.length);
}
} catch (OutOfMemoryError e) {
System.out.println("OutOfMemoryError: " + e.getMessage());
}
}
}
@@ -0,0 +1,66 @@
package com.ankurm.bufferedreader;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
/**
* What does BufferedReader actually save? Counts the read calls that reach the underlying InputStream, then times
* three ways of consuming the same file. Corrects a common claim: an unbuffered InputStreamReader/FileReader does NOT
* make one operating-system read per character, because its decoder already reads 8 KB of bytes at a time.
*/
public final class ReadCalls {
/** Counts calls and bytes that reach the stream under the reader. */
static final class CountingStream extends FilterInputStream {
long calls, bytes;
CountingStream(InputStream in) { super(in); }
@Override public int read() throws IOException { int b = super.read(); calls++; if (b >= 0) bytes++; return b; }
@Override public int read(byte[] b, int off, int len) throws IOException {
int n = super.read(b, off, len); calls++; if (n > 0) bytes += n; return n;
}
}
public static void main(String[] a) throws Exception {
Path p = Path.of(a[0]);
System.out.println("== 1. read() calls that reach the underlying InputStream (first 50 MB of the file) ==");
count("InputStreamReader.read() per char", p, false, 0, 'c');
count("BufferedReader(8192).read() per char", p, true, 8192, 'c');
count("BufferedReader(8192).readLine()", p, true, 8192, 'l');
count("BufferedReader(65536).readLine()", p, true, 65536, 'l');
count("BufferedReader(1).readLine()", p, true, 1, 'l');
System.out.println();
System.out.println("== 2. time to consume the whole file (3 passes each; indicative, same JVM) ==");
for (int pass = 1; pass <= 3; pass++) {
System.out.printf("pass %d InputStreamReader.read() per char %,6d ms%n", pass, time(p, false, 0, 'c'));
System.out.printf("pass %d BufferedReader.read() per char %,6d ms%n", pass, time(p, true, 8192, 'c'));
System.out.printf("pass %d BufferedReader.readLine() %,6d ms%n", pass, time(p, true, 8192, 'l'));
}
}
static final long LIMIT = 50_000_000L;
static void count(String label, Path p, boolean buffered, int size, char mode) throws Exception {
CountingStream cs = new CountingStream(new FileInputStream(p.toFile()));
Reader base = new InputStreamReader(cs, StandardCharsets.UTF_8);
Reader r = buffered ? new BufferedReader(base, size) : base;
long chars = 0;
try (r) {
if (mode == 'c') { int c; while (chars < LIMIT && (c = r.read()) != -1) chars++; }
else { BufferedReader br = (BufferedReader) r; String l; while (chars < LIMIT && (l = br.readLine()) != null) chars += l.length() + 1; }
}
System.out.printf("%-38s underlying calls=%,9d avg bytes/call=%,8.1f%n", label, cs.calls, (double) cs.bytes / cs.calls);
}
static long time(Path p, boolean buffered, int size, char mode) throws Exception {
long t0 = System.nanoTime(), x = 0;
Reader base = new InputStreamReader(new FileInputStream(p.toFile()), StandardCharsets.UTF_8);
Reader r = buffered ? new BufferedReader(base, size) : base;
try (r) {
if (mode == 'c') { int c; while ((c = r.read()) != -1) x += c; }
else { BufferedReader br = (BufferedReader) r; String l; while ((l = br.readLine()) != null) x += l.length(); }
}
if (x == 42) System.out.print(""); // keep the loop alive
return (System.nanoTime() - t0) / 1_000_000;
}
}
@@ -0,0 +1,46 @@
package com.ankurm.bufferedreader;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.Scanner;
/**
* A reader that serves two lines and then fails, as a dying disk or a dropped network share would.
* BufferedReader tells you; Scanner looks exactly like a clean end of file.
*/
public final class ScannerSwallowsDemo {
/** Delivers "line one\nline two\n" in one read, then throws on the next. */
static final class FailingReader extends Reader {
private boolean first = true;
@Override public int read(char[] buf, int off, int len) throws IOException {
if (!first) throw new IOException("simulated read failure (disk gone)");
first = false;
String s = "line one\nline two\n";
s.getChars(0, s.length(), buf, off);
return s.length();
}
@Override public void close() {}
}
public static void main(String[] a) {
System.out.println("-- BufferedReader --");
int n = 0;
try (BufferedReader br = new BufferedReader(new FailingReader())) {
String l;
while ((l = br.readLine()) != null) { n++; System.out.println("got: " + l); }
System.out.println("loop ended normally after " + n + " lines");
} catch (IOException e) {
System.out.println("IOException after " + n + " lines: " + e.getMessage());
}
System.out.println("-- Scanner --");
n = 0;
try (Scanner sc = new Scanner(new FailingReader())) {
while (sc.hasNextLine()) { n++; System.out.println("got: " + sc.nextLine()); }
System.out.println("loop ended normally after " + n + " lines");
System.out.println("sc.ioException() = " + sc.ioException());
}
}
}
@@ -0,0 +1,43 @@
package com.ankurm.bufferedreader;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Deterministic generator for the synthetic files used by the benchmarks.
*
* <pre>
* java TestData lines big.log 1000000000 # ~1 GB of ~100-byte ASCII log lines
* java TestData oneline long.txt 200000000 # 200 MB with NO newline at all
* </pre>
*/
public final class TestData {
public static void main(String[] a) throws IOException {
String kind = a[0];
Path p = Path.of(a[1]);
long bytes = Long.parseLong(a[2]);
long written = 0;
long t0 = System.nanoTime();
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(p), 1 << 16)) {
if (kind.equals("lines")) {
long i = 0;
while (written < bytes) {
String line = "2026-01-01T00:00:%02d.%03dZ INFO [worker-%02d] request id=%09d path=/api/v1/items/%d status=%d ms=%d\n"
.formatted(i % 60, i % 1000, i % 32, i, i % 977, i % 7 == 0 ? 500 : 200, i % 113);
byte[] b = line.getBytes(StandardCharsets.US_ASCII);
out.write(b);
written += b.length;
i++;
}
} else if (kind.equals("oneline")) {
byte[] chunk = "{\"k\":\"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\"},".getBytes(StandardCharsets.US_ASCII);
while (written < bytes) { out.write(chunk); written += chunk.length; }
} else throw new IllegalArgumentException(kind);
}
System.out.printf("generated %s: %,d bytes in %d ms%n", p.getFileName(), Files.size(p), (System.nanoTime() - t0) / 1_000_000);
}
}
@@ -0,0 +1,108 @@
package com.ankurm.bufferedreader;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Every behaviour the post states about line reading, pinned as an assertion. */
class ReadLineContractTest {
@TempDir Path dir;
static List<String> br(String s) throws IOException {
List<String> out = new ArrayList<>();
try (BufferedReader r = new BufferedReader(new StringReader(s))) { String l; while ((l = r.readLine()) != null) out.add(l); }
return out;
}
@Test void readLineAcceptsAllThreeTerminators() throws IOException {
assertEquals(List.of("a", "b"), br("a\nb"));
assertEquals(List.of("a", "b"), br("a\r\nb"));
assertEquals(List.of("a", "b"), br("a\rb"));
}
@Test void finalNewlineDoesNotCreateAnEmptyLastLine() throws IOException {
assertEquals(List.of("a", "b"), br("a\nb\n"));
assertEquals(List.of("a", "b", ""), br("a\nb\n\n"));
}
@Test void emptyFileHasZeroLinesAndLoneNewlineHasOne() throws IOException {
assertEquals(List.of(), br(""));
assertEquals(List.of(""), br("\n"));
}
@Test void readLineCannotTellWhetherTheLastLineHadATerminator() throws IOException {
assertEquals(br("a\nb"), br("a\nb\n"));
}
@Test void allFourApproachesAgreeOnAWellFormedFile() throws Exception {
Path p = dir.resolve("f.txt");
Files.writeString(p, "one\ntwo\r\nthree\n\nfive");
List<String> viaReader = new ArrayList<>();
try (BufferedReader r = Files.newBufferedReader(p)) { String l; while ((l = r.readLine()) != null) viaReader.add(l); }
List<String> viaScanner = new ArrayList<>();
try (Scanner sc = new Scanner(p)) { while (sc.hasNextLine()) viaScanner.add(sc.nextLine()); }
List<String> viaLines;
try (Stream<String> s = Files.lines(p)) { viaLines = s.toList(); }
List<String> viaAll = Files.readAllLines(p);
assertEquals(List.of("one", "two", "three", "", "five"), viaReader);
assertEquals(viaReader, viaScanner);
assertEquals(viaReader, viaLines);
assertEquals(viaReader, viaAll);
}
@Test void invalidUtf8IsStrictInFilesApiAndSilentlyReplacedInFileReader() throws Exception {
Path p = dir.resolve("latin1.txt");
Files.write(p, "caf\u00e9\n".getBytes(StandardCharsets.ISO_8859_1));
assertThrows(MalformedInputException.class, () -> Files.readAllLines(p));
UncheckedIOException u = assertThrows(UncheckedIOException.class, () -> { try (Stream<String> s = Files.lines(p)) { s.toList(); } });
assertInstanceOf(MalformedInputException.class, u.getCause());
try (BufferedReader r = new BufferedReader(new FileReader(p.toFile(), StandardCharsets.UTF_8))) {
assertEquals("caf\uFFFD", r.readLine());
}
assertEquals(List.of("caf\u00e9"), Files.readAllLines(p, StandardCharsets.ISO_8859_1));
}
@Test void utf8BomSurvivesAsTheFirstCharacter() throws Exception {
Path p = dir.resolve("bom.csv");
Files.write(p, new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF, 'i', 'd', '\n'});
try (BufferedReader r = Files.newBufferedReader(p)) {
String first = r.readLine();
assertEquals("\uFEFFid", first);
assertNotEquals("id", first);
}
}
@Test void scannerHidesIoExceptionButBufferedReaderThrows() {
assertThrows(IOException.class, () -> {
try (BufferedReader r = new BufferedReader(new ScannerSwallowsDemo.FailingReader())) { while (r.readLine() != null) { } }
});
Scanner sc = new Scanner(new ScannerSwallowsDemo.FailingReader());
int n = 0;
while (sc.hasNextLine()) { sc.nextLine(); n++; }
assertEquals(2, n);
assertNotNull(sc.ioException());
}
@Test void readLineReturnsAStringAsLongAsTheLine() throws Exception {
Path p = dir.resolve("long.txt");
Files.writeString(p, "x".repeat(1_000_000));
try (BufferedReader r = Files.newBufferedReader(p)) { assertEquals(1_000_000, r.readLine().length()); }
}
@Test void unclosedFilesLinesStillReadsButClosingIsYourJob() throws Exception {
Path p = dir.resolve("f.txt");
Files.writeString(p, "a\nb\n");
try (Stream<String> s = Files.lines(p)) { assertEquals(2, s.count()); }
}
}