Backend

99 Ngày Java — Ngày 56: Terminal operations

SSite Admin
21 tháng 09, 2026 10 phút đọc 3 lượt xem
99 Ngày Java — Ngày 56: Terminal operations

Hai ngày qua ta xếp một dây chuyền lười: nguồn, rồi các mắt xích. Hôm nay là cái nút chạyphép kết thúc, thứ duy nhất làm cả dây chuyền chuyển động và trả về kết quả. Có đúng năm họ, nhìn kiểu trả về là nhận ra; và bốn cái bẫy quen thuộc: stream chỉ dùng một lần, reduce với identity sai, toList() không sửa được, và allMatch trả true trên tập rỗng.

Sketchnote Ngày 56: terminal operations — năm họ phép kết thúc, stream chỉ dùng một lần, reduce và collect, toList không sửa được, anyMatch ngắt sớm và allMatch trên tập rỗng, findFirst khác findAny ở thứ tự

Năm họ phép kết thúc, và stream chỉ dùng một lần

Không có phép kết thúc thì không có gì chạy — đó là bài của Ngày 54. Hôm nay thêm một tầng: kiểu trả về cho biết bạn đang dùng họ nào. Trả về collection là collect; trả về một số hay một Optional là họ rút gọn; trả về boolean là ba câu hỏi có/không; trả về Optional<T> là lấy một phần tử; trả về voidforEach — và đó là họ đáng nghi nhất.

// PHÉP KẾT THÚC — cái nút "chạy" của cả dây chuyền.

// Ngày 54: không có phép kết thúc thì KHÔNG CÓ GÌ chạy cả.
donHang.stream().filter(d -> d.tong() > 1_000_000);          // ❌ chưa làm gì hết
donHang.stream().filter(d -> d.tong() > 1_000_000).count();  // ✅ giờ mới chạy

// ⭐ Có đúng NĂM họ phép kết thúc — nhìn KIỂU TRẢ VỀ là biết họ nào:
//   ① gom về collection  → collect(...), toList()               : List, Map, Set...
//   ② rút về một giá trị → reduce, count, sum, min, max         : một số, một Optional
//   ③ trả lời có/không  → anyMatch, allMatch, noneMatch        : boolean
//   ④ lấy một phần tử   → findFirst, findAny                   : Optional<T>
//   ⑤ làm gì đó         → forEach, forEachOrdered              : void — tác dụng phụ

// ❗ MỖI STREAM CHỈ DÙNG ĐƯỢC MỘT LẦN — bẫy đầu tiên của người mới:
Stream<DonHang> s = donHang.stream();
long soDon = s.count();
List<DonHang> ds = s.toList();
// 💥 IllegalStateException: stream has already been operated upon or closed
// ➜ Cần hai kết quả thì tạo hai stream, hoặc gom về List rồi làm tiếp từ List.

// ❗ forEach KHÔNG phải vòng for. Nó là phép KẾT THÚC có tác dụng phụ:
donHang.stream().forEach(d -> tong += d.tong());  // ❌ biến ngoài — không compile với lambda,
                                                   //    và sai bét khi chạy song song
long tong = donHang.stream().mapToLong(DonHang::tong).sum();  // ✅ để stream tính

// ⭐ NGUYÊN TẮC: nếu bạn đang forEach để ĐIỀN vào một List hay Map bên ngoài,
//    thì cái bạn cần là collect. forEach chỉ dành cho việc thật sự là tác dụng phụ:
//    ghi log, gửi email, gọi API — và ngay cả khi đó, cân nhắc một vòng for thường.
  • Mỗi stream chỉ dùng được một lần — gọi phép kết thúc thứ hai là IllegalStateException; cần hai kết quả thì tạo hai stream hoặc gom về List trước.

  • forEach không phải vòng for: nếu bạn dùng nó để điền vào một List bên ngoài thì thứ bạn cần là collect; cộng dồn vào biến ngoài thì không compile được với lambda, và sai khi chạy song song.

  • Tác dụng phụ thật (ghi log, gửi email) thì forEach hợp lệ — nhưng một vòng for thường vẫn dễ đọc và dễ debug hơn.

