Java Internals & Concurrency/I/O cổ điển — InputStream, Reader, buffered wrapper
20/42
Bài 20 / 42~14 phútI/O & NIOMiễn phí lượt xem

I/O cổ điển — InputStream, Reader, buffered wrapper

Byte stream vs char stream, decorator pattern của java.io, vì sao BufferedReader nhanh hơn FileReader hàng trăm lần. Charset và bug latent khi không specify encoding.

TL;DR: java.io chia 2 nhánh: byte stream (InputStream/OutputStream) cho binary, char stream (Reader/Writer) cho text — text phải decode byte thành char theo charset cụ thể. Mỗi lần read() không buffer là 1 syscall (~1-2μs); BufferedInputStream gom 8KB mỗi syscall nên số syscall giảm ~8000 lần — lý do "thêm 1 dòng wrap, nhanh hàng trăm lần". Toàn bộ java.io thiết kế theo decorator pattern: ghép wrapper (buffer, decode, decompress) quanh source. Pitfall lớn nhất: new FileReader(path) không charset dùng platform default — bug latent trên Windows với Java trước 18.

Code sau đọc file 100MB:

try (FileInputStream fis = new FileInputStream("data.bin")) {
    int b;
    while ((b = fis.read()) != -1) {
        process(b);
    }
}

Thời gian: ~2-3 phút.

Thay 1 dòng:

try (InputStream in = new BufferedInputStream(new FileInputStream("data.bin"))) {
    int b;
    while ((b = in.read()) != -1) {
        process(b);
    }
}

Thời gian: ~0.3 giây. Chênh lệch hàng trăm lần với cùng API, cùng logic, chỉ thêm BufferedInputStream.

Lý do nằm ở level OS: mỗi lần gọi read() không tham số là 1 syscall xuống kernel, mỗi syscall tốn ~1-2 microsecond. Đọc 100MB byte-by-byte cần ~100 triệu syscall — riêng overhead syscall đã chiếm ~100-200 giây. BufferedInputStream gom 8KB mỗi lần syscall nên số syscall giảm ~8000 lần; thời gian còn lại chỉ là tốc độ đọc tuần tự của disk.

Đây là bài học đầu tiên về I/O Java: API Java trông giống nhau nhưng khác nhau vô cùng về hiệu năng. Hiểu cơ chế OS + pattern decorator của java.io → biết ghép đúng wrapper để code không vừa sai vừa chậm.

Bài này đi qua: phân chia 2 nhánh API (byte stream vs char stream), pattern decorator nền tảng thiết kế java.io, vì sao buffer quan trọng ở cấp syscall, và charset — lý do new FileReader("x.txt") là bug latent trên Windows.

1. Analogy — Ống nước, xô, máy lọc

Tưởng tượng bạn cần chuyển nước từ giếng về nhà:

  • FileInputStream: vặn vòi giếng, nước chảy từng giọt vào chén, bê về nhà. Đi 1 triệu chuyến cho 100 lít.
  • BufferedInputStream: dùng xô 10 lít, múc đầy rồi bê về. 10k chuyến thay vì 1 triệu.
  • InputStreamReader: máy lọc nước thành nước trái cây ở đầu vòi — chuyển từ "byte" sang "char" (decode theo charset).
  • BufferedReader: xô chứa nước trái cây, còn có cốc lấy đúng 1 ly mỗi lần — tiện cho "đọc 1 dòng".

Mỗi class có 1 vai trò duy nhất. Ghép lại tạo pipeline đọc file. Đây là decorator pattern — trọng tâm thiết kế java.io.

Đời thườngJava
Vòi nướcFileInputStream
Xô 10 lítBufferedInputStream
Máy lọc (byte → char)InputStreamReader
Xô + cốc lấy 1 dòngBufferedReader

Pipeline đầy đủ cho đọc text UTF-8 có readLine:

FileInputStream -> BufferedInputStream -> InputStreamReader -> BufferedReader
       ^                 ^                       ^                   ^
       source           buffer byte          byte -> char         buffer char + readLine
💡 Cách nhớ

