Backend

99 Ngày Java — Ngày 57: Collectors chuyên sâu

SSite Admin
22 tháng 09, 2026 11 phút đọc 1 lượt xem
99 Ngày Java — Ngày 57: Collectors chuyên sâu

Hôm qua ta thấy collect là phép kết thúc mạnh nhất; hôm nay mở hộp đồ nghề của nó: Collectors. Bộ ba cơ bản toList/toSet/toMap — với toMap nổ khi trùng khóa và khi giá trị null; groupingBypartitioningBy — "GROUP BY" của Java, với sự khác biệt nhỏ mà quan trọng: partitioningBy luôn có đủ hai khóa; và các collector lồng nhaucounting, mapping, filtering, teeing, collectingAndThen — thứ biến một truy vấn báo cáo mười lăm dòng vòng lặp thành ba dòng đọc như câu tiếng Anh.

Sketchnote Ngày 57: Collectors chuyên sâu — toMap với ba bẫy trùng khóa, null và kiểu map; groupingBy với downstream counting/summingInt/mapping; partitioningBy luôn đủ hai khóa; filtering giữ nhóm rỗng; teeing và collectingAndThen

toList, toSet, toMap — và ba cái bẫy của toMap

toListtoSet gần như không có gì để nói, ngoài chuyện hôm qua: stream.toList() của Java 16 không sửa được, còn Collectors.toList() thì được. toMap mới là nơi đáng chú ý: nó nhận hai hàm — khóa và giá trị — và có ba cái bẫy mà hầu như ai cũng dẫm phải ít nhất một lần: IllegalStateException khi trùng khóa, NullPointerException khi giá trị null, và HashMap mặc định không hứa thứ tự.

// BỘ BA CƠ BẢN — toList, toSet, toMap. Dễ nhất, và toMap là nơi bug hay trốn nhất.

// ① toList / toSet — không có gì bất ngờ, trừ chuyện ĐÃ HỌC hôm qua:
List<String> ten  = nhanVien.stream().map(NhanVien::hoTen).collect(Collectors.toList()); // ArrayList, sửa được
List<String> ten2 = nhanVien.stream().map(NhanVien::hoTen).toList();                    // ⭐ Java 16+: KHÔNG sửa được
Set<String> phong = nhanVien.stream().map(NhanVien::phongBan).collect(Collectors.toSet()); // HashSet — KHÔNG có thứ tự
Set<String> phongSx = nhanVien.stream().map(NhanVien::phongBan)
        .collect(Collectors.toCollection(TreeSet::new));                                 // ⭐ cần thứ tự: tự chọn container

// ② toMap — hai hàm: một cho KHÓA, một cho GIÁ TRỊ.
Map<String, NhanVien> theoMa = nhanVien.stream()
        .collect(Collectors.toMap(NhanVien::ma, Function.identity()));   // ✅ mã nhân viên là duy nhất

// ❗ BẪY 1: TRÙNG KHÓA → IllegalStateException
Map<String, NhanVien> theoPhong = nhanVien.stream()
        .collect(Collectors.toMap(NhanVien::phongBan, Function.identity()));
// 💥 IllegalStateException: Duplicate key Kỹ thuật (attempted merging values NhanVien[An] and NhanVien[Bình])
// ➜ Đưa hàm MERGE — bạn quyết định giữ ai khi hai phần tử cùng khóa:
Map<String, NhanVien> luongCaoNhat = nhanVien.stream()
        .collect(Collectors.toMap(NhanVien::phongBan, Function.identity(),
                 (a, b) -> a.luong() >= b.luong() ? a : b));                 // ✅ giữ người lương cao hơn
Map<String, Integer> tongLuong = nhanVien.stream()
        .collect(Collectors.toMap(NhanVien::phongBan, NhanVien::luong, Integer::sum)); // ✅ cộng dồn theo phòng