collect và reduce — gom về một chỗ

reduce gấp nhiều phần tử thành một giá trị bất biến — một con số, một boolean. collect thì tạo một container rồi thêm dần vàoList, Map, StringBuilder. Dùng nhầm cái này cho việc của cái kia là nguồn của hai cái bẫy hiệu năng thật, và một cái bẫy UnsupportedOperationException rất hay gặp từ Java 16.

// COLLECT và REDUCE — hai cách gom cả dây chuyền về một chỗ.

// ① REDUCE: gấp N phần tử thành MỘT giá trị bằng một phép toán lặp lại.
int tong = so.stream().reduce(0, (a, b) -> a + b);       // 0 là GIÁ TRỊ KHỞI ĐẦU (identity)
int tong2 = so.stream().reduce(0, Integer::sum);         // cùng ý, gọn hơn

// ❗ BẪY 1: identity phải là phần tử TRUNG HÒA của phép toán — cộng thì 0, nhân thì 1.
so.stream().reduce(1, Integer::sum);                     // ❌ kết quả lệch đúng 1 — và
                                                         //    chạy song song còn lệch NHIỀU hơn,
                                                         //    vì mỗi luồng cộng thêm 1 lần nữa
// ❗ BẪY 2: không có identity thì trả về Optional — vì stream RỖNG thì lấy gì mà gấp?
Optional<Integer> max = so.stream().reduce(Integer::max);
max.orElse(0);                                           // ✅ đừng .get() mù quáng (Ngày 58)

// ❗ BẪY 3: reduce để NỐI CHUỖI là O(n²) — mỗi bước tạo một String mới, chép lại toàn bộ.
ten.stream().reduce("", (a, b) -> a + ", " + b);         // ❌ 10.000 tên = hàng chục triệu ký tự chép
ten.stream().collect(Collectors.joining(", "));          // ✅ một StringBuilder duy nhất

// ⭐ BAO GIỜ DÙNG REDUCE? Khi kết quả là MỘT giá trị BẤT BIẾN (số, boolean, record nhỏ).
//    Khi kết quả là một CONTAINER (List, Map, StringBuilder) thì đó là việc của collect.

// ② COLLECT: "gom có thể biến đổi" — một container được TẠO một lần rồi THÊM dần vào.
List<String> ds = ten.stream().collect(Collectors.toList());   // cách cũ, vẫn thấy khắp nơi
List<String> ds2 = ten.stream().toList();                       // ⭐ Java 16+: gọn hơn

// ❗ HAI CÁI ĐÓ KHÔNG GIỐNG NHAU — khác nhau ở chỗ dễ gây bug nhất:
ds.add("x");    // ✅ Collectors.toList() trả ArrayList — sửa được (dù đặc tả không hứa)
ds2.add("x");   // 💥 UnsupportedOperationException — toList() trả list KHÔNG SỬA ĐƯỢC
// ➜ Cần sửa tiếp thì new ArrayList<>(stream.toList()). Không cần thì toList() là mặc định tốt:
//    nó cấm sửa nhầm, và cho phép null (Collectors.toUnmodifiableList() thì không).

// ③ ĐỪNG reduce để tính TỔNG SỐ NGUYÊN — dùng stream nguyên thủy:
so.stream().reduce(0, Integer::sum);          // ❌ boxing/unboxing từng phần tử
so.stream().mapToInt(Integer::intValue).sum(); // ✅ IntStream: sum, average, max sẵn có
IntSummaryStatistics tk = donHang.stream().mapToInt(DonHang::tong).summaryStatistics();
tk.getAverage(); tk.getMax(); tk.getCount();   // ⭐ một lần duyệt, đủ mọi con số
  • Identity phải trung hòa — cộng thì 0, nhân thì 1; sai identity thì tuần tự lệch một, song song lệch theo số luồng.

  • Không dùng reduce để nối chuỗi — mỗi bước chép lại toàn bộ, Collectors.joining chỉ dùng một StringBuilder.

  • toList() của Java 16 trả list không sửa được và cho phép null; Collectors.toList() trả ArrayList sửa được — hai cái trông giống mà hành xử khác.

  • Tổng, trung bình, max trên số thì dùng mapToIntsummaryStatistics — một lần duyệt đủ mọi con số, không boxing.