Mỗi class 1 trách nhiệm. Ghép từ trong ra ngoài: source → buffer byte → decode → buffer char. Nhớ công thức này, build được pipeline cho mọi use case (decompress, decrypt, parse).

2. Hai nhánh API

Java chia I/O thành 2 nhánh riêng biệt:

NhánhĐơn vịBase abstract classDùng cho
Byte streambyte (8 bit)InputStream, OutputStreamBinary data: ảnh, PDF, zip, bytecode
Char streamchar (UTF-16 trong JVM)Reader, WriterText — cần biết encoding

Vì sao tách 2 nhánh?

Text cần encoding. File "hello.txt" trên disk là chuỗi byte. Cùng chuỗi byte có thể là UTF-8 (mỗi char 1-4 byte, tương thích ASCII), UTF-16 (2-4 byte), Windows-1252/ISO-8859-1 (1 byte), hay GBK/Shift-JIS (encoding CJK).

JVM nội tại dùng UTF-16 cho char. Đọc text từ file = decode byte → char theo charset cụ thể.

Byte stream không biết encoding — trả về raw byte. Char stream biết encoding — trả về char.

Nếu bạn đọc file PNG bằng Reader → JVM decode byte thành char → corrupt dữ liệu. Nếu đọc file text bằng InputStream → nhận byte thô → phải tự decode.

Rule: text → char stream + charset explicit. Binary → byte stream.

Hierarchy

flowchart TD
    A[InputStream abstract] --> B[FileInputStream]
    A --> C[ByteArrayInputStream]
    A --> D[BufferedInputStream]
    A --> E[DataInputStream]
    A --> F[ObjectInputStream]

    G[Reader abstract] --> H[FileReader]
    G --> I[InputStreamReader]
    G --> J[BufferedReader]

    K[Bridge byte to char] -.-> I

Class InputStreamReader là bridge giữa 2 nhánh: wrap InputStream, decode byte thành char với charset.

3. Đọc byte — FileInputStream + buffer

Pattern cơ bản

import java.io.*;

try (InputStream in = new BufferedInputStream(new FileInputStream("data.bin"))) {
    byte[] buf = new byte[8192];
    int n;
    while ((n = in.read(buf)) != -1) {
        process(buf, n);
    }
}

Breakdown:

  • FileInputStream("data.bin"): mở file, return byte one-by-one nếu chỉ dùng read(), hoặc block nếu dùng read(byte[]).
  • new BufferedInputStream(...): wrap — internal buffer 8KB, gom syscall.
  • try-with-resources: bắt buộc cho I/O để tránh leak file descriptor — xem lại Try-with-resources.
  • in.read(buf): đọc tối đa buf.length byte vào buf, trả số byte thực đọc được. Có thể ít hơn buf.length (file sắp hết) hoặc -1 (EOF).

Luôn check return value. Nhiều bug do xử lý byte "ma" từ phần cũ của buf:

// BAD
while (in.read(buf) != -1) {
    process(buf);   // Process ca byte cu con trong buf!
}

// GOOD
int n;
while ((n = in.read(buf)) != -1) {
    process(buf, n);   // Chi process n byte dau
}

Tại sao buf 8KB?

8KB là default size của BufferedInputStream và match với page size OS. Đọc 1 page OS ~ 1 syscall. Tăng buf lên 64KB giảm thêm syscall nhưng tốn memory — trade-off.

Rule: default 8KB đủ cho đa số case. File cực lớn (vượt 1GB) có thể tăng lên 64KB hoặc 128KB. File nhỏ (dưới 1MB) tăng buf không lợi.

4. Đọc text — BufferedReader

3 tầng decorator

try (BufferedReader r = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("log.txt"),
            StandardCharsets.UTF_8))) {
    String line;
    while ((line = r.readLine()) != null) {
        System.out.println(line);
    }
}

Đọc từ trong ra ngoài:

  1. FileInputStream("log.txt"): byte từ disk.
  2. InputStreamReader(fis, UTF_8): decode byte thành char. Bắt buộc pass charset.
  3. BufferedReader(reader): buffer char + readLine() — tách line tự động theo \n / \r\n.