// ❗ BẪY 2: GIÁ TRỊ NULL → NullPointerException (HashMap chịu được null, nhưng toMap thì KHÔNG)
Map<String, String> emailTheoMa = nhanVien.stream()
        .collect(Collectors.toMap(NhanVien::ma, NhanVien::email));  // 💥 NPE ngay khi gặp một email null
// ➜ filter(nv -> nv.email() != null) trước; hoặc map giá trị sang Optional; hoặc — nếu thật sự cần null
//    trong map — đừng dùng toMap: forEach + map.put là câu trả lời thành thật hơn.

// ❗ BẪY 3: KIỂU MAP — mặc định là HashMap, KHÔNG hứa thứ tự.
Map<String, Integer> theoTen = nhanVien.stream()
        .collect(Collectors.toMap(NhanVien::hoTen, NhanVien::luong,
                 (a, b) -> a, LinkedHashMap::new));   // ⭐ tham số thứ 4: giữ thứ tự gặp (hay TreeMap::new để sắp xếp)
// Muốn chọn kiểu map thì BẮT BUỘC phải đưa hàm merge — không có overload 3 tham số (key, value, factory).

// ③ toUnmodifiableList / toUnmodifiableMap (Java 10) — kết quả KHÓA CỨNG, và từ chối null luôn.
Map<String, Integer> coDinh = nhanVien.stream()
        .collect(Collectors.toUnmodifiableMap(NhanVien::ma, NhanVien::luong));
coDinh.put("x", 1);   // 💥 UnsupportedOperationException
// ⭐ Trả ra khỏi method/service thì ưu tiên bản unmodifiable — người gọi không sửa nhầm được,
//    và bạn không phải sao chép phòng thủ.
  • Trùng khóa là ngoại lệ, không phải ghi đètoMap hai tham số ném IllegalStateException; đưa hàm merge thứ ba để nói rõ giữ ai ((a, b) -> a, Integer::sum, so sánh lương…).

  • Giá trị null là NPE — khác HashMap.put; lọc null trước, hoặc thừa nhận rằng toMap không phải công cụ đúng cho dữ liệu có lỗ hổng.

  • Muốn chọn kiểu map thì phải đưa merge — tham số thứ tư LinkedHashMap::new giữ thứ tự gặp, TreeMap::new sắp xếp theo khóa; không có overload ba tham số cho việc này.

  • toUnmodifiableList/toUnmodifiableMap (Java 10) cho kết quả khóa cứng và từ chối null — lựa chọn mặc định tốt khi trả ra khỏi service.

groupingBy và partitioningBy — GROUP BY của Java

groupingBy nhận một hàm phân loại và trả Map<K, List<T>> — mặc định là HashMap với ArrayList bên trong. Sức mạnh nằm ở tham số thứ hai, downstream collector: gom xong rồi counting, summingInt, averagingInt, mapping hay maxBy trên từng nhóm. Lồng một groupingBy nữa là gom nhiều cấp. Còn partitioningBy là anh em với khóa boolean — và luôn có đủ hai khóa true/false, ngay cả khi không phần tử nào rơi vào một bên.

// GROUPINGBY và PARTITIONINGBY — "GROUP BY" của Java.

// ① groupingBy(classifier) — mặc định: HashMap<K, List<T>>
Map<String, List<NhanVien>> theoPhong = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan));
// { "Kỹ thuật" = [An, Bình], "Kinh doanh" = [Chi] }   — thứ tự KHÓA không đảm bảo (HashMap),
//                                                       thứ tự PHẦN TỬ trong nhóm = thứ tự gặp (ArrayList)

// ② groupingBy(classifier, downstream) — GOM xong rồi làm gì với TỪNG nhóm?
Map<String, Long> soNguoi = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan, Collectors.counting()));
Map<String, Integer> tongLuong = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan, Collectors.summingInt(NhanVien::luong)));
Map<String, Double> luongTrungBinh = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan, Collectors.averagingInt(NhanVien::luong)));
Map<String, List<String>> tenTheoPhong = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan,
                 Collectors.mapping(NhanVien::hoTen, Collectors.toList())));   // ⭐ mapping: biến đổi BÊN TRONG nhóm
