Đổi stream() thành parallelStream() là thay đổi rẻ nhất trong Java: một chữ, và mã chạy trên mọi nhân CPU.
Đó cũng chính là vấn đề. Vì nó rẻ tới mức người ta gõ vào mà không hỏi, và trong nhiều trường hợp nó làm mã chậm đi.
Tôi đo bốn tình huống trên máy 16 nhân.
Bốn kết quả
SongSong.mangLon_tuanTu 2458.906 us/op
SongSong.mangLon_songSong 348.493 us/op <- nhanh gấp 7
SongSong.nang_tuanTu 1941.818 us/op
SongSong.nang_songSong 295.718 us/op <- nhanh gấp 6,6
SongSong.lienKet_tuanTu 1639.822 us/op
SongSong.lienKet_songSong 2201.035 us/op <- CHẬM hơn 1,3 lần
SongSong.nho_tuanTu 0.046 us/op
SongSong.nho_songSong 97.150 us/op <- CHẬM hơn 2112 lần
Hai trường hợp đầu là thứ song song sinh ra để làm. Hai trường hợp sau là lý do đừng gõ parallel theo phản xạ.
Vì sao 100 phần tử lại chậm hơn hai nghìn lần
listNho.parallelStream().mapToLong(Integer::longValue).sum(); // 100 phần tử
Cộng 100 số là việc mất 46 nano giây khi chạy tuần tự. Còn chạy song song thì phải: dựng spliterator, chia nhỏ dữ liệu, đẩy công việc vào ForkJoinPool, chờ các luồng xong, gộp kết quả.
Toàn bộ bộ máy đó tốn khoảng 97 micro giây — gấp hai nghìn lần công việc thật.
Đây là bài học chung của mọi cơ chế song song: có một chi phí cố định để khởi động, và nó chỉ đáng khi phần việc thật đủ lớn để bù lại.
Với stream, ngưỡng thô thường được nhắc là khoảng 10.000 phần tử cho phép tính rẻ. Nhưng con số đó chỉ là điểm khởi đầu để đo, không phải quy tắc.
Vì sao LinkedList chạy song song lại chậm hơn
lienKet_tuanTu 1639.822 us/op
lienKet_songSong 2201.035 us/op
Cùng một triệu phần tử, cùng phép cộng — mà song song lại thua.
Nguyên nhân là tách dữ liệu. Để chia việc cho nhiều luồng, Stream cần cắt nguồn thành các khúc. Với mảng hay ArrayList, cắt là một phép chia chỉ số — tức thời, và mỗi khúc bằng nhau.
Với LinkedList thì không có cách nào biết "phần tử thứ 500.000 ở đâu" ngoài việc đi từ đầu. Nên spliterator phải duyệt tuần tự để tách, và toàn bộ lợi thế biến mất — chỉ còn chi phí.
Xếp hạng nguồn theo mức độ dễ tách:
| Nguồn | Tách |
|---|---|
mảng, ArrayList, IntStream.range |
rất tốt — chia đôi chỉ số |
HashMap, HashSet |
tốt — chia theo ngăn |
TreeMap, TreeSet |
khá — chia theo nhánh cây |
LinkedList |
tệ |
Stream.iterate, BufferedReader.lines |
rất tệ — bản chất tuần tự |
Nói cách khác: nguồn quan trọng hơn số phần tử.
Khi phép tính đắt thì ngưỡng hạ xuống rất nhiều
listLon.parallelStream().limit(20_000).mapToLong(x -> nang(x)).sum();
nang là một vòng lặp 200 phép căn bậc hai cho mỗi phần tử.
nang_tuanTu 1941.818 us/op
nang_songSong 295.718 us/op <- nhanh gấp 6,6
Chỉ 20.000 phần tử — ít hơn nhiều so với 10 triệu ở phép cộng — mà song song vẫn thắng đậm.
Vì tổng công việc mới là thứ quyết định, không phải số phần tử. Ngưỡng thật là số phần tử × chi phí mỗi phần tử.
Nên câu hỏi đúng không phải "danh sách của tôi có lớn không" mà là "tổng thời gian xử lý có đủ lớn để bù chi phí khởi động không".
Ba câu hỏi trước khi gõ parallel
Một: nguồn có tách được rẻ không? Mảng và ArrayList thì có. LinkedList, Stream.iterate, hay đọc file theo dòng thì không.
Hai: tổng công việc có đủ lớn không? Ước lượng thô: dưới một mili giây thì gần như chắc chắn không đáng.
Ba: phép xử lý có độc lập không? Mỗi phần tử phải xử lý được mà không phụ thuộc phần tử khác, và không đụng vào trạng thái dùng chung.
Nếu một trong ba câu trả lời là "không", đừng dùng song song.
Ba cái bẫy khác
forEach không giữ thứ tự khi song song. Cần thứ tự thì dùng forEachOrdered — nhưng nó ép đồng bộ và ăn mất phần lớn lợi ích. Thường tốt hơn là gom vào List rồi duyệt.
Tác dụng phụ vào biến ngoài là hỏng. Đoạn này chạy sai âm thầm:
List<String> kq = new ArrayList<>();
ds.parallelStream().forEach(kq::add); // ArrayList KHÔNG an toàn luồng
Kết quả có thể thiếu phần tử, hoặc ném ArrayIndexOutOfBoundsException ở một chỗ chẳng liên quan. Dùng collect — nó được thiết kế để an toàn khi song song.
Tất cả dùng chung một pool. Mặc định mọi parallelStream trong JVM chạy trên ForkJoinPool.commonPool(), có số luồng bằng số nhân trừ một. Nghĩa là một tác vụ song song chậm sẽ chặn mọi tác vụ song song khác trong cùng ứng dụng.
Đặc biệt nguy hiểm: đừng bao giờ đặt thao tác I/O — gọi API, truy vấn CSDL — trong parallelStream. Nó sẽ chiếm luồng của pool chung để ngồi chờ, và mọi thứ khác đứng theo. Cần song song cho I/O thì dùng ExecutorService riêng, hoặc virtual thread — chuyện của chặng đồng thời.
Muốn dùng pool riêng
Có một mẹo được dùng khá rộng rãi: chạy stream bên trong một ForkJoinPool khác:
ForkJoinPool poolRieng = new ForkJoinPool(4);
try {
poolRieng.submit(() -> ds.parallelStream().map(...).toList()).get();
} finally {
poolRieng.shutdown();
}
Nó hoạt động vì parallelStream dùng pool đang chạy nếu có. Nhưng đây là hành vi không được ghi trong tài liệu — nó đúng với bản cài đặt hiện tại, không phải một bảo đảm. Dùng thì nên ghi chú lại.
Đo, và đo đúng cách
Điều tôi muốn nhấn mạnh nhất: mọi con số trong bài này là của máy tôi, với dữ liệu của tôi.
16 nhân cho tăng tốc 7 lần ở phép cộng mảng — không phải 16, vì còn chi phí gộp và băng thông bộ nhớ. Máy 4 nhân sẽ ra con số khác hẳn. Máy ảo bị giới hạn CPU lại khác nữa, và đây là chỗ rất hay bị bỏ qua: trong container có --cpus=2, parallelStream vẫn tưởng mình có đủ nhân của máy chủ nếu JVM không đọc đúng giới hạn cgroup.
Nên quy trình đúng là: viết tuần tự trước, đo, thấy chậm thì thử song song, đo lại, và giữ bản nhanh hơn.
Thử ba mươi giây
Lấy một parallelStream() bất kỳ trong dự án của bạn, đếm xem nguồn của nó có bao nhiêu phần tử trong thực tế.
Nếu dưới một nghìn, gần như chắc chắn bản tuần tự nhanh hơn. Và nếu bên trong có gọi mạng hay truy vấn CSDL, bạn vừa tìm ra một chỗ đang chiếm pool chung của cả ứng dụng.
Ngày mai: Optional trong chuỗi Stream — findFirst, max, reduce đều trả về Optional, và cách nối chúng vào chuỗi mà không biến thành một câu if trá hình.