readLine() trả null khi EOF (không phải empty string — phải phân biệt "dòng rỗng" vs "hết file").

Vì sao 3 tầng?

Bạn có thể thắc mắc: "tại sao không có 1 class FileTextReader gom tất cả?"

Trả lời: flexibility. Mỗi tầng có thể thay:

// Doc tu network socket
BufferedReader r = new BufferedReader(
    new InputStreamReader(socket.getInputStream(), UTF_8));

// Doc tu decompress GZIP
BufferedReader r = new BufferedReader(
    new InputStreamReader(new GZIPInputStream(fis), UTF_8));

Cùng BufferedReader + readLine, source có thể là file, network, zip, gzip — chỉ thay component source. Decorator pattern cho phép compose linh hoạt.

FileReader shortcut — và bug

Java có FileReader — wrap sẵn FileInputStream + InputStreamReader:

try (BufferedReader r = new BufferedReader(new FileReader("log.txt"))) {
    ...
}

Trông ngắn gọn hơn hẳn. Nhưng:

new FileReader("log.txt")

Constructor này dùng platform default charset. Trên Linux/macOS là UTF-8. Trên Windows thường là Windows-1252 (cp1252).

File UTF-8 có tiếng Việt, đọc trên Windows với default cp1252 → diacritics vỡ. Code pass CI (Linux) nhưng fail production (Windows).

Java 11+ thêm constructor nhận charset:

new FileReader("log.txt", StandardCharsets.UTF_8)

Với Java cũ hơn 11, dùng InputStreamReader explicit:

new InputStreamReader(new FileInputStream("log.txt"), StandardCharsets.UTF_8)

Hoặc modern hơn — NIO.2 (bài Path và Files):

Files.newBufferedReader(Path.of("log.txt"))   // Default UTF-8
⚠️ Charset phải explicit

Bất kỳ khi nào convert byte ↔ char — luôn pass charset. new String(bytes), str.getBytes(), new FileReader(path), new InputStreamReader(in) đều dùng default — potential bug. Dùng version có charset parameter.

5. Ghi file — BufferedWriter

try (BufferedWriter w = new BufferedWriter(
        new OutputStreamWriter(
            new FileOutputStream("out.txt"),
            StandardCharsets.UTF_8))) {
    w.write("Hello\n");
    w.write("World\n");
}   // Close auto flush

Pattern đối xứng đọc. Ghi buffer char → buffer byte → file.

Flush — quan trọng

Write vào BufferedWriter không ngay lập tức xuống disk. Nó vào buffer trong memory. Close writer tự flush buffer → ghi disk.

Nếu JVM crash trước close → mất data trong buffer.

Manual flush:

w.write("critical data");
w.flush();   // Ep xuong disk ngay

Tuy nhiên, flush() chỉ đảm bảo data xuống OS, chưa chắc xuống disk thật. OS có page cache — data có thể ở RAM OS chờ flush. Muốn thật sự xuống disk:

FileOutputStream fos = new FileOutputStream("data.log");
// ... write ...
fos.getFD().sync();   // fsync syscall - ghi thuc su xuong disk, cham

Hoặc dùng StandardOpenOption.SYNC với NIO.2 (bài Path và Files).

Dùng khi nào:

  • Flush thường: default cho mọi close() — đủ 99% case.
  • flush() thủ công: transaction log, audit trail giữa chừng.
  • fsync: DB transaction, điều kiện durability nghiêm ngặt — ví dụ WAL (write-ahead log: file ghi tuần tự mọi thay đổi trước khi áp dụng vào dữ liệu chính, nền tảng durability của database). Chậm ~5-50ms mỗi call — không dùng cho mỗi write.

6. Vì sao buffer quan trọng — chi tiết syscall

Đây là core concept, đáng ngâm kỹ.

Con số cụ thể

OpThời gian
Method call Java (intra-JVM)~1 ns
Syscall (user-space → kernel)~1-5 μs = 1000-5000 ns
Read 1 block disk (SSD)~50-100 μs
Read 1 block disk (HDD)~5-10 ms

Đọc 100MB byte-by-byte

FileInputStream fis = new FileInputStream("100mb.dat");
while (fis.read() != -1) { ... }

