Map, Filter và Reduce: Ba Thao Tác Cốt Lõi Của Stream API
Làm chủ bộ ba map, filter, reduce trong Java Stream, tối ưu primitive stream và bảo đảm tính chất kết hợp (associativity) khi chạy song song.
TL;DR: Bộ ba filter (lọc điều kiện), map (biến đổi dữ liệu) và reduce (gộp về một giá trị duy nhất) giải quyết hơn 80% tác vụ tổng hợp dữ liệu mà không cần viết vòng lặp truyền thống. mapToDouble/Long/Int chuyển đổi sang Primitive Stream để loại bỏ chi phí Autoboxing. reduce có 3 dạng: kèm giá trị khởi tạo (identity), không kèm identity (trả về Optional), và dạng kèm combiner cho môi trường xử lý song song. Ràng buộc toán học quan trọng nhất của reduce: accumulator và combiner bắt buộc phải có tính chất kết hợp (associativity)—nếu vi phạm, luồng chạy tuần tự vẫn ra kết quả đúng nhưng luồng song song sẽ sinh lỗi ngầm sai lệch dữ liệu.
Sau khi đã nắm được cơ chế lazy pipeline ở Stream Basics, bài học này sẽ đi sâu vào bộ ba thao tác nền tảng giúp chuyển đổi phong cách từ mệnh lệnh (imperative) sang khai báo (declarative).
1. Ba thao tác map, filter và reduce giải quyết bài toán gì?
Trong quản lý ấn phẩm thư viện, giả sử bạn cần tính tổng tiền phạt của các lượt mượn sách đã quá hạn:
// Cach 1: Imperative (Vong lap truyen thong)
double totalFine = 0.0;
for (LoanRecord record : loanRecords) {
if (record.isOverdue()) {
totalFine += record.calculateFine();
}
}
// Cach 2: Functional Stream API
double totalFineStream = loanRecords.stream()
.filter(LoanRecord::isOverdue)
.mapToDouble(LoanRecord::calculateFine)
.sum();
Với Stream API, từng bước được mô tả theo đúng luồng tư duy nghiệp vụ: Lọc phiếu quá hạn (filter) -> Lấy số tiền phạt (mapToDouble) -> Tính tổng (sum).

2. filter hoạt động và kết hợp nhiều điều kiện như thế nào?
filter(Predicate<T>) nhận vào một hàm điều kiện trả về boolean. Phần tử nào cho kết quả true sẽ được chuyển tiếp qua trạm kế tiếp:
Predicate<LoanRecord> isOverdue = LoanRecord::isOverdue;
Predicate<LoanRecord> isHighPenalty = r -> r.calculateFine() > 50_000.0;
// Ket hop dieu kien logic bang default methods:
List<LoanRecord> severeCases = loanRecords.stream()
.filter(isOverdue.and(isHighPenalty))
.toList();
Tách các điều kiện thành nhiều bước filter liên tiếp giúp mã nguồn sáng sủa hơn mà không làm giảm hiệu năng, bởi cơ chế Loop Fusion của JVM sẽ gộp chúng vào một lần duyệt duy nhất.
3. map và primitive stream tối ưu hiệu năng biến đổi dữ liệu ra sao?
map(Function<T, R>) biến đổi đối tượng đầu vào kiểu T thành đối tượng đầu ra kiểu R:
// Chuyen doi tu LoanRecord sang String barcode
List<String> barcodes = loanRecords.stream()
.map(LoanRecord::getBarcode)
.toList();
Triệt tiêu Autoboxing với Primitive Streams
Khi biến đổi dữ liệu thành các kiểu số nguyên thủy (int, long, double), việc dùng map() thông thường sẽ sinh ra các đối tượng wrapper (Integer, Double), gây áp lực dọn rác lên JVM. Các biến thể mapToInt(), mapToLong(), mapToDouble() chuyển luồng dữ liệu sang Primitive Stream:
DoubleSummaryStatistics stats = loanRecords.stream()
.filter(LoanRecord::isOverdue)
.mapToDouble(LoanRecord::calculateFine)
.summaryStatistics();
System.out.println("Average fine: " + stats.getAverage());
System.out.println("Max fine: " + stats.getMax());
System.out.println("Total fine: " + stats.getSum());
DoubleSummaryStatistics cho phép tính toán count, sum, min, max, average trong đúng 1 lượt duyệt duy nhất.
4. Ba dạng cú pháp của reduce hoạt động như thế nào?
reduce là thao tác gộp (reduction) trừu tượng hóa mọi phép tích lũy:
Dạng 1: Kèm Identity (Giá trị khởi tạo)
double total = loanRecords.stream()
.map(LoanRecord::calculateFine)
.reduce(0.0, Double::sum); // 0.0 la identity
- Identity đóng hai vai trò: Là giá trị tích lũy ban đầu, và là giá trị trả về nếu stream rỗng. Do có identity bảo lãnh, phương thức trả về trực tiếp kiểu giá trị mà không cần bọc
Optional.
Dạng 2: Không kèm Identity (Trả về Optional)
Optional<Double> maxFine = loanRecords.stream()
.map(LoanRecord::calculateFine)
.reduce(Double::max);
- Nếu stream rỗng, kết quả trả về
Optional.empty(), ngăn chặn rủi ro chọn sai giá trị mặc định (ví dụ0.0không thể coi là max hợp lệ nếu tất cả số phạt đều âm).
Dạng 3: Accumulator khác kiểu kèm Combiner
// Tich luy truc tiep tu LoanRecord sang double ma khong can map truoc
double totalFine = loanRecords.stream().reduce(
0.0,
(acc, record) -> acc + record.calculateFine(), // Accumulator
Double::sum // Combiner (chay khi parallel)
);
5. Tại sao tính chất kết hợp (associativity) quyết định tính đúng đắn khi chạy song song?
Hàm tích lũy trong reduce bắt buộc phải thỏa mãn Tính chất kết hợp (Associative Property):
f(f(a, b), c) == f(a, f(b, c))
- Thỏa mãn: Phép cộng
(a + b), phép nhân(a * b),Math.max(a, b), ghép chuỗi. - Vi phạm: Phép trừ
(a - b), phép chia(a / b). Ví dụ:(10 - 2) - 1 = 7, nhưng10 - (2 - 1) = 9.

