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; groupingBy và partitioningBy — "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 nhau — counting, 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.

toList, toSet, toMap — và ba cái bẫy của toMap
toList và toSet 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 đè —
toMaphai tham số némIllegalStateException; đư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ằngtoMapkhô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::newgiữ thứ tự gặp,TreeMap::newsắ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ất —
counting,summingInt,averagingInt,mapping,maxBythay cho một vòng lặp vớicomputeIfAbsentvà một biến đếm.Chọn kiểu map thì phải đưa downstream —
groupingBy(f, TreeMap::new, counting());HashMapmặc định không hứa thứ tự khóa,ArrayListtrong 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/hashCodesẵn, và không cóget(...).get(...)trả null.partitioningByluôn có cảtruevàfalse—groupingByvớ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 đã đủ.filteringgiữ nhóm rỗng,filtertrướcgroupingBylàm nhóm biến mất — báo cáo cần con số 0 thì chỉfilteringcho được.collectingAndThen(maxBy(...), Optional::get)là cách hợp lệ hiếm hoi đểget()mộtOptional: nhóm tronggroupingBykhông bao giờ rỗng.teeingthay 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.ofthì combiner phải đúng dù bạn chưa chạy song song — và trước đó, kiểm tra lạijoining/teeing/collectingAndThenđã đủ chưa.
Bài tập nhỏ
Gom danh sách nhân viên theo
phongBanbằngtoMaphai tham số — đọc thông điệpIllegalStateException, 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ó
emailnull và chạytoMap(NhanVien::ma, NhanVien::email)— xem NPE nổ ở đâu, rồi sửa bằngfilter.Viết báo cáo "số nhân viên lương trên 20 triệu theo phòng" hai cách:
filterrồigroupingBy, vàgroupingByvớifiltering— so sánh khi một phòng không ai thỏa.Thay
groupingBy(nv -> nv.luong() > 20_000_000)bằngpartitioningBytrên danh sách mà không ai vượt 20 triệu — inget(true)của cả hai.Dùng
teeingtí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ớiaveragingInt.
Kết lại
Bốn ý gói lại hôm nay: toMap có ba 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 collector — counting, 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 Optional — Ngà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!
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.