fis.read() đọc 1 byte = 1 syscall.

100MB = 100 × 1024 × 1024 byte ≈ 100 triệu byte → ~100 triệu syscall. 100 triệu syscall × 1.5μs ≈ 150 giây — khớp con số "~2-3 phút" ở đầu bài. Gần như toàn bộ thời gian đốt vào chuyển context user-space ↔ kernel, không phải đọc disk.

Đọc với buffer 8KB

BufferedInputStream in = new BufferedInputStream(new FileInputStream("100mb.dat"));
while (in.read() != -1) { ... }

Internal buffer 8KB. Lần đầu read(): gọi 1 syscall load 8KB vào buffer, trả byte đầu. 8191 byte sau đọc từ buffer, không syscall.

100MB / 8KB = 12800 syscall. 12800 × 1.5μs ≈ 20ms overhead syscall — thời gian tổng (~0.3 giây) giờ do disk đọc tuần tự 100MB quyết định.

Số syscall giảm 8000×, thời gian tổng giảm hàng trăm lần. Free (không viết gì thêm, chỉ wrap).

Trade-off của buffer

Buffer lớn giảm syscall nhưng tốn memory. Buffer 1MB → 125 syscall cho 100MB file — chỉ nhanh thêm chút ít so với 8KB. Không đáng. Default 8KB là sweet spot — match page size OS, balance memory vs syscall.

7. Decorator pattern — tư duy thiết kế

Nhìn lại chain đọc gzip text:

InputStream fileStream = new FileInputStream("data.txt.gz");      // source
InputStream gzStream   = new GZIPInputStream(fileStream);          // decompress
InputStream bufStream  = new BufferedInputStream(gzStream);        // buffer byte
Reader reader          = new InputStreamReader(bufStream, UTF_8);  // byte -> char
BufferedReader br      = new BufferedReader(reader);               // buffer char + readLine

5 class wrap nhau, mỗi class 1 trách nhiệm. Thêm chức năng là thêm wrapper, bỏ chức năng là bỏ wrapper.

Cần decrypt? Thêm new CipherInputStream(gzStream, cipher). Cần count byte read? Thêm custom wrapper count. Cần đọc chỉ N byte đầu? Wrap LimitedInputStream.

Pattern: Open/Closed principle — extend behavior bằng wrapper mới, không sửa class base.

Nhược: syntax dài, 5 class lồng nhau đọc khó. NIO.2 (java.nio.file) simplify nhiều — bài Path và Files.

8. PrintWriter, PrintStream, System.out

PrintWriter wrap Writer, thêm println, printf, print:

try (PrintWriter pw = new PrintWriter(new BufferedWriter(
        new FileWriter("out.txt", StandardCharsets.UTF_8)))) {
    pw.println("Line 1");
    pw.printf("Price: %.2f%n", 12.5);
}

System.outPrintStream wrap stdout, auto-flush sau mỗi println — in 100k dòng trong loop mất vài giây chỉ vì flush liên tục. Batch job print nhiều nên wrap lại:

PrintStream buffered = new PrintStream(
    new BufferedOutputStream(System.out), false);   // autoFlush = false
System.setOut(buffered);
// ... in nhieu dong ...
System.out.flush();   // Force flush khi can

Với script CLI output ngắn, auto-flush là đúng (thấy output ngay).

9. Pitfall tổng hợp

Nhầm 1: Không dùng buffer.

try (FileInputStream fis = new FileInputStream("big.dat")) {
    int b;
    while ((b = fis.read()) != -1) process(b);   // 1 byte = 1 syscall
}

✅ Wrap BufferedInputStream:

try (InputStream in = new BufferedInputStream(new FileInputStream("big.dat"))) { ... }

Nhầm 2: FileReader không specify charset.

new FileReader("utf8.txt");   // Platform default, bug Windows

✅ Java 11+: new FileReader(path, StandardCharsets.UTF_8) hoặc NIO.2 Files.newBufferedReader(path).

Nhầm 3: Không close stream.

BufferedReader r = new BufferedReader(new FileReader("x"));
// Dung xong khong close -> leak file descriptor

