Hai đoạn mã dưới đây làm cùng một việc: đếm số dòng kết thúc bằng muc-7 trong một file CSV 130 MB.
// Cách một
Files.readAllLines(tep).stream().filter(s -> s.endsWith("muc-7")).count();
// Cách hai
try (var s = Files.lines(tep)) {
s.filter(x -> x.endsWith("muc-7")).count();
}
Chạy với heap 128 MB:
--- heap 128 MB, readAllLines ---
at java.base/java.io.BufferedReader.readLine(BufferedReader.java:436)
at java.base/java.nio.file.Files.readAllLines(Files.java:3408)
OutOfMemoryError
--- heap 128 MB, Files.lines ---
Files.lines -> 30000 dòng, 127 ms
Cách hai còn chạy được với heap 61 MB — chưa bằng nửa kích thước file:
--- heap 64 MB, Files.lines ---
File 130 MB, heap tối đa 61 MB
Files.lines -> 30000 dòng, 142 ms
Khác biệt nằm ở một chữ: lười.
Vì sao được
Files.readAllLines đúng như tên: đọc toàn bộ file vào một List<String> rồi mới trả về. Với 3 triệu dòng, đó là 3 triệu đối tượng String sống cùng lúc trong heap — thường tốn nhiều hơn kích thước file trên đĩa, vì mỗi String còn có phần đầu đối tượng và con trỏ.
Files.lines trả về một Stream lười: nó chỉ đọc một dòng khi có ai hỏi tới, xử lý xong thì dòng đó thành rác cho GC dọn. Tại mọi thời điểm, bộ nhớ chỉ giữ một dòng.
Đây chính là cơ chế "đi dọc thay vì đi ngang" ở bài Stream đầu chặng, áp dụng vào I/O. Từng dòng đi hết chuỗi filter → count rồi mới tới dòng sau.
Hệ quả: kích thước file không còn bị giới hạn bởi bộ nhớ. Bạn xử lý được file 50 GB trên máy 8 GB RAM, miễn là không gom hết vào một List ở cuối.
Ngắt sớm: không đọc phần thừa
Tìm dòng đầu tiên khớp một điều kiện:
readAllLines rồi tìm : 410 ms
Files.lines rồi tìm : 116 ms
Nhanh hơn 3,5 lần, và lý do không phải tốc độ đọc — mà là Files.lines dừng lại ngay khi findFirst có kết quả. Dòng thứ sáu trở đi trong file không bao giờ được đọc.
readAllLines thì luôn đọc hết 3 triệu dòng trước khi bạn kịp lọc.
Với file log vài GB mà bạn chỉ cần dòng lỗi đầu tiên, khác biệt này là giữa vài chục mili giây và vài phút.
Bắt buộc phải đóng
Files.lines là một trong số rất ít Stream giữ tài nguyên hệ thống — nó mở một file handle.
try (var s = Files.lines(tep)) {
...
}
Không đóng thì file handle rò rỉ. Trên máy chủ xử lý hàng nghìn file, bạn sẽ gặp Too many open files — một lỗi rất khó truy về nguyên nhân, vì nó xuất hiện ở chỗ khác hoàn toàn.
Danh sách những stream cần đóng ngắn thôi: Files.lines, Files.list, Files.walk, Files.find, và stream đến từ một Reader bạn tự mở. Còn list.stream() hay Stream.of(...) thì không.
Cách nhớ: stream đến từ hệ thống tệp thì phải đóng.
Những phép làm mất tính lười
Không phải chuỗi Stream nào cũng giữ được ưu thế trên. Vài phép buộc phải gom hết dữ liệu:
sorted() — không thể sắp xếp khi chưa thấy hết. Nó gom toàn bộ vào bộ nhớ, và bạn quay lại vấn đề của readAllLines.
distinct() — phải nhớ những gì đã gặp. Với dữ liệu ít trùng lặp thì gần như nhớ hết.
collect(toList()) ở cuối — hiển nhiên, vì kết quả là một danh sách trong bộ nhớ.
count() sau các phép biến đổi — thực ra vẫn lười, nhưng phải duyệt hết nên không ngắt sớm được.
Nên với file lớn, quy tắc là: lọc và biến đổi thoải mái, nhưng cẩn thận trước sorted, distinct và collect.
Cần sắp xếp dữ liệu lớn hơn bộ nhớ thì đó là bài toán sắp xếp ngoài — chia file thành khúc, sắp từng khúc, rồi trộn. Stream không giải hộ được.
Xử lý file lớn cho đúng
Vài mẫu tôi dùng thường xuyên:
Lọc rồi ghi ra file khác — không bao giờ gom vào bộ nhớ:
try (var vao = Files.lines(nguon);
var ra = Files.newBufferedWriter(dich)) {
vao.filter(s -> s.contains("ERROR")).forEach(s -> {
try { ra.write(s); ra.newLine(); }
catch (IOException e) { throw new UncheckedIOException(e); }
});
}
Chú ý UncheckedIOException: lambda không nhận checked exception, nên phải bọc lại — đúng hạn chế đã nói ở bài giao diện hàm.
Bỏ dòng tiêu đề bằng skip(1), hoặc dropWhile nếu phần đầu file có nhiều dòng chú thích.
Gom thống kê thay vì gom dữ liệu. collect(summarizingLong(...)) giữ năm con số, không giữ ba triệu dòng.
Xử lý theo lô khi cần ghi vào CSDL: gom từng 1000 dòng rồi ghi một lần, thay vì gom hết rồi ghi. Stream không có sẵn phép chia lô, nên đây là chỗ vòng lặp thường rõ hơn.
Bảng mã và hiệu năng đọc
Files.lines(path) mặc định dùng UTF-8 và ném ngoại lệ nếu gặp byte không hợp lệ — điều này tốt cho tính đúng đắn nhưng có thể làm hỏng việc xử lý file cũ. Muốn bỏ qua thì mở BufferedReader với CharsetDecoder cấu hình riêng rồi gọi .lines().
Về tốc độ, Files.lines đủ nhanh cho hầu hết việc. Cần nhanh hơn nữa thì đọc theo byte và tự tách dòng — nhưng đó là tối ưu cuối cùng nên nghĩ tới, sau khi đã đo.
Nguồn lười khác
Ngoài file, còn vài nguồn giữ được tính lười:
BufferedReader.lines() — đọc từ bất cứ đâu, kể cả socket hay System.in.
Stream.iterate và Stream.generate — sinh vô hạn, đã gặp ở bài đầu chặng.
Pattern.splitAsStream(chuoi) — tách chuỗi lười, khác String.split vốn tạo cả mảng ngay.
ResultSet của JDBC không trả về stream, nhưng nhiều thư viện bọc lại được — và khi đó phải cẩn thận: kết nối phải sống suốt thời gian duyệt stream.
Thử ba mươi giây
Sinh một file vài trăm MB, rồi chạy hai đoạn ở đầu bài với -Xmx128m.
Một bên OutOfMemoryError, một bên xong trong một phần mười giây. Nếu trong dự án của bạn có chỗ nào gọi readAllLines hay readString trên file do người dùng tải lên, đó là một sự cố đang chờ ngày dữ liệu đủ lớn.
Ngày mai: vòng lặp hay Stream — ba bài toán, ba phép đo, và kết luận cho từng trường hợp thay vì một câu trả lời chung.