99 Ngày Java — Ngày 54: Stream API nhập môn
Ba ngày vừa rồi — lambda, functional interface, method reference — đều là chuẩn bị cho hôm nay. Stream API là lý do Java 8 được gọi là bước ngoặt, và nó không chỉ là "cách viết vòng lặp cho đẹp". Điểm khác biệt thật nằm ở tính lười: stream không chạy gì cả cho tới khi bạn yêu cầu kết quả — và chính điều đó cho bạn ngắt sớm, dữ liệu vô hạn, và một hệ quả bất ngờ: thứ tự các phép có ảnh hưởng thật tới tốc độ.

Dây chuyền ba phần — và luật cốt lõi
Mọi stream đều có đúng ba phần: nguồn, các phép trung gian (bao nhiêu cũng được), và đúng một phép kết thúc. Luật quan trọng nhất: không có phép kết thúc thì không có gì chạy cả — viết cả dây chuyền dài mà quên nó thì chương trình im lặng không làm gì, và đó là bất ngờ số một của người mới.
// STREAM — một DÂY CHUYỀN ba phần, và luôn luôn đúng ba phần.
List<String> ten = nhanVien.stream() // ① NGUỒN
.filter(nv -> nv.luong() > 20_000_000) // ② TRUNG GIAN (bao nhiêu cũng được)
.map(NhanVien::hoTen) // ② TRUNG GIAN
.sorted() // ② TRUNG GIAN
.toList(); // ③ KẾT THÚC (đúng MỘT, và bắt buộc)
// ⭐ LUẬT CỐT LÕI: KHÔNG CÓ PHÉP KẾT THÚC THÌ KHÔNG CÓ GÌ CHẠY CẢ.
nhanVien.stream().filter(nv -> { System.out.println("kiểm tra"); return true; });
// ➜ Chạy dòng trên: KHÔNG in ra gì hết. Không một chữ.
// Vì chưa có phép kết thúc, nên chưa có gì được thực hiện.
// Đây là lỗi số một của người mới: viết cả dây chuyền rồi thắc mắc sao không chạy.
// PHÂN BIỆT hai loại chỉ bằng KIỂU TRẢ VỀ, khỏi học thuộc:
// trả về Stream → TRUNG GIAN (lười, chưa chạy)
// trả về thứ khác → KẾT THÚC (chạy ngay)
// filter, map, sorted, distinct, limit → Stream → trung gian
// toList, count, forEach, reduce, anyMatch → khác → kết thúc
// ❗ STREAM DÙNG MỘT LẦN RỒI BỎ — khác hẳn Collection:
var s = danhSach.stream();
s.forEach(System.out::println);
s.count(); // 💥 IllegalStateException: stream has already been operated upon or closed
// ➜ Cần dùng lại thì tạo stream mới từ nguồn. Stream KHÔNG phải cấu trúc dữ liệu;
// nó là một BẢN MÔ TẢ công việc sẽ làm trên dữ liệu.Phân biệt hai loại bằng kiểu trả về, khỏi học thuộc: trả về
Streamlà trung gian, trả về thứ khác là kết thúc.Stream dùng một lần rồi bỏ — gọi lần hai là
IllegalStateException, khác hẳnCollection.Stream không phải cấu trúc dữ liệu; nó là một bản mô tả công việc sẽ làm trên dữ liệu.
Lười biếng — và vì sao thứ tự các phép quan trọng
Đây là phần đáng giá nhất bài. Stream không lọc hết cả danh sách rồi mới biến đổi: mỗi phần tử đi hết dây chuyền rồi mới tới phần tử sau. Chính vì thế mà findFirst trên một triệu phần tử có thể chỉ chạm vào ba phần tử.
// LƯỜI BIẾNG — không phải chi tiết học thuật, mà là chuyện HIỆU NĂNG thật.
var ds = List.of("An", "Bình", "Cường", "Dũng", "Én");
var kq = ds.stream()
.filter(s -> { System.out.println("lọc: " + s); return s.length() > 2; })
.map(s -> { System.out.println("biến: " + s); return s.toUpperCase(); })
.findFirst();
// Bạn nghĩ nó lọc CẢ 5 rồi mới biến đổi? KHÔNG PHẢI.
// Kết quả in ra thật sự:
// lọc: An ← "An" dài 2, bị loại
// lọc: Bình ← qua
// biến: Bình ← biến đổi NGAY, không đợi lọc xong cả danh sách
// (DỪNG — findFirst đã có kết quả, ba phần tử còn lại KHÔNG hề được chạm tới)
// ⭐ MỖI PHẦN TỬ ĐI HẾT DÂY CHUYỀN RỒI MỚI TỚI PHẦN TỬ SAU.
// Đây là khác biệt nền tảng so với việc bạn tự viết ba vòng lặp nối nhau,
// và nó cho hai thứ mà vòng lặp thủ công khó có được:
// ① NGẮT SỚM (short-circuit) — có kết quả là dừng, không chạy nốt:
// findFirst, findAny, anyMatch, allMatch, noneMatch, limit
boolean coDonLoi = donHang.stream().anyMatch(DonHang::bịLỗi);
// Một triệu đơn mà đơn thứ 3 đã lỗi → chỉ kiểm tra 3 đơn.
// ② XỬ LÝ DỮ LIỆU VÔ HẠN — điều vòng lặp thường không làm nổi:
Stream.iterate(1, n -> n * 2).limit(10).toList(); // 1,2,4,...,512
Stream.generate(Math::random).limit(5).toList();
// ❗ Quên limit trên stream vô hạn = treo máy. Không có ngoại lệ nào cứu bạn.
// ⭐ HỆ QUẢ THỰC DỤNG: THỨ TỰ CÁC PHÉP CÓ ẢNH HƯỞNG THẬT.
ds.stream().sorted().filter(dieuKien).toList(); // ❌ sắp CẢ triệu rồi mới lọc
ds.stream().filter(dieuKien).sorted().toList(); // ✅ lọc còn 100 rồi mới sắp
// Cùng kết quả, nhưng cái sau nhanh hơn hàng chục lần. Lọc trước, sắp sau.Ngắt sớm:
anyMatch,findFirst,limitdừng ngay khi có câu trả lời — điều mà ba vòng lặp nối nhau không làm được.Dữ liệu vô hạn với
Stream.iteratevàStream.generate— nhưng quênlimitlà treo máy, không ngoại lệ nào cứu bạn.Lọc trước, sắp sau:
sorted().filter()sắp cả triệu rồi mới lọc, cònfilter().sorted()chỉ sắp trăm — cùng kết quả, nhanh hơn hàng chục lần.
Stream hay vòng lặp — câu trả lời thành thật
Stream không phải lúc nào cũng tốt hơn, và ai bảo bạn "luôn dùng stream" là đang bán khẩu hiệu. Nó thắng rõ khi là chuỗi biến đổi có tên; nhưng vòng lặp vẫn thắng khi cần chỉ số, cần break phức tạp, hoặc cần ném checked exception.
// STREAM hay VÒNG LẶP — câu trả lời thành thật, không phải khẩu hiệu.
// ✅ STREAM THẮNG RÕ khi là chuỗi biến đổi có tên:
Map<String, List<NhanVien>> theoPhong = nv.stream()
.filter(n -> n.dangLamViec())
.collect(groupingBy(NhanVien::phongBan));
// Viết bằng vòng lặp: 8 dòng, có computeIfAbsent, dễ sai.
// ✅ STREAM THẮNG khi cần NGẮT SỚM trên dữ liệu lớn, hoặc cần song song:
long soDon = donHang.parallelStream().filter(DonHang::daTra).count();
// ❗ NHƯNG parallelStream KHÔNG phải nút "tăng tốc". Nó chỉ đáng dùng khi
// dữ liệu RẤT lớn (thường trên 10.000 phần tử) VÀ mỗi phép tính đủ nặng.
// Với danh sách nhỏ, chi phí chia việc và gom kết quả còn LỚN HƠN phần tiết kiệm.
// Và nó dùng chung ForkJoinPool.commonPool với cả ứng dụng — một
// parallelStream chạy lâu có thể làm chậm mọi thứ khác. Hãy ĐO trước khi dùng.
// ❌ VÒNG LẶP THẮNG khi cần chỉ số, cần break/continue phức tạp, hoặc cần đổi biến ngoài:
for (int i = 0; i < ds.size(); i++) {
if (ds.get(i).equals(canTim)) { viTri = i; break; } // stream không có "chỉ số"
}
// ❌ VÒNG LẶP THẮNG khi cần ném checked exception (Ngày 52 đã nói):
for (Path p : ds) Files.delete(p); // ✅ IOException khai bình thường
ds.forEach(p -> Files.delete(p)); // ❌ không biên dịch được
// ❌ VÒNG LẶP THẮNG khi dây chuyền dài quá và mất khả năng debug từng bước.
// ⭐ VÀ MỘT QUY TẮC KHÔNG BAO GIỜ ĐƯỢC PHÁ:
// KHÔNG SỬA nguồn dữ liệu trong lúc stream đang chạy trên nó.
ds.stream().forEach(x -> ds.remove(x)); // 💥 ConcurrentModificationException
ds.removeIf(dieuKien); // ✅ dùng đúng công cụ cho việc xóa
// Lambda trong stream nên là hàm THUẦN: nhận vào, trả ra, không đụng gì bên ngoài.
// Phá luật này thì stream song song sẽ cho kết quả sai một cách ngẫu nhiên.parallelStreamkhông phải nút tăng tốc: nó dùng chungForkJoinPool.commonPoolvới cả ứng dụng, và với danh sách nhỏ thì chi phí chia việc còn lớn hơn phần tiết kiệm. Hãy đo trước.Không bao giờ sửa nguồn dữ liệu trong lúc stream chạy trên nó — dùng
removeIfcho việc xóa.Lambda trong stream nên là hàm thuần: nhận vào, trả ra, không đụng gì bên ngoài — phá luật này thì stream song song cho kết quả sai một cách ngẫu nhiên.
Bài tập nhỏ
Viết một dây chuyền không có phép kết thúc và xác nhận nó không in ra gì cả.
Thêm
printlnvàofiltervàmaprồi dùngfindFirst— đọc thứ tự in ra.Đo thời gian
sorted().filter()vàfilter().sorted()trên một triệu phần tử.Gọi hai phép kết thúc trên cùng một stream để gặp
IllegalStateException.So
stream()vớiparallelStream()trên 100 phần tử rồi trên 10 triệu — xem cái nào thắng ở đâu.
Kết lại
Bốn ý gói lại hôm nay: dây chuyền luôn có ba phần và không có phép kết thúc thì không có gì chạy; stream lười nên mỗi phần tử đi hết dây chuyền rồi mới tới phần tử sau — đó là thứ cho bạn ngắt sớm và dữ liệu vô hạn; hệ quả thực dụng là lọc trước, sắp sau, cùng kết quả mà nhanh hơn hàng chục lần; và vòng lặp vẫn thắng khi cần chỉ số, break phức tạp hay checked exception — còn parallelStream thì phải đo trước khi dùng, đừng coi nó là nút tăng tốc. Ngày 55 ta đi kỹ vào các phép trung gian: map, flatMap, distinct, peek, limit và skip. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