try-with-resources. Linux default limit 1024 FD, macOS thấp hơn — leak nhanh hết.

Nhầm 4: Đọc bytes rồi new String(bytes) không charset.

byte[] data = Files.readAllBytes(path);
String s = new String(data);   // Platform default

new String(data, StandardCharsets.UTF_8).

Nhầm 5: Ghi binary qua Writer.

Writer w = new FileWriter("image.png");   // Writer cho char - corrupt binary
w.write(byteData);

✅ Dùng OutputStream cho binary: new FileOutputStream("image.png").

10. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: JEP 400 (Java 18) là thay đổi quan trọng — từ Java 18, FileReader default UTF-8 trên mọi platform, fix bug kinh điển "chạy trên Windows vỡ chữ tiếng Việt". Code chạy trên Java 18+ an toàn hơn. Nhưng nếu code deploy mix versions (JDK 11 local, JDK 18 prod), vẫn phải explicit charset để nhất quán.

11. Tóm tắt

  • 2 nhánh API: byte stream (InputStream/OutputStream) cho binary, char stream (Reader/Writer) cho text.
  • BufferedXxx wrap cho hiệu năng — giảm số syscall hàng nghìn lần. Default 8KB đủ.
  • Decorator pattern: ghép nhiều wrapper cho chức năng (compress, encrypt, buffer, bridge byte/char).
  • Luôn specify charset explicit — tránh FileReader không arg dùng platform default (bug latent Windows).
  • try-with-resources bắt buộc cho mọi I/O — tránh leak file descriptor.
  • BufferedReader.readLine() tiện đọc text line-by-line; trả null khi EOF.
  • flush() đẩy buffer xuống OS; fsync / getFD().sync() đẩy thực sự xuống disk (chậm).
  • Syscall ~1-2μs, method call ~1ns — chênh hàng nghìn lần. Lý do buffer critical.
  • System.out.println auto-flush — chậm trong loop lớn; wrap BufferedOutputStream nếu cần.
  • Java 18+: UTF-8 default trên mọi platform (JEP 400).

12. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao BufferedInputStream nhanh hơn FileInputStream hàng trăm lần?

FileInputStream.read() đọc 1 byte = 1 syscall xuống kernel. Syscall tốn ~1-2μs do chuyển context user-space → kernel-space. Đọc 100MB byte-by-byte = ~100 triệu syscall × 1.5μs ≈ 150 giây.

BufferedInputStream có internal buffer 8KB. Lần đầu read(): 1 syscall đọc 8192 byte vào buffer, trả byte đầu. 8191 byte sau lấy thẳng từ buffer — chỉ là method call Java ~1ns, không syscall. Tổng syscall giảm 8000×.

100MB với BufferedInputStream: 12800 syscall ≈ 20ms overhead — thời gian tổng (~0.3 giây) do disk quyết định. Thời gian giảm hàng trăm lần, "miễn phí" — chỉ thêm dòng wrap.

Đây là pattern ai cũng dùng — wrap BufferedInputStream/BufferedReader quanh FileXxx gần như luôn đúng.

Q2
Khác biệt chi tiết giữa byte stream và char stream?
  • Byte stream (InputStream/OutputStream): đơn vị 1 byte = 8 bit. Không hiểu encoding. Dùng cho binary: ảnh, PDF, zip, bytecode, protocol binary (Protobuf, Avro).
  • Char stream (Reader/Writer): đơn vị char = UTF-16 code unit trong JVM. Cần charset để chuyển byte ↔ char.

Bridge: InputStreamReader(in, charset) wrap byte stream thành char stream, decode byte → char. OutputStreamWriter(out, charset) ngược lại.

Lỗi thường gặp:

  • Đọc file PNG bằng Reader → decode byte thành char → file corrupt khi ghi lại.
  • Đọc file text bằng InputStream → được byte thô → phải new String(bytes, charset) thủ công.

Rule: text → char stream + charset explicit. Binary → byte stream.

Q3
Vì sao new FileReader("x.txt") là bug tiềm ẩn? Nó khác gì với new FileReader("x.txt", UTF_8)?