Khi chạy song song qua .parallelStream(), ForkJoinPool chia nhỏ luồng dữ liệu thành nhiều nhánh và nạp identity vào đầu mỗi nhánh. Nếu phép toán không kết hợp hoặc identity không phải phần tử trung tính, kết quả gộp cuối cùng sẽ sai lệch hoàn toàn.
6. Khi nào nên dùng collect thay cho reduce?
reduce được thiết kế để gộp dữ liệu thành một giá trị bất biến (immutable value). Khi bạn muốn gom các phần tử vào một cấu trúc dữ liệu có thể thay đổi (mutable container) như List, Set hoặc nhóm thành Map, việc dùng reduce sẽ tạo ra bản sao mới ở mỗi bước tích lũy, gây lãng phí bộ nhớ nghiêm trọng.
Trong trường hợp này, hãy sử dụng collect(Collector)—thao tác Mutable Reduction chuyên dụng (xem chi tiết tại Collectors Deep Dive).
7. Đâu là những cạm bẫy thường gặp với map, filter và reduce?
❌ Cạm bẫy 1: Dùng reduce thô cho các phép toán đã có sẵn API chuyên biệt
// Code smell:
long count = items.stream().reduce(0L, (acc, item) -> acc + 1L, Long::sum);
// Chuan muc:
long count = items.stream().count();
❌ Cạm bẫy 2: Dùng phép trừ trong parallel reduce
// Ket qua ngau nhien va sai lech vi phep tru khong co tinh chat ket hop:
int diff = numbers.parallelStream().reduce(0, (a, b) -> a - b);
❌ Cạm bẫy 3: Boxing liên tục qua các trạm map
// Lang phi bo nho Heap:
double sum = items.stream()
.map(LibraryItem::getFine) // Tra ve Stream<Double> (Boxed)
.reduce(0.0, Double::sum);
// Toi uu CPU & RAM:
double sum = items.stream()
.mapToDouble(LibraryItem::getFine) // DoubleStream (Primitive)
.sum();
8. Tóm tắt các điểm cốt lõi
filterloại bỏ dữ liệu thừa, có thể ghép nhiều điều kiện quaand(),or().mapToInt/Long/Doublechuyển sang primitive stream, triệt tiêu hoàn toàn chi phí Autoboxing.reduceyêu cầu identity là phần tử trung tính và accumulator bắt buộc có tính chất kết hợp (associativity) để chạy an toàn song song.- Dùng
collectkhi gom dữ liệu vào container (List,Map); tham khảo thêm kỹ thuật gom nhóm dữ liệu tương đương tại SQL Aggregate Functions.
9. Câu hỏi tự kiểm tra (Self-Check)
- Q1Tại sao phương thức reduce(BinaryOperator) lại trả về Optional trong khi reduce(T, BinaryOperator) lại trả về giá trị trực tiếp T?
- Q2Vì sao việc sử dụng mapToInt().sum() lại nhanh hơn và ít tốn RAM hơn so với map().reduce(0, Integer::sum)?
- Q3Điều gì sẽ xảy ra nếu ta thực hiện phép trừ trong parallelStream().reduce(0, (a, b) -> a - b)?
- Q4Identity trong phép tính reduce bắt buộc phải thỏa mãn điều kiện toán học nào?
- Q5Khi nào ta nên sử dụng collect(Collector) thay vì cố gắng dùng reduce()?
Bài tiếp theo: Stream Nâng Cao: flatMap, takeWhile, dropWhile và Infinite Stream
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Stream Basics: Pipeline Lazy và Terminal Operation
Bài tiếpStream Nâng Cao: flatMap, takeWhile, dropWhile và Stateful Operations
Lộ trình Junior Java Backend Developer
Chặng 6/21 · Lambda & Stream API — bài 5/12 của chặng
Thảo luận & góp ý
Hỏi đáp về bài này