Backend

99 Ngày Java — Ngày 54: Stream API nhập môn

SSite Admin
19 tháng 09, 2026 8 phút đọc 0 lượt xem
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 độ.

Sketchnote Ngày 54: Stream API nhập môn — dây chuyền ba phần với nguồn trung gian và kết thúc, không có phép kết thúc thì không chạy gì, tính lười và ngắt sớm, lọc trước sắp sau, và khi nào vòng lặp vẫn thắng

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ề Stream là 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ẳn Collection.

  • 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, limit dừ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.iterateStream.generate — nhưng quên limit là 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òn filter().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.
  • parallelStream không phải nút tăng tốc: nó dùng chung ForkJoinPool.commonPool vớ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 removeIf 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 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 println vào filtermap rồi dùng findFirst — đọc thứ tự in ra.

  • Đo thời gian sorted().filter()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ới parallelStream() 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, limitskip. Hẹn gặp lại!

S

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.

Bài viết liên quan

99 Ngày Spring — Ngày 54: @SpringBootTest

Khi nào test tích hợp mới xứng đáng, vì sao RANDOM_PORT là lựa chọn đúng, cache context quyết định bộ test chạy hai phút hay bốn mươi phút — và vì sao @WithMockUser hoàn toàn vô nghĩa ở đây.

19 thg 9, 20268 phút0
99 Ngày Spring — Ngày 53: @DataJpaTest

Mỗi test là một transaction bị rollback, flush và clear quyết định test có chứng minh được gì không, vì sao "xanh trên H2 chết trên production" làm cả đội mất niềm tin — và Testcontainers với @ServiceConnection.

18 thg 9, 20268 phút6
99 Ngày Java — Ngày 53: Method reference

Bốn loại và loại thứ ba hay nhầm nhất, khi nào method reference dễ đọc hơn lambda và khi nào không, cái bẫy NPE nổ ngay lúc tạo reference — cùng nơi nó đáng giá nhất: Comparator và groupingBy.

18 thg 9, 20267 phút5