Map<String, Optional<NhanVien>> luongCaoNhat = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan,
                 Collectors.maxBy(Comparator.comparingInt(NhanVien::luong))));
// ❗ maxBy trả Optional dù nhóm KHÔNG BAO GIỜ rỗng (rỗng thì đã không có khóa) — gỡ bằng collectingAndThen (bài dưới).

// ③ groupingBy(classifier, mapFactory, downstream) — chọn KIỂU MAP
Map<String, Long> theoPhongSapXep = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan, TreeMap::new, Collectors.counting())); // ⭐ khóa có thứ tự
// ❗ Chọn được map thì PHẢI đưa downstream — không có overload (classifier, mapFactory).

// ④ Gom NHIỀU CẤP — downstream lại là một groupingBy khác:
Map<String, Map<Integer, List<NhanVien>>> theoPhongRoiNam = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan,
                 Collectors.groupingBy(nv -> nv.ngayVao().getYear())));
theoPhongRoiNam.get("Kỹ thuật").get(2024);   // ❗ get(2024) có thể null — không phải nhóm nào cũng có năm đó
// ⭐ Hai cấp là đủ đọc. Ba cấp trở lên thì tạo record làm khóa:
record PhongNam(String phong, int nam) {}
Map<PhongNam, Long> dem = nhanVien.stream()
        .collect(Collectors.groupingBy(nv -> new PhongNam(nv.phongBan(), nv.ngayVao().getYear()),
                 Collectors.counting()));   // record có equals/hashCode sẵn — làm khóa map là chuẩn

// ⑤ partitioningBy — groupingBy với khóa boolean, LUÔN có ĐỦ hai khóa true/false
Map<Boolean, List<NhanVien>> chia = nhanVien.stream()
        .collect(Collectors.partitioningBy(nv -> nv.luong() > 20_000_000));
chia.get(true);    // ✅ có thể là danh sách rỗng, KHÔNG BAO GIỜ null
Map<Boolean, Long> demChia = nhanVien.stream()
        .collect(Collectors.partitioningBy(nv -> nv.luong() > 20_000_000, Collectors.counting()));
// ❗ groupingBy(nv -> nv.luong() > 20_000_000) cho kết quả "gần giống" — nhưng THIẾU khóa nếu không ai thỏa:
//    get(true) trả null → NPE ở dòng tiếp theo. Điều kiện đúng/sai thì dùng partitioningBy.
  • Downstream là tham số đáng học nhấtcounting, summingInt, averagingInt, mapping, maxBy thay cho một vòng lặp với computeIfAbsent và một biến đếm.

  • Chọn kiểu map thì phải đưa downstreamgroupingBy(f, TreeMap::new, counting()); HashMap mặc định không hứa thứ tự khóa, ArrayList trong nhóm giữ thứ tự gặp.

  • Hai cấp là đủ đọc — từ ba cấp trở lên, tạo record làm khóa: groupingBy(nv -> new PhongNam(...)) phẳng, có equals/hashCode sẵn, và không có get(...).get(...) trả null.

  • partitioningBy luôn có cả truefalsegroupingBy với lambda boolean thì thiếu khóa khi không ai thỏa, và get(true) thành NPE.

joining, filtering, teeing — collector lồng nhau

Phần "chuyên sâu" thật sự là ghép collector với nhau. joining nối chuỗi đúng cách với prefix/suffix; collectingAndThen biến đổi kết quả một lần cuối — gỡ Optional của maxBy, khóa list; filtering (Java 9) lọc bên trong nhóm và giữ nhóm rỗng — khác hẳn filter trước groupingBy; flatMapping dẹt các collection con; teeing (Java 12) chạy hai collector trên cùng một lượt duyệt. Và nếu vẫn thiếu, một Collector tự viết chỉ là bốn hàm.