FileReader(String) không arg charset dùng platform default charset, lấy từ system property file.encoding:

  • Linux / macOS: default UTF-8.
  • Windows: thường Windows-1252 (cp1252).

File UTF-8 có tiếng Việt, đọc trên Windows với cp1252 → diacritics vỡ. Code pass CI (Linux) nhưng fail production (Windows). Bug silent — không exception, chỉ là text không đọc được.

Java 11+: FileReader(path, charset) explicit.

Java 18+ (JEP 400): default charset đổi thành UTF-8 trên mọi platform. new FileReader("x.txt") Java 18+ an toàn hơn. Nhưng code deploy mix versions (dev JDK 11, prod JDK 18) vẫn phải explicit để nhất quán.

Rule production: luôn explicit charset cho mọi conversion byte ↔ char. Không rely vào default, không phụ thuộc Java version.

Q4
Khi nào nên gọi flush() thủ công, khi nào cần fsync?

Data ghi đi qua 2 tầng buffer:

  1. JVM buffer → OS: BufferedWriter.flush(), OutputStream.flush() push data xuống OS. Nhanh.
  2. OS cache → disk: fos.getFD().sync(), FileChannel.force(true) gọi fsync syscall — ghi thực sự xuống disk. Chậm ~5-50ms.

flush() thủ công khi:

  • Interactive output — user phải thấy text trước khi prompt nhập.
  • Long-running writer (file log chạy vài giờ) — thấy data mới giữa chừng.
  • Trước khi switch context (close connection, rename file).

fsync khi:

  • Transaction log DB (WAL) — mất data unacceptable nếu power-off.
  • Atomic file swap — đảm bảo tmp file thực sự xuống disk trước rename.
  • Financial / medical audit trail — compliance requirement.

Không dùng fsync mỗi write — ~5-50ms mỗi call → throughput chết. Batch operation rồi fsync cuối, hoặc fsync theo interval (mỗi giây 1 lần).

Q5
Đoạn sau có vấn đề gì? BufferedReader r = new BufferedReader(new FileReader("x.txt")); r.readLine();

2 vấn đề:

  1. Không close — file descriptor leak. JVM có limit FD (~1024 trên Linux, ít hơn macOS). Chạy lâu (service 24/7) → "Too many open files" error, app crash.
  2. Default charsetFileReader(String) dùng platform default. Bug encoding như đã thảo luận.

Fix đầy đủ:

try (var r = new BufferedReader(new FileReader("x.txt", StandardCharsets.UTF_8))) {
  String line = r.readLine();
  // ...
}

Hoặc NIO.2 modern hơn (bài 02 — Path và Files):

try (var r = Files.newBufferedReader(Path.of("x.txt"))) {
  // Default UTF-8, close auto qua try-with-resources
}

NIO.2 khuyến khích cho code mới — gọn hơn, charset default UTF-8, API consistent.

Q6
Tại sao decorator pattern phù hợp cho I/O stream?

I/O có nhiều concerns trực giao (orthogonal): source (file, network, memory), buffer, compress, decrypt, encode/decode, parse. Mỗi concern độc lập — user có thể cần bất kỳ combination nào.

Nếu viết 1 class/method riêng cho mỗi combination → exponential: 3 source × 2 buffer × 2 compress × 2 encrypt = 24 class. Không maintainable.

Decorator pattern: tách mỗi concern thành 1 class wrap base (InputStream). User compose theo nhu cầu:

// Binh thuong
new BufferedInputStream(new FileInputStream(path))

// Decompress + encrypt + buffer
new BufferedInputStream(
  new CipherInputStream(
      new GZIPInputStream(
          new FileInputStream(path)),
      cipher))

Mỗi class 1 trách nhiệm (Single Responsibility), class mới thêm không sửa cũ (Open/Closed).

Trade-off: syntax verbose với chain dài. NIO.2 simplify nhiều case thường dùng (bài 02 — Path và Files). Nhưng pattern decorator vẫn hữu ích cho custom: wrap CountingInputStream để metric, LimitedInputStream để rate-limit, etc.

Bài tiếp theo: Path và Files — NIO.2 thay java.io.File

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên