Đổ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.