// JOINING và CÁC COLLECTOR LỒNG NHAU — chỗ Collectors thật sự "chuyên sâu".

// ① joining — nối chuỗi ĐÚNG CÁCH (Ngày 56: đừng reduce)
String ds  = nhanVien.stream().map(NhanVien::hoTen).collect(Collectors.joining(", "));
String ds2 = nhanVien.stream().map(NhanVien::hoTen).collect(Collectors.joining(", ", "[", "]"));  // "[An, Bình, Chi]"
// ❗ joining chỉ nhận Stream<CharSequence> — phải map sang String trước, không thì KHÔNG compile.

// ② collectingAndThen — gom xong rồi BIẾN ĐỔI kết quả đúng một lần cuối
Map<String, NhanVien> luongCaoNhat = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan,
                 Collectors.collectingAndThen(
                     Collectors.maxBy(Comparator.comparingInt(NhanVien::luong)),
                     Optional::get)));                          // ✅ gỡ Optional — nhóm không bao giờ rỗng
List<String> khoa = nhanVien.stream().map(NhanVien::hoTen)
        .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));

// ③ filtering (Java 9) — lọc BÊN TRONG nhóm, GIỮ LẠI nhóm rỗng
Map<String, List<NhanVien>> luongCaoTheoPhong = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan,
                 Collectors.filtering(nv -> nv.luong() > 20_000_000, Collectors.toList())));
// { "Kỹ thuật" = [An], "Kinh doanh" = [] }   ⭐ phòng Kinh doanh VẪN CÓ MẶT, với danh sách rỗng
nhanVien.stream().filter(nv -> nv.luong() > 20_000_000)
        .collect(Collectors.groupingBy(NhanVien::phongBan));
// { "Kỹ thuật" = [An] }                       ❗ lọc TRƯỚC: phòng không ai thỏa BIẾN MẤT khỏi map
// ➜ Báo cáo "mỗi phòng có bao nhiêu người lương cao" cần con số 0 cho Kinh doanh — chỉ filtering cho được.

// ④ flatMapping (Java 9) — mỗi phần tử sinh NHIỀU giá trị rồi gộp vào nhóm
Map<String, Set<String>> kyNangTheoPhong = nhanVien.stream()
        .collect(Collectors.groupingBy(NhanVien::phongBan,
                 Collectors.flatMapping(nv -> nv.kyNang().stream(), Collectors.toSet())));
// { "Kỹ thuật" = [Java, SQL, Docker], ... }  — mapping thì cho Set<List<String>>, flatMapping mới "dẹt" ra

// ⑤ teeing (Java 12) — HAI collector chạy trên cùng một lượt duyệt, rồi gộp kết quả
record KhoangLuong(int thap, int cao) {}
KhoangLuong khoang = nhanVien.stream().collect(Collectors.teeing(
        Collectors.minBy(Comparator.comparingInt(NhanVien::luong)),
        Collectors.maxBy(Comparator.comparingInt(NhanVien::luong)),
        (min, max) -> new KhoangLuong(min.orElseThrow().luong(), max.orElseThrow().luong())));
// ⭐ Một lần duyệt thay vì hai stream — nhớ Ngày 56: stream chỉ dùng được một lần.

// ⑥ Tự viết Collector — hiếm khi cần, nhưng nên biết nó chỉ là BỐN hàm:
Collector<NhanVien, StringJoiner, String> danhSachTen = Collector.of(
        () -> new StringJoiner(" | "),                     // supplier:    tạo container
        (sj, nv) -> sj.add(nv.hoTen()),                    // accumulator: thêm một phần tử
        StringJoiner::merge,                               // combiner:    gộp hai container (khi song song)
        StringJoiner::toString);                           // finisher:    biến đổi cuối
