"Stream chậm hơn vòng lặp" là một trong những câu được nhắc nhiều nhất về Java, và cũng là một trong những câu ít khi kèm số liệu nhất.
Tôi đo bốn bài toán bằng JMH.
Bốn kết quả
VongLapVsStream.congMang_vongLap 237.705 us/op
VongLapVsStream.congMang_stream 235.167 us/op <- ngang nhau
VongLapVsStream.locGom_vongLap 706.782 us/op
VongLapVsStream.locGom_stream 768.834 us/op <- chậm hơn 9%
VongLapVsStream.gomNhom_vongLap 629.182 us/op
VongLapVsStream.gomNhom_stream 732.829 us/op <- chậm hơn 16%
VongLapVsStream.nho_vongLap 0.006 us/op
VongLapVsStream.nho_stream 0.024 us/op <- chậm hơn 4 lần
Ba nhận xét trước khi bàn.
Cộng một triệu số: ngang nhau
for (int x : mang) t += x; // 237,7 us
Arrays.stream(mang).asLongStream().sum(); // 235,2 us
Chênh lệch nằm trong sai số. Đây là kết quả làm nhiều người bất ngờ, vì trực giác nói Stream phải chậm hơn — có bao nhiêu tầng bọc ở giữa.
Lý do: IntStream trên mảng là đường đi được tối ưu kỹ nhất trong toàn bộ Stream API. Không có đóng hộp, spliterator biết trước kích thước, và JIT nội tuyến gần như toàn bộ chuỗi thành một vòng lặp.
Bài học: đừng giả định Stream chậm hơn khi làm việc với kiểu nguyên thuỷ.
Lọc, biến đổi, gom: chậm hơn khoảng 10%
// vòng lặp — 706,8 us
List<String> kq = new ArrayList<>();
for (NhanVien n : ds) if (n.luong() > 30_000_000L) kq.add(n.ten().toUpperCase());
// stream — 768,8 us
ds.stream().filter(n -> n.luong() > 30_000_000L).map(n -> n.ten().toUpperCase()).toList();
Trên 100.000 phần tử, Stream tốn thêm 62 micro giây.
Chi phí đến từ việc dựng đường ống và các lời gọi lambda mà JIT không nội tuyến hết. 9% là con số điển hình cho loại bài toán này.
Câu hỏi thực tế: 62 micro giây có đáng để đánh đổi lấy hai dòng mã rõ ràng hơn không? Với gần như mọi mã nghiệp vụ, câu trả lời là có — 62 micro giây là 0,06 mili giây, trong khi một truy vấn CSDL đơn giản đã tốn vài mili giây.
Gom nhóm: chậm hơn 16%
// vòng lặp — 629,2 us
Map<String,Long> m = new HashMap<>();
for (NhanVien n : ds) m.merge(n.phong(), 1L, Long::sum);
// stream — 732,8 us
ds.stream().collect(groupingBy(NhanVien::phong, counting()));
Đây là chênh lệch lớn nhất trong ba bài toán trên dữ liệu lớn, và cũng là chỗ tôi thấy đáng bàn nhất.
Bản vòng lặp dùng Map.merge — một phương thức nhiều người không biết, và nếu không biết thì họ sẽ viết computeIfAbsent hoặc một cặp containsKey/get/put dài hơn nhiều và có thể sai.
Bản Stream đọc ra ngay ý định: gom theo phòng, đếm.
Nên so sánh công bằng không phải "vòng lặp ngắn gọn với Stream", mà là "vòng lặp viết đúng với Stream". Và khi đó khoảng cách về độ rõ ràng thường lớn hơn 16% kia.
Danh sách 20 phần tử: chậm hơn bốn lần
nho_vongLap 0.006 us/op
nho_stream 0.024 us/op
Bốn lần nghe nhiều, nhưng con số tuyệt đối là 18 nano giây.
Chi phí khởi tạo đường ống Stream gần như cố định, nên với dữ liệu nhỏ nó chiếm toàn bộ thời gian. Cùng lý do với parallelStream trên 100 phần tử ở bài hôm kia, chỉ nhẹ hơn nhiều bậc.
18 nano giây không bao giờ là vấn đề — trừ khi đoạn mã đó nằm trong một vòng lặp chạy hàng triệu lần.
Vậy chọn thế nào
Sau khi có số, tôi vẫn cho rằng hiệu năng không phải tiêu chí chọn trong 95% trường hợp. Đây là các tiêu chí tôi dùng:
Dùng Stream khi:
Chuỗi phép biến đổi đọc ra được ý định — filter, map, groupingBy nói rõ hơn vòng lặp tương đương.
Cần gom nhóm, thống kê, hoặc nối chuỗi — Collectors làm sẵn những thứ viết tay rất dễ sai.
Nguồn dữ liệu lười hoặc rất lớn — như bài hôm qua đã thấy.
Muốn dễ chuyển sang song song sau này.
Dùng vòng lặp khi:
Cần break hoặc continue với logic phức tạp. takeWhile giải quyết được vài trường hợp, còn lại thì vòng lặp rõ hơn hẳn.
Cần chỉ số phần tử. Stream không có khái niệm vị trí, và mọi cách vòng đều xấu.
Thân xử lý có tác dụng phụ — ghi file, gọi API, cập nhật trạng thái. Stream sinh ra cho biến đổi thuần tuý.
Cần sửa nhiều biến cùng lúc — lambda bị luật effectively final chặn, và mọi cách lách đều tệ.
Đoạn mã nằm trong đường đi nóng đã đo được là nút thắt.
Ba chỗ Stream làm mã tệ hơn
Cân bằng lại, vì tôi thấy nhiều mã lạm dụng Stream:
Chuỗi quá dài. Bảy tám phép nối nhau thì không ai đọc nổi. Tách thành các bước có tên, hoặc quay về vòng lặp.
Nhồi logic vào lambda. Một lambda mười dòng với if lồng nhau bên trong map — đó là một phương thức đang giả trang.
Dùng forEach thay cho vòng lặp. ds.forEach(x -> {...}) không hơn gì for (var x : ds) {...}, mà lại mất break, mất chỉ số, và stack trace khó đọc hơn. forEach chỉ nên dùng ở cuối một chuỗi thật.
Về việc đo
Mọi con số ở đây đo bằng JMH với 3 vòng làm nóng và 4 vòng đo, trên một máy cụ thể. Chúng cho biết thứ tự độ lớn, không phải hằng số vũ trụ.
Nếu bạn định dựa vào hiệu năng để chọn, hãy đo trên chính dữ liệu và phần cứng của mình. Và trước đó, hãy chắc rằng chỗ đó thật sự là nút thắt — kinh nghiệm của tôi là nó gần như không bao giờ là chỗ bạn đoán.
Thử ba mươi giây
Lấy một đoạn Stream trong dự án của bạn, đếm số phép nối trong chuỗi.
Nếu trên năm, thử tách làm hai bước có tên. Bạn sẽ thấy mã dễ đọc hơn mà hiệu năng không đổi — vì Stream lười, tách chuỗi không tạo thêm lần duyệt nào.
Ngày mai ta khép lại chặng lập trình hàm: viết mã hàm mà vẫn đọc được — khi nào chuỗi quá dài, cách đặt tên, và giới hạn của phong cách hàm trong Java.