- Hiểu functional interface — nền tảng của mọi thứ trong chương này.
- Viết lambda và biết mọi dạng rút gọn.
- Dùng bốn loại method reference.
- Thuộc các interface hàm dựng sẵn:
Function,Predicate,Consumer,Supplier,BiFunction,UnaryOperator. - Nắm ba đặc tính của Stream: lười, một lần dùng, không sửa nguồn.
- Phân biệt thao tác trung gian và kết thúc.
- Làm chủ
Collectors:toList,groupingBy,partitioningBy,joining,toMap. - Dùng
Optionalđúng cách — và biết ba cách dùng sai phổ biến. - Hiểu parallel stream và vì sao nó thường chậm hơn.
- Biết khi nào vòng lặp
forvẫn tốt hơn Stream.
1. Functional interface
Functional interface là interface có đúng một phương thức trừu tượng. Đó là toàn bộ định nghĩa — và cũng là điều kiện để dùng lambda.
@FunctionalInterface // tuỳ chọn, nhưng NÊN có
interface PhepTinh {
double tinh(double a, double b); // ← đúng MỘT method trừu tượng
// default và static method KHÔNG tính — bao nhiêu cũng được
default PhepTinh roiThi(DoubleUnaryOperator sau) {
return (a, b) -> sau.applyAsDouble(tinh(a, b));
}
static PhepTinh cong() { return (a, b) -> a + b; }
}
// ❌ Không phải functional interface — hai method trừu tượng
// @FunctionalInterface
// interface Hai { void a(); void b(); }
// error: Hai is not a functional interface
// --- Ba cách cài đặt, từ cũ tới mới ---
// ① Class riêng (Java 1.0) — 5 dòng cho một phép cộng
class Cong implements PhepTinh {
@Override public double tinh(double a, double b) { return a + b; }
}
PhepTinh p1 = new Cong();
// ② Anonymous class (Java 1.1) — 3 dòng
PhepTinh p2 = new PhepTinh() {
@Override public double tinh(double a, double b) { return a + b; }
};
// ③ Lambda (Java 8) — MỘT dòng ⭐
PhepTinh p3 = (a, b) -> a + b;
@FunctionalInterfaceNó không thay đổi hành vi — chỉ bảo trình biên dịch kiểm tra rằng interface thực sự có đúng một phương thức trừu tượng. Không có nó, ai đó thêm method thứ hai vào interface của bạn và mọi lambda dùng nó trong toàn dự án đột nhiên không biên dịch được — với thông điệp lỗi ở chỗ khác, rất khó truy nguyên. Có chú thích thì lỗi báo ngay tại interface.
2. Lambda
// Cú pháp đầy đủ: (kiểu tên, kiểu tên) -> { câu lệnh; return giaTri; }
PhepTinh a = (double x, double y) -> { return x + y; };
// Bỏ kiểu — trình biên dịch tự suy ra
PhepTinh b = (x, y) -> { return x + y; };
// Một biểu thức → bỏ luôn ngoặc nhọn và return
PhepTinh c = (x, y) -> x + y;
// MỘT tham số → bỏ được cả ngoặc tròn
Predicate<String> d = s -> s.isEmpty();
// KHÔNG tham số → ngoặc tròn rỗng là bắt buộc
Supplier<String> e = () -> "xin chào";
// Nhiều dòng → cần ngoặc nhọn và return tường minh
PhepTinh f = (x, y) -> {
if (y == 0) throw new ArithmeticException("Chia cho 0");
return x / y;
};
// var trong tham số (Java 11+) — chỉ hữu ích khi cần thêm annotation
PhepTinh g = (var x, var y) -> x + y;
int dem = 0;
List.of("a", "b").forEach(s -> dem++);
// ❌ error: local variables referenced from a lambda expression
// must be final or effectively final
// ✅ Cách 1: dùng mảng một phần tử (thô nhưng chạy)
int[] dem2 = {0};
List.of("a", "b").forEach(s -> dem2[0]++); // mảng là tham chiếu → OK
// ✅ Cách 2: AtomicInteger (an toàn đa luồng)
AtomicInteger dem3 = new AtomicInteger();
List.of("a", "b").forEach(s -> dem3.incrementAndGet());
// ✅✅ Cách 3: dùng Stream đúng cách — không cần biến đếm nào
long dem4 = List.of("a", "b").stream().count();
Vì sao có ràng buộc này: lambda có thể sống lâu hơn phương thức tạo ra nó (được lưu vào
trường, truyền sang luồng khác). Biến cục bộ nằm trên stack và biến mất khi phương thức kết thúc, nên
lambda phải sao chép giá trị. Nếu cho phép sửa, hai bên sẽ thấy hai giá trị khác nhau — nên Java
cấm hẳn. Đây cũng là lý do this trong lambda trỏ tới object bao ngoài, không phải lambda
(khác hẳn anonymous class).
3. Method reference
// ① Phương thức STATIC: Class::method
Function<String, Integer> f1 = s -> Integer.parseInt(s);
Function<String, Integer> f2 = Integer::parseInt; // ✅
// ② Phương thức của MỘT INSTANCE cụ thể: bien::method
String tienTo = "Java";
Predicate<String> p1 = s -> tienTo.startsWith(s);
Predicate<String> p2 = tienTo::startsWith; // ✅
// ③ Phương thức của instance TUỲ Ý thuộc một class: Class::method
Function<String, Integer> f3 = s -> s.length();
Function<String, Integer> f4 = String::length; // ✅ tham số đầu thành receiver
Comparator<String> c1 = (a, b) -> a.compareTo(b);
Comparator<String> c2 = String::compareTo; // ✅
// ④ CONSTRUCTOR: Class::new
Supplier<List<String>> s1 = () -> new ArrayList<>();
Supplier<List<String>> s2 = ArrayList::new; // ✅
Function<String, SinhVien> s3 = SinhVien::new; // constructor 1 tham số
IntFunction<String[]> s4 = String[]::new; // constructor MẢNG
// --- Ứng dụng thực tế ---
danhSach.forEach(System.out::println);
danhSach.sort(Comparator.comparing(SinhVien::getTen));
List<Integer> doDai = ten.stream().map(String::length).toList();
4. Interface hàm dựng sẵn
| Interface | Phương thức | Nhận → Trả | Dùng để |
|---|---|---|---|
Function<T,R> | R apply(T) | T → R | Biến đổi giá trị |
Predicate<T> | boolean test(T) | T → boolean | Kiểm tra điều kiện |
Consumer<T> | void accept(T) | T → ∅ | Thực hiện tác dụng phụ |
Supplier<T> | T get() | ∅ → T | Sinh giá trị (lười) |
UnaryOperator<T> | T apply(T) | T → T | Function<T,T> |
BinaryOperator<T> | T apply(T,T) | (T,T) → T | Gộp hai giá trị cùng kiểu |
BiFunction<T,U,R> | R apply(T,U) | (T,U) → R | Hai tham số |
BiPredicate<T,U> | boolean test(T,U) | (T,U) → boolean | Kiểm tra hai giá trị |
BiConsumer<T,U> | void accept(T,U) | (T,U) → ∅ | Map.forEach |
Runnable | void run() | ∅ → ∅ | Tác vụ chạy trong luồng |
Callable<V> | V call() | ∅ → V (ném được) | Tác vụ có kết quả |
// --- Kết hợp Function ---
Function<String, Integer> doDai = String::length;
Function<Integer, String> moTa = n -> "dài " + n;
doDai.andThen(moTa).apply("Java"); // "dài 4" — doDai TRƯỚC
moTa.compose(doDai).apply("Java"); // "dài 4" — doDai cũng trước
Function.<String>identity(); // x -> x
// --- Kết hợp Predicate ---
Predicate<String> rong = String::isEmpty;
Predicate<String> ngan = s -> s.length() < 5;
rong.negate(); // không rỗng
rong.or(ngan); // rỗng HOẶC ngắn
rong.negate().and(ngan); // không rỗng VÀ ngắn
Predicate.not(rong); // Java 11+, dùng được với method reference
// --- Consumer nối tiếp ---
Consumer<String> in = System.out::println;
Consumer<String> ghiLog = s -> log.info(s);
in.andThen(ghiLog).accept("xin chào"); // làm cả hai
// --- Supplier: hoãn tính toán tới khi thực sự cần ---
Supplier<String> tonKem = () -> tinhToanRatLau();
Optional.ofNullable(x).orElseGet(tonKem); // chỉ chạy khi x là null ⭐
// --- Phiên bản NGUYÊN THỦY: tránh autoboxing ---
IntPredicate chan = n -> n % 2 == 0; // thay Predicate<Integer>
IntFunction<String> doi = n -> "số " + n;
ToIntFunction<String> dai = String::length;
IntUnaryOperator binh = n -> n * n;
// Với hàng triệu phần tử, các phiên bản này nhanh hơn 4-6 lần
5. Stream API — ba đặc tính
collection.stream()Arrays.stream()Stream.of()filter mapsorted distincttrả về Stream — LƯỜI
collect forEachcount reducekích hoạt xử lý
// ① LƯỜI — không có thao tác kết thúc thì KHÔNG CHẠY GÌ
Stream<String> s = List.of("a", "b").stream()
.filter(x -> { System.out.println("lọc " + x); return true; });
// KHÔNG in gì cả! filter chưa hề chạy.
s.count(); // bây giờ mới in "lọc a", "lọc b"
// ② MỘT LẦN DÙNG
Stream<String> s2 = List.of("a", "b").stream();
s2.count(); // ✅
s2.count(); // 💥 IllegalStateException: stream has already been
// operated upon or closed
// ③ KHÔNG SỬA NGUỒN
List<String> goc = new ArrayList<>(List.of("b", "a"));
List<String> moi = goc.stream().sorted().toList();
System.out.println(goc); // [b, a] — không đổi
System.out.println(moi); // [a, b]
// ⭐ HỆ QUẢ CỦA TÍNH LƯỜI: xử lý theo CHIỀU DỌC, không phải chiều ngang
List.of("a", "bb", "ccc").stream()
.filter(x -> { System.out.println("lọc " + x); return x.length() > 1; })
.map(x -> { System.out.println(" ánh xạ " + x); return x.toUpperCase(); })
.findFirst();
// lọc a
// lọc bb
// ánh xạ bb ← DỪNG LUÔN! "ccc" không hề được chạm tới
Xử lý theo chiều dọc — vì sao Stream nhanh hơn tưởng tượng
Vòng lặp thông thường xử lý theo chiều ngang: lọc hết toàn bộ danh sách, tạo list trung gian, rồi ánh xạ hết. Stream xử lý theo chiều dọc: mỗi phần tử đi qua toàn bộ chuỗi thao tác rồi mới tới phần tử tiếp theo.
Hai lợi ích: (1) không tạo collection trung gian — tiết kiệm bộ nhớ;
(2) các thao tác "ngắn mạch" như findFirst, anyMatch,
limit dừng ngay khi đủ, không duyệt phần còn lại. Với danh sách một triệu phần tử mà bạn chỉ
cần phần tử đầu tiên thoả điều kiện, Stream có thể chỉ duyệt vài phần tử.
6. Tạo Stream
// Từ collection
danhSach.stream();
danhSach.parallelStream();
// Từ mảng
Arrays.stream(mang);
Arrays.stream(mang, 1, 4); // một phần
// Từ giá trị rời
Stream.of("a", "b", "c");
Stream.empty();
Stream.ofNullable(coTheNull); // Java 9+: rỗng nếu null
// Stream số nguyên thủy — tránh autoboxing
IntStream.range(0, 5); // 0 1 2 3 4
IntStream.rangeClosed(1, 5); // 1 2 3 4 5
IntStream.of(1, 2, 3).sum();
// Vô hạn — BẮT BUỘC có limit() hoặc thao tác ngắn mạch
Stream.iterate(1, n -> n * 2).limit(10); // 1 2 4 8 ...
Stream.iterate(1, n -> n < 100, n -> n * 2); // Java 9+: có điều kiện dừng
Stream.generate(Math::random).limit(5);
// Từ chuỗi
"a,b,c".chars(); // IntStream các mã ký tự
Pattern.compile(",").splitAsStream("a,b,c");
// Từ tệp — nhớ đóng! (chương 11)
try (Stream<String> dong = Files.lines(Path.of("data.txt"))) {
dong.filter(l -> l.contains("ERROR")).forEach(System.out::println);
}
// Từ Map
map.entrySet().stream();
map.keySet().stream();
map.values().stream();
7. Thao tác trung gian
List<String> ds = List.of("java", "python", "go", "java", "rust");
// filter — giữ phần tử thoả điều kiện
ds.stream().filter(s -> s.length() > 2)
// map — biến đổi từng phần tử
.map(String::toUpperCase)
// distinct — loại trùng (dùng equals)
.distinct()
// sorted — sắp xếp
.sorted()
.sorted(Comparator.comparingInt(String::length))
// limit / skip — phân trang
.skip(1).limit(3)
// peek — xem giữa chừng, CHỈ để gỡ lỗi
.peek(System.out::println)
.toList();
// ⭐ flatMap — LÀM PHẲNG cấu trúc lồng nhau
List<List<String>> long2 = List.of(
List.of("a", "b"),
List.of("c", "d")
);
long2.stream().flatMap(List::stream).toList(); // [a, b, c, d]
// Tách câu thành từ
List.of("xin chào", "thế giới").stream()
.flatMap(c -> Arrays.stream(c.split(" ")))
.toList(); // [xin, chào, thế, giới]
// Lấy mọi đơn hàng của mọi khách
khachHang.stream()
.flatMap(k -> k.getDonHang().stream())
.toList();
// mapToInt/Long/Double — chuyển sang stream nguyên thủy
ds.stream().mapToInt(String::length).sum();
ds.stream().mapToInt(String::length).average().orElse(0);
// takeWhile / dropWhile (Java 9+) — dừng/bỏ theo điều kiện
Stream.of(1, 2, 3, 10, 4, 5).takeWhile(n -> n < 5).toList(); // [1,2,3]
Stream.of(1, 2, 3, 10, 4, 5).dropWhile(n -> n < 5).toList(); // [10,4,5]
// ⚠️ Khác filter: chúng DỪNG ở phần tử đầu tiên không thoả
peek chỉ để gỡ lỗi
peek nhận một Consumer và trả về stream nguyên vẹn — có vẻ tiện để "làm gì đó
giữa chừng". Nhưng hai vấn đề: (1) vì stream lười, peek có thể không chạy
với mọi phần tử (hoặc không chạy chút nào nếu thiếu thao tác kết thúc); (2) từ Java 9,
trình biên dịch được phép bỏ qua peek nếu nó chứng minh được kết quả không đổi (ví
dụ khi bạn chỉ gọi count()).
Javadoc nói rõ peek "tồn tại chủ yếu để hỗ trợ gỡ lỗi". Đừng dùng nó để thay đổi trạng thái.
8. Thao tác kết thúc
List<String> ds = List.of("java", "python", "go");
// --- Thu thập ---
ds.stream().toList(); // Java 16+, BẤT BIẾN ⭐
ds.stream().collect(Collectors.toList()); // cách cũ, sửa được
ds.stream().toArray(String[]::new);
// --- Duyệt ---
ds.forEach(System.out::println);
ds.stream().forEachOrdered(System.out::println); // giữ thứ tự cả khi song song
// --- Đếm và thống kê ---
ds.stream().count();
ds.stream().mapToInt(String::length).summaryStatistics();
// IntSummaryStatistics{count=3, sum=12, min=2, average=4.0, max=6}
// --- Tìm kiếm (NGẮN MẠCH — dừng ngay khi có kết quả) ---
ds.stream().findFirst(); // Optional
ds.stream().findAny(); // Optional (hợp với song song)
ds.stream().anyMatch(s -> s.startsWith("j")); // true
ds.stream().allMatch(s -> s.length() > 1); // true
ds.stream().noneMatch(String::isEmpty); // true
// --- Min / max ---
ds.stream().max(Comparator.comparingInt(String::length));
// --- reduce: gộp về một giá trị ---
Optional<String> noi = ds.stream().reduce((a, b) -> a + ", " + b);
String noi2 = ds.stream().reduce("", (a, b) -> a + b); // có giá trị khởi đầu
int tongDoDai = ds.stream().reduce(0, (t, s) -> t + s.length(), Integer::sum);
// ⚠️ Nhưng thường có cách gọn hơn reduce:
int tot = ds.stream().mapToInt(String::length).sum(); // ✅ rõ hơn
9. Collectors — sức mạnh thật sự
record SinhVien(String ten, String lop, double diem, int tuoi) { }
List<SinhVien> ds = ...;
// --- Thu thập cơ bản ---
ds.stream().collect(Collectors.toList());
ds.stream().collect(Collectors.toSet());
ds.stream().collect(Collectors.toCollection(TreeSet::new));
// --- toMap ---
Map<String, Double> tenDiem = ds.stream()
.collect(Collectors.toMap(SinhVien::ten, SinhVien::diem));
// ⚠️ Khoá TRÙNG → IllegalStateException: Duplicate key
// ✅ Xử lý trùng bằng hàm gộp
Map<String, Double> anToan = ds.stream()
.collect(Collectors.toMap(SinhVien::ten, SinhVien::diem, Double::max));
// --- joining: nối chuỗi ---
String tenAll = ds.stream().map(SinhVien::ten)
.collect(Collectors.joining(", ", "[", "]")); // [An, Bình, Chi]
// --- Thống kê ---
Collectors.counting();
Collectors.summingDouble(SinhVien::diem);
Collectors.averagingDouble(SinhVien::diem);
Collectors.summarizingDouble(SinhVien::diem); // count/sum/min/avg/max
Collectors.maxBy(Comparator.comparingDouble(SinhVien::diem));
// ⭐⭐ groupingBy — thao tác mạnh nhất của Stream API
// Nhóm đơn giản
Map<String, List<SinhVien>> theoLop = ds.stream()
.collect(Collectors.groupingBy(SinhVien::lop));
// Nhóm + đếm
Map<String, Long> siSo = ds.stream()
.collect(Collectors.groupingBy(SinhVien::lop, Collectors.counting()));
// Nhóm + tính trung bình
Map<String, Double> diemTb = ds.stream()
.collect(Collectors.groupingBy(SinhVien::lop,
Collectors.averagingDouble(SinhVien::diem)));
// Nhóm + lấy danh sách tên
Map<String, List<String>> tenTheoLop = ds.stream()
.collect(Collectors.groupingBy(SinhVien::lop,
Collectors.mapping(SinhVien::ten, Collectors.toList())));
// Nhóm HAI TẦNG
Map<String, Map<Boolean, List<SinhVien>>> hai = ds.stream()
.collect(Collectors.groupingBy(SinhVien::lop,
Collectors.groupingBy(s -> s.diem() >= 8)));
// Chỉ định kiểu Map cụ thể
TreeMap<String, Long> sapXep = ds.stream()
.collect(Collectors.groupingBy(SinhVien::lop,
TreeMap::new, Collectors.counting()));
// --- partitioningBy: chia ĐÔI theo điều kiện (nhanh hơn groupingBy) ---
Map<Boolean, List<SinhVien>> dat = ds.stream()
.collect(Collectors.partitioningBy(s -> s.diem() >= 5));
dat.get(true); // đạt
dat.get(false); // không đạt — LUÔN có cả hai khoá, kể cả khi rỗng
// --- teeing (Java 12+): hai collector cùng lúc ---
record ThongKe(double min, double max) { }
ThongKe tk = ds.stream().collect(Collectors.teeing(
Collectors.minBy(Comparator.comparingDouble(SinhVien::diem)),
Collectors.maxBy(Comparator.comparingDouble(SinhVien::diem)),
(a, b) -> new ThongKe(a.map(SinhVien::diem).orElse(0.0),
b.map(SinhVien::diem).orElse(0.0))
));
10. Optional
// --- Tạo ---
Optional<String> a = Optional.of("giá trị"); // NPE nếu null
Optional<String> b = Optional.ofNullable(coTheNull);
Optional<String> c = Optional.empty();
// --- ❌ CÁCH DÙNG SAI: chỉ thay if-null bằng if-isPresent ---
if (b.isPresent()) {
String s = b.get();
System.out.println(s.toUpperCase());
}
// Không tốt hơn `if (x != null)` chút nào — chỉ dài hơn
// --- ✅ CÁCH DÙNG ĐÚNG: nối chuỗi thao tác ---
b.map(String::toUpperCase).ifPresent(System.out::println);
// --- Lấy giá trị ---
b.orElse("mặc định"); // giá trị đã tính sẵn
b.orElseGet(() -> tinhToanTonKem()); // ⭐ CHỈ chạy khi rỗng
b.orElseThrow(); // NoSuchElementException
b.orElseThrow(() -> new MyException("không thấy"));
// --- Biến đổi ---
b.map(String::length); // Optional<Integer>
b.filter(s -> s.length() > 3); // rỗng nếu không thoả
b.flatMap(s -> timTrongDb(s)); // khi hàm đã trả Optional
b.or(() -> Optional.of("dự phòng")); // Java 9+
// --- Thực hiện hành động ---
b.ifPresent(System.out::println);
b.ifPresentOrElse(System.out::println,
() -> System.out.println("trống")); // Java 9+
b.stream(); // Java 9+: Stream 0 hoặc 1 phần tử
// --- Ví dụ thực tế: chuỗi tra cứu an toàn ---
String thanhPho = timNguoiDung(id) // Optional<NguoiDung>
.flatMap(NguoiDung::getDiaChi) // Optional<DiaChi>
.map(DiaChi::getThanhPho) // Optional<String>
.filter(t -> !t.isBlank())
.orElse("Không rõ");
// Thay cho bốn tầng kiểm tra null lồng nhau
Optional// ❌ ① Làm TRƯỜNG của class
class NguoiDung {
private Optional<String> email; // ❌ Optional KHÔNG Serializable,
// tốn thêm object cho mỗi instance
private String email; // ✅ trường có thể null...
public Optional<String> getEmail() {
return Optional.ofNullable(email); // ...nhưng getter trả Optional ✅
}
}
// ❌ ② Làm THAM SỐ method
void xuLy(Optional<String> ten) { } // ❌ người gọi phải bọc thủ công,
// và vẫn truyền null được!
void xuLy(String ten) { } // ✅ hoặc nạp chồng hai bản
// ❌ ③ Gọi get() mà không kiểm tra
String s = tim(id).get(); // ❌ NoSuchElementException — không hơn gì NPE
String s2 = tim(id).orElseThrow(
() -> new KhongTimThay("Không có người dùng " + id)); // ✅ có ngữ cảnh
// ❌ Optional<List> — dùng list rỗng thay vì Optional rỗng
Optional<List<String>> ds; // ❌ hai cách biểu diễn "không có gì"
List<String> ds2; // ✅ trả List.of() khi rỗng
Mục đích thiết kế của Optional (theo chính tác giả Brian Goetz): làm
kiểu trả về cho những phương thức mà "không có kết quả" là chuyện bình thường. Nó không phải
công cụ thay thế mọi chỗ dùng null.
11. Parallel stream — và vì sao thường không nên dùng
// Chuyển sang song song chỉ cần một từ
long tong = ds.parallelStream().mapToLong(SinhVien::diem).sum();
long tong2 = ds.stream().parallel().count();
// ⚠️ Mặc định dùng ForkJoinPool.commonPool() — CHUNG cho toàn ứng dụng!
System.out.println(ForkJoinPool.commonPool().getParallelism());
// = số nhân CPU - 1
| Điều kiện để parallel có thể nhanh hơn | Vì sao |
|---|---|
| Dữ liệu rất lớn (từ ~10.000 phần tử) | Chi phí chia việc và gộp kết quả phải nhỏ hơn phần tiết kiệm được |
| Mỗi phần tử tốn nhiều CPU | Nếu thao tác chỉ là x * 2, chi phí điều phối lớn hơn công việc |
| Nguồn chia tách tốt | ArrayList, mảng, IntStream.range chia dễ; LinkedList, Iterator thì không |
| Thao tác không phụ thuộc thứ tự | sorted, limit, forEachOrdered buộc phải đồng bộ |
| Hàm không có tác dụng phụ | Sửa biến chung từ nhiều luồng gây kết quả sai |
| Không dính I/O | Luồng chờ I/O làm nghẽn pool chung của toàn ứng dụng |
// ❌ SAI: tác dụng phụ lên collection không an toàn luồng
List<String> kq = new ArrayList<>();
ds.parallelStream().forEach(kq::add);
// → mất phần tử, hoặc ArrayIndexOutOfBoundsException, hoặc null xen kẽ
// Kết quả KHÁC NHAU mỗi lần chạy — loại bug khó tái hiện nhất
// ✅ ĐÚNG: để collect() lo việc gộp
List<String> kq2 = ds.parallelStream().map(...).toList();
// ❌ SAI: I/O trong parallel stream
duongDan.parallelStream().forEach(p -> Files.readString(p));
// → chiếm hết pool CHUNG, mọi parallel stream khác trong ứng dụng bị treo
// ❌ SAI: dữ liệu quá nhỏ
List.of(1, 2, 3).parallelStream().map(n -> n * 2).toList();
// → chậm hơn tuần tự HÀNG CHỤC lần vì chi phí khởi tạo pool
12. Khi nào không dùng Stream
// ❌ Stream cho việc quá đơn giản — dài hơn và chậm hơn
IntStream.range(0, 10).forEach(System.out::println);
for (int i = 0; i < 10; i++) System.out.println(i); // ✅ rõ hơn
// ❌ Cần cả CHỈ SỐ lẫn giá trị — Stream làm rất vụng
IntStream.range(0, ds.size())
.forEach(i -> System.out.println(i + ": " + ds.get(i)));
for (int i = 0; i < ds.size(); i++) // ✅
System.out.println(i + ": " + ds.get(i));
// ❌ Cần break/continue giữa chừng với logic phức tạp
// Stream chỉ có findFirst/anyMatch/takeWhile — không linh hoạt bằng vòng lặp
// ❌ Cần sửa biến bên ngoài
// Vướng ràng buộc effectively final; ép dùng AtomicInteger là dấu hiệu
// bạn đang dùng sai công cụ
// ❌ Ném checked exception bên trong lambda
duongDan.forEach(p -> Files.readString(p)); // ❌ không biên dịch được
for (Path p : duongDan) Files.readString(p); // ✅ với method có throws
// ✅ Stream toả sáng khi: LỌC → BIẾN ĐỔI → NHÓM/TỔNG HỢP
Map<String, Double> diemTbTheoLop = ds.stream()
.filter(s -> s.tuoi() >= 18)
.collect(Collectors.groupingBy(SinhVien::lop,
Collectors.averagingDouble(SinhVien::diem)));
// Viết bằng vòng lặp: khoảng 15 dòng với Map lồng nhau và kiểm tra null
13. Cheat sheet chương 10
| Tình huống | Cách viết |
|---|---|
| Lọc rồi thu về list | ds.stream().filter(dk).toList() |
| Biến đổi | .map(X::getY) |
| Làm phẳng lồng nhau | .flatMap(List::stream) |
| Nhóm theo tiêu chí | collect(groupingBy(X::getY)) |
| Nhóm + đếm | collect(groupingBy(X::getY, counting())) |
| Chia đôi | collect(partitioningBy(dk)) |
| Nối chuỗi | collect(joining(", ", "[", "]")) |
| Tổng / trung bình | .mapToInt(X::getY).sum() / .average() |
| Tìm phần tử đầu thoả | .filter(dk).findFirst() |
| Kiểm tra tồn tại | .anyMatch(dk) |
| Sắp nhiều tiêu chí | .sorted(comparing(A).thenComparing(B)) |
| Top N | .sorted(cmp).limit(n) |
| Loại trùng | .distinct() (cần equals/hashCode) |
| Tránh autoboxing | IntStream, mapToInt, IntPredicate |
| Giá trị mặc định tốn kém | opt.orElseGet(() -> …) — không phải orElse |
| Chuỗi tra cứu an toàn | opt.flatMap(…).map(…).orElse(…) |
| Kiểu trả về "có thể không có" | Optional<T> — không dùng cho trường/tham số |
| Song song | Chỉ khi đã đo — mặc định là không |
Bài tập
Viết lại chương 8 bằng Stream
Lấy bài "Phân tích tệp nhật ký" ở chương 8 và viết lại toàn bộ bằng Stream:
- Số dòng theo cấp độ →
groupingBy+counting. - Tập cấp độ của mỗi module →
groupingBy+mapping+toSet. - Top 5 module nhiều lỗi nhất →
groupingBy→entrySet().stream()→sorted→limit. - Lỗi theo giờ, sắp theo thời gian →
groupingBy(…, TreeMap::new, counting()). - Module có lỗi mọi giờ → kết hợp
groupingByvàfilter.
Sau đó lập bảng so sánh: số dòng code, độ dễ đọc (tự chấm 1-5), và thời gian chạy với tệp 1 triệu dòng.
Gợi ý
Kết luận bạn nên rút ra: Stream ngắn hơn nhiều ở các bài nhóm/tổng hợp (thường 1 dòng
thay 8-10 dòng), nhưng có thể chậm hơn một chút vì chi phí tạo object trung gian. Với
dữ liệu vừa và nhỏ, tính rõ ràng thắng; với vòng lặp nóng hàng triệu lần, vòng lặp thắng.
Đọc tệp: try (var dong = Files.lines(path)) { … } — Stream từ tệp
phải đóng.
Hệ thống báo cáo bán hàng
Cho record DonHang(String maDon, String khachHang, String sanPham, int soLuong, long donGia, LocalDate ngay), viết các báo cáo chỉ dùng Stream:
- Tổng doanh thu theo tháng, sắp theo thời gian.
- Top 3 sản phẩm bán chạy nhất (theo số lượng).
- Top 3 khách hàng chi nhiều nhất (theo tổng tiền).
- Với mỗi khách: số đơn, tổng tiền, đơn giá trị nhất — gom vào một
record ThongKeKhach. - Sản phẩm nào chưa từng được khách VIP mua? (VIP = tổng chi > 10 triệu)
- Giá trị đơn hàng trung bình theo từng ngày trong tuần.
- Chuỗi ngày liên tiếp dài nhất có đơn hàng.
Gợi ý
(1): groupingBy(d -> YearMonth.from(d.ngay()), TreeMap::new, summingLong(this::tongTien)).
(4): Collectors.collectingAndThen hoặc teeing; hoặc đơn giản nhất là
groupingBy rồi entrySet().stream().map(…).
(7) là bài duy nhất mà Stream không phải công cụ tốt — cần trạng thái giữa các phần tử.
Hãy thử làm bằng Stream, thấy nó gượng ép ra sao, rồi viết lại bằng vòng lặp và so sánh. Đây là
bài học về giới hạn của Stream.
Tự cài Stream tối giản
Cài đặt MyStream<T> với API tối giản để hiểu tính lười hoạt động thế nào:
interface MyStream<T> {
static <T> MyStream<T> of(List<T> ds);
MyStream<T> filter(Predicate<? super T> dk); // lười
<R> MyStream<R> map(Function<? super T, ? extends R> f); // lười
MyStream<T> limit(int n); // lười
List<T> toList(); // kết thúc
Optional<T> findFirst(); // kết thúc, ngắn mạch
long count(); // kết thúc
}
- Thao tác trung gian không được xử lý gì — chỉ ghi lại việc cần làm.
- Xử lý theo chiều dọc: mỗi phần tử đi qua toàn bộ chuỗi rồi mới tới phần tử sau.
findFirstvàlimitphải ngắn mạch thật sự.- Ném
IllegalStateExceptionnếu dùng lại stream đã tiêu thụ. - Chứng minh bằng
System.out.printlntrong lambda rằng thứ tự thực thi đúng như Stream thật.
Gợi ý
Cách đơn giản nhất: lưu một danh sách các "giai đoạn", mỗi giai đoạn là một
Function<Object, Object> hoặc một record mô tả loại thao tác. Khi thao tác
kết thúc chạy, duyệt phần tử nguồn, cho từng phần tử đi qua toàn bộ giai đoạn, và dừng sớm khi
đủ điều kiện.
Cách "đúng chuẩn" hơn (và là cách JDK làm) là dùng mẫu Sink: mỗi giai đoạn bọc giai
đoạn sau và có phương thức accept(T) cùng cờ cancellationRequested().
Refactor code null-check sang Optional
Viết lại đoạn sau bằng Optional, rồi trả lời các câu hỏi bên dưới.
public String layMaBuuChinh(String maNguoiDung) {
NguoiDung nd = repo.tim(maNguoiDung);
if (nd != null) {
DiaChi dc = nd.getDiaChi();
if (dc != null) {
String ma = dc.getMaBuuChinh();
if (ma != null && !ma.isBlank()) {
return ma.toUpperCase();
}
}
}
return "KHONG_RO";
}
- Viết lại với
Optional— bao nhiêu dòng? - Nếu
repo.tim()đổi thành trảOptional<NguoiDung>, code đổi thế nào? - Vì sao dùng
flatMapchứ không phảimapkhi getter đã trảOptional? - Phiên bản nào dễ đọc hơn với người mới? Phiên bản nào dễ mở rộng hơn khi thêm một tầng nữa?
Gợi ý
map với hàm trả Optional<X> sẽ cho
Optional<Optional<X>> — lồng hai tầng, vô dụng.
flatMap "làm phẳng" một tầng, đúng như flatMap của Stream.
Câu cuối là câu quan trọng: chuỗi Optional thêm một tầng chỉ tốn một
dòng, còn null-check lồng nhau thêm ba dòng và một cấp thụt lề.
Đo hiệu năng: vòng lặp vs Stream vs parallel
Benchmark ba cách với n = 1.000 / 100.000 / 10.000.000:
- Tổng bình phương các số chẵn — phép tính nhẹ.
- Tổng của
Math.sqrt(Math.sin(x) * Math.cos(x))— phép tính nặng. - Lọc + ánh xạ + thu thập 1 triệu chuỗi.
Với mỗi bài, chạy cả ba cách: vòng lặp for, stream(), parallelStream(). Lập bảng và trả lời:
- Ở kích thước nào parallel bắt đầu thắng? Có khác nhau giữa bài 1 và bài 2 không? Vì sao?
IntStreamso vớiStream<Integer>chênh nhau bao nhiêu? Giải thích bằng autoboxing.- Máy bạn có mấy nhân? Tốc độ tăng có tỉ lệ với số nhân không?
Gợi ý
Kết quả điển hình: với phép tính nhẹ, parallel chỉ thắng từ vài triệu phần tử trở lên;
với phép tính nặng, nó thắng ngay từ vài nghìn. Điều này khẳng định:
lượng công việc trên mỗi phần tử quan trọng hơn số lượng phần tử.
Stream<Integer> chậm hơn IntStream khoảng 4-6 lần do
autoboxing.
Warm-up JIT rất quan trọng ở bài này: chạy ít nhất 5 lượt và bỏ 2 lượt đầu. Lý tưởng nhất là
dùng JMH, nhưng đo thủ công cũng đủ thấy xu hướng.
Quiz
Functional interface là gì? Vì sao nó cần thiết cho lambda?
Xem đáp án
Là interface có đúng một phương thức trừu tượng (SAM — Single Abstract Method).
Phương thức default và static không tính, muốn thêm bao nhiêu cũng được.
Vì sao cần thiết: lambda không có tên và không có kiểu riêng — nó chỉ là "cách viết
tắt" để cài đặt một interface. Trình biên dịch phải xác định được lambda đó cài đặt phương thức nào.
Nếu interface có hai phương thức trừu tượng, không có cách nào biết (a, b) -> a + b
là cài đặt cái nào.
Chú thích @FunctionalInterface là tuỳ chọn nhưng nên có: nó khiến trình
biên dịch kiểm tra ràng buộc. Không có nó, ai đó thêm phương thức thứ hai và mọi lambda dùng
interface đó trong toàn dự án ngừng biên dịch — với lỗi báo ở chỗ khác, rất khó truy nguyên.
Nêu ba đặc tính của Stream.
Xem đáp án
-
Lười (lazy) — thao tác trung gian (
filter,map,sorted) không chạy gì cả cho tới khi có thao tác kết thúc. Không có thao tác kết thúc thì không có gì xảy ra. -
Một lần dùng — tiêu thụ xong là hết. Dùng lại ném
IllegalStateException: stream has already been operated upon or closed. Cần dùng hai lần thì tạo hai stream từ nguồn. - Không sửa nguồn — mọi thao tác trả về stream hoặc giá trị mới; collection gốc không đổi.
Hệ quả quan trọng nhất của tính lười: Stream xử lý theo chiều dọc — mỗi phần tử đi
qua toàn bộ chuỗi thao tác rồi mới tới phần tử sau. Nhờ vậy không tạo collection trung gian, và các
thao tác ngắn mạch (findFirst, anyMatch, limit) dừng ngay khi
đủ.
map và flatMap khác nhau thế nào?
Xem đáp án
map biến đổi một phần tử thành một phần tử —
T → R. Số phần tử không đổi.
flatMap biến đổi một phần tử thành một stream, rồi
nối tất cả các stream đó lại — T → Stream<R>, kết quả là
Stream<R> chứ không phải Stream<Stream<R>>. Số phần tử
thay đổi.
List<String> cau = List.of("xin chào", "thế giới");
cau.stream().map(c -> c.split(" ")).toList();
// List<String[]> — hai mảng ❌
cau.stream().flatMap(c -> Arrays.stream(c.split(" "))).toList();
// [xin, chào, thế, giới] ✅
Dùng flatMap khi cần làm phẳng cấu trúc lồng nhau:
List<List<X>> → List<X>, hoặc lấy mọi đơn hàng của mọi
khách hàng. Optional cũng có flatMap với ý nghĩa y hệt.
Vì sao biến bên ngoài dùng trong lambda phải "effectively final"?
Xem đáp án
Vì lambda có thể sống lâu hơn phương thức tạo ra nó — được lưu vào trường, truyền sang luồng khác, hoặc trả về. Biến cục bộ nằm trên stack và biến mất khi phương thức kết thúc, nên lambda phải sao chép giá trị vào chính nó.
Nếu cho phép sửa, sẽ có hai bản sao khác nhau: biến gốc và bản trong lambda. Sửa bên nào thì bên kia không thấy — nguồn bug cực kỳ khó hiểu. Java chọn cấm hẳn thay vì để lập trình viên tự xoay xở.
Cách xử lý khi cần "biến đổi":
- Dùng mảng một phần tử
int[] dem = {0}— bản thân mảng là tham chiếu, không đổi. - Dùng
AtomicInteger— an toàn cả với đa luồng. - Tốt nhất: viết lại bằng Stream để không cần biến đếm nào —
.count(),.sum(),.collect().
Liên quan: this trong lambda trỏ tới object bao ngoài, không phải lambda —
khác hẳn anonymous class. Lambda không tạo phạm vi this mới.
Optional.orElse và orElseGet khác nhau thế nào?
Xem đáp án
orElse(T) nhận một giá trị đã tính sẵn — biểu thức đó
luôn được đánh giá, kể cả khi Optional có giá trị.
orElseGet(Supplier) nhận một hàm —
chỉ chạy khi Optional rỗng.
Optional<String> co = Optional.of("giá trị");
co.orElse(tinhToanTonKem()); // ⚠️ tinhToanTonKem() VẪN CHẠY!
co.orElseGet(() -> tinhToanTonKem()); // ✅ KHÔNG chạy
Quy tắc chọn:
orElse— hằng số hoặc giá trị rẻ:orElse(""),orElse(0).orElseGet— mọi thứ tốn kém: truy vấn database, đọc tệp, tạo object lớn.
Đây là bẫy hiệu năng phổ biến nhất với Optional, và cũng là một lỗi âm thầm — code vẫn
đúng, chỉ chậm hơn cần thiết. Tương tự với orElseThrow(Supplier): exception chỉ được
tạo khi thực sự cần ném.
Vì sao không nên dùng Optional làm trường của class hay tham số method?
Xem đáp án
Theo chính tác giả Brian Goetz, Optional được thiết kế cho một mục đích duy
nhất: làm kiểu trả về của phương thức mà "không có kết quả" là chuyện bình thường.
Làm trường:
OptionalkhôngSerializable— phá vỡ serialization.- Tốn thêm một object cho mỗi instance — đáng kể với hàng triệu object.
- Framework (JPA, Jackson) thường không xử lý được.
Làm tham số:
- Người gọi phải tự bọc
Optional.of(x)— dài dòng. - Vẫn truyền
nullvào được, nên chẳng an toàn hơn gì — chỉ thêm một tầng. - Thay vào đó: nạp chồng method, hoặc dùng builder.
Cách đúng:
private String email; // trường: có thể null
public Optional<String> getEmail() { // getter: trả Optional ✅
return Optional.ofNullable(email);
}
Cũng đừng dùng Optional<List> — trả về List.of() rỗng, vì list rỗng
đã biểu diễn "không có gì" rồi.
Khi nào parallel stream thực sự nhanh hơn?
Xem đáp án
Chỉ khi thoả đồng thời các điều kiện:
- Dữ liệu lớn — từ khoảng 10.000 phần tử trở lên.
- Phép tính nặng trên mỗi phần tử — đây là yếu tố quan trọng hơn số lượng. Với
x * 2, chi phí điều phối lớn hơn công việc. - Nguồn chia tách tốt —
ArrayList, mảng,IntStream.rangechia dễ;LinkedList,Iterator,Files.linesthì không. - Không phụ thuộc thứ tự —
sorted,limit,forEachOrderedbuộc phải đồng bộ, triệt tiêu lợi ích. - Không có tác dụng phụ — sửa collection chung từ nhiều luồng cho kết quả sai.
- Không có I/O — luồng chờ I/O làm nghẽn
ForkJoinPool.commonPool(), ảnh hưởng toàn bộ ứng dụng vì pool này dùng chung.
Bẫy lớn nhất: mọi parallel stream mặc định chia sẻ một pool chung. Một tác vụ chậm sẽ làm treo tất cả những chỗ khác.
Quy tắc: mặc định không dùng. Chỉ dùng khi đã đo với dữ liệu thật và chứng minh được nó nhanh hơn.
Khi nào vòng lặp for tốt hơn Stream?
Xem đáp án
Dùng for khi:
- Cần cả chỉ số lẫn giá trị — Stream làm việc này rất vụng, phải qua
IntStream.rangerồiget(i). - Cần
break/continuevới logic phức tạp — Stream chỉ cófindFirst,anyMatch,takeWhile. - Cần sửa biến bên ngoài — vướng ràng buộc effectively final; phải dùng
AtomicIntegerlà dấu hiệu sai công cụ. - Ném checked exception — lambda không khai báo
throwsđược. - Thao tác quá đơn giản —
IntStream.range(0,10).forEach(…)dài hơn và chậm hơnfor. - Vòng lặp nóng cực kỳ — Stream có chi phí tạo object trung gian và gọi lambda.
Dùng Stream khi: chuỗi lọc → biến đổi → nhóm/tổng hợp. Đây là chỗ nó thắng áp đảo:
ds.stream()
.filter(s -> s.tuoi() >= 18)
.collect(groupingBy(SinhVien::lop, averagingDouble(SinhVien::diem)));
// Viết bằng vòng lặp: ~15 dòng với Map lồng nhau và kiểm tra null
Tiêu chí thực dụng: "Cách nào diễn đạt ý định rõ hơn?" Không có bên nào luôn thắng.
Tổng kết
Sau chương 10, bạn đã nắm:
- Functional interface — đúng một phương thức trừu tượng; luôn thêm
@FunctionalInterface. - Lambda: mọi dạng rút gọn; ràng buộc effectively final và lý do đằng sau.
- Bốn loại method reference: static, instance cụ thể, instance tuỳ ý, constructor.
- Interface hàm dựng sẵn và cách kết hợp (
andThen,compose,negate,and,or). - Ba đặc tính Stream: lười, một lần dùng, không sửa nguồn; xử lý theo chiều dọc.
- Trung gian:
filter,map,flatMap,distinct,sorted,limit,takeWhile. - Kết thúc:
toList,collect,reduce,count,anyMatch,findFirst. Collectors:groupingBynhiều tầng,partitioningBy,joining,toMap,teeing.Optionalđúng cách: nối chuỗi thay vìisPresent+get; chỉ dùng làm kiểu trả về.- Parallel stream: sáu điều kiện, pool chung, và quy tắc "đo trước khi dùng".
- Giới hạn của Stream — khi nào
forvẫn tốt hơn.
Kết nối
- Chương 5 (OOP nâng cao) — lambda thay anonymous class; functional interface là interface một method.
- Chương 7-8 (Collections) —
stream(),removeIf,forEach,Comparatortổ hợp. - Chương 9 (Generics) —
Function<? super T, ? extends R>: PECS ở khắp mọi chữ ký của Stream API. - Chương 6 (Ngoại lệ) — vì sao lambda kỵ checked exception;
UncheckedIOException. - Chương 11 (Tệp tin) —
Files.lines()trả Stream và phải đóng. - Chương 12 (Đa luồng) —
ForkJoinPoolđứng sau parallel stream;CompletableFuturecũng dùng lambda. - Chương 13-14 — ánh xạ
ResultSetbằng Stream; assertion của JUnit nhận lambda. - Python Chương 9 — đối chiếu: generator cũng lười và một lần dùng, nhưng dùng
yieldthay chuỗi phương thức; comprehension gần vớimap+filter. - JavaScript Chương 5 —
Array.map/filter/reducekhông lười (tạo mảng trung gian mỗi bước), khác hẳn Stream.