Chương 10 · Lambda & Stream

Java hàm — lambda, Stream API và Optional

Java 8 (2014) là bản cập nhật thay đổi ngôn ngữ nhiều nhất kể từ khi ra đời. Nó mang lập trình hàm vào một ngôn ngữ vốn thuần hướng đối tượng: lambda thay cho anonymous class dài dòng, Stream API thay cho vòng lặp lồng nhau, và Optional thay cho việc trả về null. Chương này dạy cả ba — cùng với điều quan trọng không kém: khi nào không nên dùng chúng.

Độ dài: ~1700 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-9
🎯 Mục tiêu chương
  • 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 for vẫ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.java
@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;
💡 Luôn thêm @FunctionalInterface

Nó 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

Lambda.java — mọi dạng rút gọn
// 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;
⚠️ Biến bên ngoài phải "effectively final"
EffectivelyFinal.java
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

MethodReference.java
// ① 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

InterfacePhương thứcNhận → TrảDùng để
Function<T,R>R apply(T)T → RBiến đổi giá trị
Predicate<T>boolean test(T)T → booleanKiể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()∅ → TSinh giá trị (lười)
UnaryOperator<T>T apply(T)T → TFunction<T,T>
BinaryOperator<T>T apply(T,T)(T,T) → TGộp hai giá trị cùng kiểu
BiFunction<T,U,R>R apply(T,U)(T,U) → RHai tham số
BiPredicate<T,U>boolean test(T,U)(T,U) → booleanKiểm tra hai giá trị
BiConsumer<T,U>void accept(T,U)(T,U) → ∅Map.forEach
Runnablevoid run()∅ → ∅Tác vụ chạy trong luồng
Callable<V>V call()∅ → V (ném được)Tác vụ có kết quả
SuDung.java
// --- 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

Nguồn
collection.stream()
Arrays.stream()
Stream.of()
Trung gian (0..n)
filter map
sorted distinct
trả về Stream — LƯỜI
Kết thúc (1)
collect forEach
count reduce
kích hoạt xử lý
Không có thao tác kết thúc thì không có gì chạy cả. Đây là điều gây bất ngờ nhất cho người mới dùng Stream.
BaDacTinh.java
// ① 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

TaoStream.java
// 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

TrungGian.java
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

KetThuc.java
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ự

Collectors.java
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

Optional.java
// --- 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
⚠️ Ba cách dùng sai Optional
SaiLam.java
// ❌ ① 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

Parallel.java
// 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ơnVì 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 CPUNế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ốtArrayList, 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/OLuồng chờ I/O làm nghẽn pool chung của toàn ứng dụng
ParallelSai.java
// ❌ 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
Quy tắc về parallel stream: mặc định là không dùng. Chỉ dùng khi bạn đã đo và chứng minh được nó nhanh hơn với dữ liệu thật của mình. Câu nói phổ biến trong cộng đồng Java: "nếu bạn không đo, bạn đang làm chậm chương trình."

12. Khi nào không dùng Stream

KhiNaoKhong.java
// ❌ 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ốngCách viết
Lọc rồi thu về listds.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 + đếmcollect(groupingBy(X::getY, counting()))
Chia đôicollect(partitioningBy(dk))
Nối chuỗicollect(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 autoboxingIntStream, mapToInt, IntPredicate
Giá trị mặc định tốn kémopt.orElseGet(() -> …) — không phải orElse
Chuỗi tra cứu an toànopt.flatMap(…).map(…).orElse(…)
Kiểu trả về "có thể không có"Optional<T> — không dùng cho trường/tham số
Song songChỉ 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:

  1. Số dòng theo cấp độ → groupingBy + counting.
  2. Tập cấp độ của mỗi module → groupingBy + mapping + toSet.
  3. Top 5 module nhiều lỗi nhất → groupingBy → entrySet().stream() → sorted → limit.
  4. Lỗi theo giờ, sắp theo thời gian → groupingBy(…, TreeMap::new, counting()).
  5. Module có lỗi mọi giờ → kết hợp groupingBy và 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:

  1. Tổng doanh thu theo tháng, sắp theo thời gian.
  2. Top 3 sản phẩm bán chạy nhất (theo số lượng).
  3. Top 3 khách hàng chi nhiều nhất (theo tổng tiền).
  4. Với mỗi khách: số đơn, tổng tiền, đơn giá trị nhất — gom vào một record ThongKeKhach.
  5. Sản phẩm nào chưa từng được khách VIP mua? (VIP = tổng chi > 10 triệu)
  6. Giá trị đơn hàng trung bình theo từng ngày trong tuần.
  7. 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:

API
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.
  • findFirst và limit phải ngắn mạch thật sự.
  • Ném IllegalStateException nếu dùng lại stream đã tiêu thụ.
  • Chứng minh bằng System.out.println trong 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.

CanSua.java
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 flatMap chứ không phải map khi 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:

  1. Tổng bình phương các số chẵn — phép tính nhẹ.
  2. Tổng của Math.sqrt(Math.sin(x) * Math.cos(x)) — phép tính nặng.
  3. 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?
  • IntStream so với Stream<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

Q1

Functional interface là gì? Vì sao nó cần thiết cho lambda?

Xem đáp án
✓ Đá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.

Q2

Nêu ba đặc tính của Stream.

Xem đáp án
✓ Đáp án
  1. 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.
  2. 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.
  3. 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 đủ.

Q3

map và flatMap khác nhau thế nào?

Xem đáp án
✓ Đá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.

SoSanh.java
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.

Q4

Vì sao biến bên ngoài dùng trong lambda phải "effectively final"?

Xem đáp án
✓ Đá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.

Q5

Optional.orElse và orElseGet khác nhau thế nào?

Xem đáp án
✓ Đá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.

KhacBiet.java
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.

Q6

Vì sao không nên dùng Optional làm trường của class hay tham số method?

Xem đáp án
✓ Đá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:

  • Optional không Serializable — 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 null và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:

Dung.java
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.

Q7

Khi nào parallel stream thực sự nhanh hơn?

Xem đáp án
✓ Đá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.range chia dễ; LinkedList, Iterator, Files.lines thì không.
  • Không phụ thuộc thứ tự — sorted, limit, forEachOrdered buộ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.

Q8

Khi nào vòng lặp for tốt hơn Stream?

Xem đáp án
✓ Đá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.range rồi get(i).
  • Cần break/continue vớ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 AtomicInteger là 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ơn for.
  • 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:

StreamThang.java
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: groupingBy nhiề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 for vẫ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, Comparator tổ 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; CompletableFuture cũng dùng lambda.
  • Chương 13-14 — ánh xạ ResultSet bằ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 yield thay chuỗi phương thức; comprehension gần với map+filter.
  • JavaScript Chương 5 — Array.map/filter/reduce không lười (tạo mảng trung gian mỗi bước), khác hẳn Stream.