// ❗ Combiner phải viết ĐÚNG dù hôm nay bạn chưa chạy song song — ngày mai ai đó thêm .parallel() là sai lặng lẽ.
// ⭐ Trước khi tự viết, tra lại: joining, teeing, collectingAndThen thường đã đủ.
  • filtering giữ nhóm rỗng, filter trước groupingBy làm nhóm biến mất — báo cáo cần con số 0 thì chỉ filtering cho được.

  • collectingAndThen(maxBy(...), Optional::get) là cách hợp lệ hiếm hoi để get() một Optional: nhóm trong groupingBy không bao giờ rỗng.

  • teeing thay cho hai stream trên cùng nguồn — min và max, tổng và đếm, trong một lượt; nhớ stream chỉ dùng một lần.

  • Tự viết Collector.of thì combiner phải đúng dù bạn chưa chạy song song — và trước đó, kiểm tra lại joining/teeing/collectingAndThen đã đủ chưa.

Bài tập nhỏ

  • Gom danh sách nhân viên theo phongBan bằng toMap hai tham số — đọc thông điệp IllegalStateException, rồi sửa bằng hàm merge giữ người lương cao nhất.

  • Thêm một nhân viên có email null và chạy toMap(NhanVien::ma, NhanVien::email) — xem NPE nổ ở đâu, rồi sửa bằng filter.

  • Viết báo cáo "số nhân viên lương trên 20 triệu theo phòng" hai cách: filter rồi groupingBy, và groupingBy với filtering — so sánh khi một phòng không ai thỏa.

  • Thay groupingBy(nv -> nv.luong() > 20_000_000) bằng partitioningBy trên danh sách mà không ai vượt 20 triệu — in get(true) của cả hai.

  • Dùng teeing tính đồng thời tổng lương và số người để ra lương trung bình trong một lượt duyệt — rồi so với averagingInt.

Kết lại

Bốn ý gói lại hôm nay: toMapba bẫy — trùng khóa là IllegalStateException (đưa hàm merge), giá trị null là NPE, và muốn chọn kiểu map thì phải đưa merge; groupingBy mạnh ở downstream collectorcounting, summingInt, mapping, maxBy — và hai cấp là đủ, từ ba cấp dùng record làm khóa; partitioningBy luôn có đủ hai khóa nên điều kiện đúng/sai đừng dùng groupingBy; và filtering giữ nhóm rỗng, teeing gộp hai collector trong một lượt, collectingAndThen gỡ Optional của maxBy. Nhắc đến OptionalNgày 58 dành trọn cho nó: tránh null đúng cách, map/flatMap/orElse, và những anti-pattern biến Optional thành null đội lốt. 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 Java — Ngày 58: Optional

Optional là công cụ cho kiểu trả về — không field, không tham số, không bọc collection; chuỗi map/flatMap/filter thay kim tự tháp if; orElse luôn tính tham số còn orElseGet lười, orElseThrow cho “không có là lỗi” — và các anti-pattern isPresent + get, orElse(null).

23 thg 9, 20269 phút9
99 Ngày Spring — Ngày 58: Spring Boot Actuator

Boot 3 mặc định chỉ lộ health; bản đồ endpoint an toàn và nguy hiểm; HealthIndicator tự viết với liveness tách khỏi readiness cho Kubernetes, info từ build và git, metrics Micrometer với tag hữu hạn — và ba lớp khóa: cổng riêng, SecurityFilterChain với EndpointRequest, exclude env/heapdump/threaddump.

23 thg 9, 202610 phút5
So sánh EasyExcel và Apache Fesod: khác biệt thật nằm ở đâu?

Fesod là hậu duệ của EasyExcel, cùng engine SAX/SXSSF. Khác biệt kiểm chứng được: vòng đời dự án, POI 5.2.5 vs 5.5.1, các API mới (numRows, csv(), headerMergeStrategy) và độ bền với file xấu — không phải hiệu năng gấp nhiều lần.

23 thg 9, 20268 phút1