count, anyMatch, và findFirst đối đầu findAny

Ba câu hỏi có/không và hai phép lấy một phần tử đều ngắt sớm: gặp câu trả lời là dừng. Đó là lý do stream vô hạn vẫn dùng được. Nhưng allMatchnoneMatch trả true trên tập rỗng, count từ Java 9 có thể bỏ qua cả dây chuyền, và findAny trả cùng phần tử với findFirst trên stream tuần tự chỉ là tình cờ, không phải lời hứa.

// COUNT, ANYMATCH, FINDFIRST vs FINDANY — những phép biết DỪNG SỚM (và một phép không).

// ① BA CÂU HỎI CÓ/KHÔNG — đều ngắt sớm: gặp câu trả lời là dừng, không duyệt hết.
boolean coDonLon  = donHang.stream().anyMatch(d -> d.tong() > 1_000_000);   // dừng ở đơn lớn đầu tiên
boolean tatCaOk   = donHang.stream().allMatch(d -> d.trangThai() != null);  // dừng ở phần tử SAI đầu tiên
boolean khongAiNo = donHang.stream().noneMatch(DonHang::noTien);           // dừng ở người NỢ đầu tiên

// ❗ BẪY: trên stream RỖNG — anyMatch = false, nhưng allMatch = TRUE và noneMatch = TRUE.
List.<DonHang>of().stream().allMatch(d -> d.tong() < 0);   // true! — "mọi phần tử" của tập rỗng
// Toán học gọi đó là "chân lý rỗng". Nếu code bạn đọc là "kiểm tra xong, tất cả đều hợp lệ"
// mà danh sách rỗng, bạn vừa duyệt một đơn hàng KHÔNG TỒN TẠI. Kiểm tra isEmpty() trước.

// ❗ ĐỌC ĐÚNG: dùng anyMatch thay cho filter(...).findFirst().isPresent() —
//    hai cái cho cùng kết quả, nhưng anyMatch nói rõ ý định và không tạo Optional thừa.

// ② COUNT — không ngắt sớm, và từ Java 9 có một bất ngờ:
long n = List.of(1, 2, 3).stream().peek(System.out::println).count();
// ➜ Java 9+: có thể KHÔNG in gì cả! JVM biết trước kích thước (SIZED) nên trả 3 thẳng,
//    không cần chạy dây chuyền. Đây là bài học "peek không đảm bảo chạy" của Ngày 55.
//    Chỉ khi có filter/flatMap làm mất thông tin kích thước thì dây chuyền mới thật sự chạy.

// ③ FINDFIRST vs FINDANY — cả hai trả Optional<T>, khác nhau đúng một chữ: THỨ TỰ.
Optional<DonHang> dau  = donHang.stream().filter(DonHang::quaHan).findFirst();  // phần tử ĐẦU theo thứ tự
Optional<DonHang> baCu = donHang.stream().filter(DonHang::quaHan).findAny();    // BẤT KỲ phần tử nào

// Trên stream TUẦN TỰ: findAny gần như luôn trả cùng phần tử với findFirst — nhưng đó là
// SỰ TÌNH CỜ của cách hiện thực, không phải lời hứa. Đừng dựa vào nó.
// Trên stream SONG SONG: findFirst phải ĐỢI để chắc chắn đó là phần tử đầu tiên theo thứ tự
// gặp — đắt. findAny lấy phần tử của luồng nào về đích trước — nhanh hơn hẳn.
// ⭐ QUY TẮC: cần "cái đầu tiên" đúng nghĩa (bản ghi mới nhất sau sorted) → findFirst.
//             chỉ cần "một cái bất kỳ thỏa điều kiện" → findAny, và nói rõ điều đó.

