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.
Pipeline đầy đủ cho đọc text UTF-8 có readLine — kèm chỗ nó chạm xuống kernel:

Hai chi tiết hình nói mà danh sách class không: tầng đệm quyết định số syscall, và charset chỉ xuất hiện đúng một chỗ.
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 class | Dùng cho |
|---|---|---|---|
| Byte stream | byte (8 bit) | InputStream, OutputStream | Binary data: ảnh, PDF, zip, bytecode |
| Char stream | char (UTF-16 trong JVM) | Reader, Writer | Text — 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
Mỗi nhánh có một abstract class gốc; class con là các "vai" trong pipeline vừa xem:
InputStream→FileInputStream,ByteArrayInputStream(source),BufferedInputStream(đệm),DataInputStream(kiểu nguyên thuỷ),ObjectInputStream(bài 04).Reader→InputStreamReader(decode),FileReader(con của nó, wrap sẵnFileInputStream),BufferedReader(đệm char +readLine).
Class InputStreamReader là bridge giữa 2 nhánh: wrap InputStream, decode byte thành char với charset. Mọi đường từ byte sang text đều qua nó — FileReader chỉ giấu bước đó đi, giấu luôn cả charset (mục 4).
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ùngread(), hoặc block nếu dùngread(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 đabuf.lengthbyte vàobuf, trả số byte thực đọc được. Có thể ít hơnbuf.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 — bằng 2 page bộ nhớ OS (page trên x86/ARM thường là 4KB), một syscall nạp trọn vào buffer. 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:
FileInputStream("log.txt"): byte từ disk.InputStreamReader(fis, UTF_8): decode byte thành char. Bắt buộc pass charset.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
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. Nếu JVM crash trước close → mất data trong buffer. Cần đẩy data đi giữa chừng (log chạy dài, trước khi đổi context) thì gọi w.flush() thủ công.
Lưu ý flush() chỉ đưa data xuống OS, chưa chắc xuống disk thật (OS còn page cache). Chuyện durability nghiêm ngặt — fsync, StandardOpenOption.SYNC — thuộc bài Path và Files.
6. Vì sao buffer giảm thời gian đọc hàng trăm lần?
Đây là core concept, đáng ngâm kỹ.
Con số cụ thể
| Op | Thời gian |
|---|---|
| Method call Java (intra-JVM) | ~1 ns |
| Syscall (user-space → kernel) | ~1-2 μs = 1000-2000 ns |
| Read 1 block disk (SSD) | ~50-100 μs |
| Read 1 block disk (HDD) | ~5-10 ms |
Cùng vòng lặp read(), khác nhau ở số lần xuống kernel

fis.read() không tham số đọc 1 byte = 1 syscall. 100MB ≈ 100 triệu byte → ~100 triệu syscall × 1.5μs ≈ 150 giây, khớp con số "~2-3 phút" ở đầu bài.
Wrap thêm BufferedInputStream: lần read() đầu tốn 1 syscall load 8KB vào internal buffer rồi trả byte đầu, 8191 byte sau lấy trong buffer. 100MB / 8KB = 12800 syscall ≈ 20ms — thời gian tổng (~0.3 giây) giờ do disk đọc tuần tự quyết định.
Đĩa vẫn đọc đúng 100MB tuần tự ở cả hai cột — thứ duy nhất thay đổi là số lần qua ranh giới kernel, giảm 8000×.
Trade-off của buffer
Buffer lớn giảm syscall nhưng tốn memory. Buffer 1MB → 100 syscall cho 100MB file — chỉ nhanh thêm chút ít so với 8KB. Không đáng. Default 8KB là sweet spot — đủ lớn để amortize chi phí syscall, đủ nhỏ để không phí memory.
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. 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").
9. 📚 Deep Dive Oracle
Spec / reference chính thức:
- java.io package — tổng quan stream hierarchy.
- InputStream abstract, Reader abstract — base class.
- Charset & StandardCharsets.
- Oracle Tutorial: I/O Streams — hands-on.
- JEP 400: UTF-8 by default — Java 18 đổi default charset thành UTF-8 trên tất cả platform.
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.
Liên hệ các bài khác
- Path và Files — NIO.2 — API hiện đại thay
java.io.File:Files.newBufferedReaderdefault UTF-8 (fix bug charset bài này), và toàn bộ chuyện durability (fsync,StandardOpenOption.SYNC). - Đọc file với Stream API —
Files.lines()kết hợp mô hình stream của bài này với functional pipeline. - Serialization — cơ chế —
ObjectInputStream/ObjectOutputStreamchính là một decorator nữa trong hierarchy byte stream bạn vừa học. - Try-with-resources — khoá Java — nền tảng của mọi block
try (...)trong bài; hiểu nó để không leak file descriptor.
10. Tóm tắt
- 2 nhánh API: byte stream (
InputStream/OutputStream) cho binary, char stream (Reader/Writer) cho text; luôn specify charset explicit —FileReaderkhông arg dùng platform default, bug latent Windows khi đọc UTF-8. BufferedXxxwrap giảm syscall hàng nghìn lần — default 8 KB đủ. Mỗi syscall ~1–2 μs vs method call ~1 ns: chênh hàng nghìn lần, lý do buffer critical.try-with-resourcesbắt buộc — tránh leak file descriptor; Linux mặc định giới hạn 1024 FD/process (ulimit -n), dễ cạn khi open nhiều file.BufferedReader.readLine()trảnullkhi EOF, không phải empty string — checknullđể kết thúc vòng lặp đúng cách.flush()đẩy buffer xuống OS — không đảm bảo ghi vào disk (cầnfsync). Java 18+ (JEP 400): UTF-8 là default trên mọi platform, không còn phụ thuộc locale.
11. Tự kiểm tra
- Q1Vì sao
BufferedInputStreamnhanh hơnFileInputStreamhàng trăm lần? - Q2Bạn đọc một file PNG bằng
Readerrồi ghi lại bằngWriter— chuyện gì xảy ra ở mức byte, và vì sao? - Q3Vì sao
new FileReader("x.txt")là bug tiềm ẩn? Nó khác gì vớinew FileReader("x.txt", UTF_8)? - Q4Đoạn sau có vấn đề gì?
BufferedReader r = new BufferedReader(new FileReader("x.txt")); r.readLine(); - Q5Tại sao decorator pattern phù hợp cho I/O stream?
Bài tiếp theo: Path và Files — NIO.2 thay java.io.File
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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
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