Java — Từ Zero đến Senior/I/O cổ điển — InputStream, Reader, buffered wrapper
~22 phútI/O & NIOMiễn phí

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.

Code sau đọc file 100MB:

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

Thời gian: ~30 giây.

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. 100× chênh lệch với cùng API, cùng logic, chỉ thêm BufferedInputStream.

Lý do nằm ở level OS: read() một byte = 1 syscall xuống kernel, mỗi syscall tốn 1-5 microsecond. Đọc 100MB byte-by-byte = 100 triệu syscall. BufferedInputStream gom 8KB 1 lần syscall → giảm syscall 8000×.

Đâ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: mỗi char 2-4 byte.
  • Windows-1252, ISO-8859-1: mỗi char 1 byte.
  • 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ài 7.3 đã học — bắt buộc cho I/O tránh leak file descriptor.
  • in.read(buf): đọc tối đa buf.length byte vào buf, trả số byte thực đọc được. Có thể < 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 (>1GB) có thể tăng lên 64KB hoặc 128KB. File nhỏ (< 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 ZIP entry
BufferedReader r = new BufferedReader(
    new InputStreamReader(zipStream, 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"))) {
    ...
}

Vẻ ngắn gọ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 < 11, dùng InputStreamReader explicit:

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

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

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 11.2).

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 (vd WAL log). 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 × 3μs = 300 giây = 5 phút.

Chưa kể disk I/O — mỗi syscall thường trả về 1 page nhưng bạn chỉ dùng 1 byte → phần còn lại đọc lại sau (trừ OS cache).

Đọ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 × 3μs = 38ms.

Khác biệt 8000×. 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ỉ thêm ~35% nhanh hơn 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 = thêm wrapper. Bỏ chức năng = bỏ wrapper.

Cần decrypt? Thêm CipherInputStream:

InputStream cipherStream = 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 (SOLID, module 6) — extend behavior bằng wrapper, 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 11.2.

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 file descriptor stdout. Auto-flush sau println — mỗi println flush ngay.

Hệ quả:

// Cham - flush sau moi println
for (int i = 0; i < 100_000; i++) {
    System.out.println(i);
}
// Mat vai giay

Wrap BufferedOutputStream để tắt auto-flush:

PrintStream buffered = new PrintStream(
    new BufferedOutputStream(System.out), false);   // autoFlush = false
System.setOut(buffered);

for (int i = 0; i < 100_000; i++) {
    System.out.println(i);
}
System.out.flush();   // Force flush khi can
// Nhanh 10x

Với script CLI output ngắn, auto-flush là đúng (thấy output ngay). Với batch job print nhiều, optimize cần thiết.

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:

OutputStream out = new FileOutputStream("image.png");
out.write(byteData);

Nhầm 6: Process toàn bộ buf không check return value.

byte[] buf = new byte[8192];
while (in.read(buf) != -1) {
    process(buf);   // Process ca phan cu neu lan cuoi khong full
}

✅ Check:

int n;
while ((n = in.read(buf)) != -1) {
    process(buf, n);
}

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 syscall 1000-10000 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 ~3μs, method call ~1ns — chênh 3000×. 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-5μs do chuyển context user-space → kernel-space. Đọc 100MB byte-by-byte = 100 triệu syscall × 3μs = 300 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 × 3μs = 38ms. Khác biệt 8000×, "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 11.2):

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 (SRP), class mới thêm không sửa cũ (OCP). Theo SOLID bài 6.

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

Bài tiếp theo: NIO.2 — Path và Files, API hiện đại

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