// ❗ CẢ HAI ĐỀU LÀ OPTIONAL — vì có thể không ai thỏa. Đừng viết .get() rồi cầu may:
dau.get().ma();                                     // 💥 NoSuchElementException nếu không ai quá hạn
dau.map(DonHang::ma).orElse("không có");            // ✅ — Ngày 58 sẽ nói kỹ về Optional
dau.orElseThrow(() -> new KhongTimThayException()); // ✅ khi "không có" thật sự là lỗi

// ④ MIN / MAX — là reduce khoác áo Comparator, cũng trả Optional:
donHang.stream().max(Comparator.comparing(DonHang::tong));     // đơn lớn nhất
donHang.stream().min(Comparator.comparing(DonHang::ngayTao));  // đơn cũ nhất

// ⭐ CÁC PHÉP NGẮT SỚM (anyMatch, allMatch, noneMatch, findFirst, findAny, limit)
//    là thứ khiến stream VÔ HẠN vẫn dùng được — Stream.iterate(...).filter(...).findFirst()
//    kết thúc ngay khi tìm thấy. Đó là tính lười của Ngày 54 ở dạng đẹp nhất.
  • allMatch trên danh sách rỗng là chân lý rỗng — code đọc như "tất cả hợp lệ" mà thật ra chưa kiểm tra gì; kiểm tra isEmpty() trước khi kết luận.

  • count() trên nguồn biết trước kích thước có thể không chạy dây chuyền — đừng đặt logic vào peek rồi trông chờ count kích hoạt nó.

  • findFirst tôn trọng thứ tự gặp nên đắt khi song song; findAny lấy phần tử của luồng về đích trước — chỉ cần "một cái bất kỳ" thì nói rõ bằng findAny.

  • Mọi phép trả Optional đều có lý do: có thể không ai thỏa. orElse, map, orElseThrow — không .get() mù quáng.

Bài tập nhỏ

  • Tạo một Stream, gọi count() rồi toList() trên cùng biến — đọc kỹ thông điệp IllegalStateException.

  • Tính tổng bằng reduce(1, Integer::sum) rồi so với reduce(0, ...); thử lại với parallel() và xem lệch bao nhiêu.

  • Gọi .add() trên kết quả của toList() và của Collectors.toList() — một cái ném UnsupportedOperationException.

  • In List.of().stream().allMatch(x -> false) — giải thích vì sao là true.

  • Viết Stream.iterate(1, n -> n + 1).filter(this::laSoNguyenTo).findFirst() với điều kiện lớn hơn 1000 và thấy nó kết thúc dù stream vô hạn.

Kết lại

Bốn ý gói lại hôm nay: kiểu trả về nói cho bạn họ phép kết thúc — collection, một giá trị, boolean, Optional, hay void; mỗi stream chỉ dùng một lầnforEach không phải vòng for; reduce cho giá trị bất biến với identity trung hòa, collect cho container, và toList() của Java 16 không sửa được; cuối cùng, các phép ngắt sớm là thứ làm stream vô hạn dùng được, nhưng allMatch trả true trên tập rỗngfindAny không hứa trả phần tử đầu. Ngày 57 đi sâu vào phần mạnh nhất của collect: CollectorstoMap, groupingBy, partitioningBy, và các collector lồng nhau. 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 56: Mock bean & test security

@MockitoBean chỉ cho thứ ngoài biên giới và mỗi bộ mock khác nhau là một context mới, @WithMockUser với bẫy roles và authorities, jwt() test luật phân quyền mà không cần token thật — và kim tự tháp test cho security.

21 thg 9, 202611 phút3
99 Ngày Spring — Ngày 55: Testcontainers

Phụ thuộc thật trong Docker thay vì thứ gần giống, @ServiceConnection biến sáu dòng cấu hình thành một annotation, một container cho cả bộ test — và bức tranh khép lại năm ngày kiểm thử.

20 thg 9, 20268 phút9
99 Ngày Java — Ngày 55: Intermediate operations

Phân biệt map và flatMap bằng kiểu trả về, distinct im lặng không lọc gì nếu thiếu equals, sorted phá vỡ tính lười, peek không được đảm bảo chạy — và vì sao skip không phải cách phân trang dữ liệu từ database.

20 thg 9, 20268 phút6