1. Không phải mọi thứ có thể song song hóa đều nên song song hóa
Trong Java có nhiều cách để song song hóa tác vụ. Nhưng “song song = luôn nhanh hơn” giống như nghĩ rằng nếu thêm muối vào súp thì nó sẽ ngon hơn: đến một mức nào đó — đúng, còn sau đó — tốt nhất đừng thử.
ExecutorService rất phù hợp khi bạn có các tác vụ rõ ràng cần khởi chạy và kiểm soát: xử lý yêu cầu, tải dữ liệu bất đồng bộ, tính toán độc lập. Bạn tự quyết định có bao nhiêu luồng trong pool và quản lý vòng đời của tác vụ.
parallelStream — cách nhanh để song song hóa xử lý collection khi các thao tác độc lập và không có tác dụng phụ. Có lợi cho các collection “nặng” (hàng chục nghìn phần tử trở lên).
ForkJoinPool — lựa chọn cho các bài toán dễ chia thành các tiểu nhiệm vụ (divide & conquer): sắp xếp, tìm kiếm, tổng hợp mảng lớn. Được dùng bên trong parallelStream, nhưng bạn cũng có thể điều khiển trực tiếp.
Đừng dùng song song “cho có”. Nếu tác vụ nhỏ, chi phí lập lịch, chuyển ngữ cảnh và đồng bộ có thể ăn hết lợi ích.
Ví dụ: khi không cần song song
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // Song song chỉ vì 5 số — quá đà!
2. Thread-safety: tránh dữ liệu chung có thể thay đổi
Trong thế giới song song, mối đe dọa chính là race conditions (tranh chấp dữ liệu). Nếu nhiều luồng thay đổi cùng một biến, kết quả có thể không như mong đợi.
- Tránh các biến chung có thể thay đổi. Ngay cả biểu thức như counter++ cũng không nguyên tử.
- Dùng collection thread-safe và các phép toán nguyên tử. Ví dụ, ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
- Không có tác dụng phụ trong stream song song. Đừng thay đổi cấu trúc bên ngoài từ parallelStream.
Ví dụ mã xấu
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // NGUY HIỂM!
Ở đây result.add() không thread-safe. Hậu quả — mất phần tử hoặc phát sinh ngoại lệ.
Cách đúng
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. Hiệu năng: nhiều luồng hơn không phải lúc nào cũng tốt hơn
Tác vụ nhỏ không đáng để song song hóa. Nếu công việc chỉ mất vài mili giây, chạy song song thường chỉ làm chậm do chi phí phụ.
Hãy đo hiệu năng. Với đo nhanh, dùng System.nanoTime():
long start = System.nanoTime();
// ... mã của bạn ...
long end = System.nanoTime();
System.out.println("Thời gian thực thi: " + (end - start) + " ns");
Với microbenchmark nghiêm túc, hãy dùng JMH (Java Microbenchmark Harness).
Ví dụ: so sánh stream tuần tự và song song
List<Integer> bigList = IntStream.range(0, 1_000_000)
.boxed().collect(Collectors.toList());
long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();
System.out.println("Tuần tự: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("Song song: " + (t3 - t2) / 1_000_000 + " ms");
Hãy thử trên máy của bạn — lợi ích chỉ rõ rệt với các collection thực sự lớn và thao tác nặng.
4. Xử lý lỗi: đừng bỏ qua ngoại lệ trong các luồng
Future và xử lý ngoại lệ
Nếu bạn chạy tác vụ qua ExecutorService.submit(), ngoại lệ sẽ không tự “bật” lên — bạn cần xử lý qua Future.get():
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Oops!");
return 42;
});
try {
Integer result = future.get(); // có thể ném ExecutionException
} catch (ExecutionException e) {
System.err.println("Lỗi trong tác vụ: " + e.getCause());
}
ForkJoin và xử lý ngoại lệ
Trong ForkJoinPool, ngoại lệ được “đóng gói” trong tác vụ. Khi gọi join()/get() chúng sẽ nổi lên:
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("Lỗi trong ForkJoin: " + e);
}
Đừng quên xử lý InterruptedException
Nhiều phương thức (ví dụ, Future.get(), Thread.sleep()) có thể ném InterruptedException. Đừng “nuốt” nó — hãy phản ứng đúng: đặt lại cờ ngắt hoặc kết thúc tác vụ.
5. Gỡ lỗi và kiểm thử mã song song
Ghi log và debug
Lỗi song song rất xảo quyệt và thường xuất hiện không ổn định. Hãy ghi log kèm tên luồng: Thread.currentThread().getName(). Điều này giúp hiểu ai và khi nào thực thi mã.
Trong trường hợp phức tạp, hãy dùng debugger có hỗ trợ đa luồng (ví dụ, IntelliJ IDEA). Thread.sleep() tạm thời đôi khi giúp “bắt” những race condition hiếm gặp.
Kiểm thử các kịch bản đa luồng
Tách riêng các bài kiểm thử cho thao tác song song và dùng tiện ích chờ điều kiện, ví dụ Awaitility. Hãy chạy các kiểm thử này nhiều lần: một số vấn đề chỉ lộ ra ở lần chạy thứ 100 hoặc 1000.
6. Những lưu ý và mẹo hữu ích
Khả năng đọc và bảo trì: hãy viết mã song song dễ hiểu
- Hãy tài liệu hóa. Bình luận những đoạn phức tạp và lý do chọn công cụ.
- Dùng các trừu tượng mức cao. Ưu tiên ExecutorService, parallelStream, ForkJoinPool thay vì tự quản lý luồng thủ công.
- Tránh “ma thuật”. Đừng làm phức tạp hóa việc đồng bộ nếu có thể làm đơn giản hơn.
Bảng: khi nào dùng công cụ nào
| Kịch bản | Công cụ khuyến nghị |
|---|---|
| Nhiều tác vụ độc lập | |
| Xử lý bộ sưu tập lớn | parallelStream hoặc ForkJoin |
| Bài toán “chia để trị” | |
| Tác vụ bất đồng bộ đơn giản | |
| Nhiều tác vụ nhỏ | Stream tuần tự |
| Tác vụ có tác dụng phụ | Chỉ dùng collection thread-safe! |
“Điều răn” của lập trình viên song song
- Đừng dùng biến chung có thể thay đổi — trừ khi chắc chắn chúng thread-safe.
- Đừng song song hóa chỉ vì song song hóa: hãy ước lượng lợi ích tiềm năng.
- Đừng quên đóng pool: shutdown()/shutdownNow().
- Đừng sử dụng parallelStream cho các thao tác có tác dụng phụ.
- Đừng quên xử lý ngoại lệ từ Future và ForkJoinTask.
- Đừng “nuốt” InterruptedException — hãy kết thúc tác vụ một cách đúng đắn.
7. Các lỗi điển hình khi lập trình song song
Lỗi №1: Song song hóa các tác vụ nhỏ. Người mới thường song song hóa mọi thứ, ngay cả khi công việc chỉ mất micro giây. Kết quả — chậm hơn do chi phí phụ.
Lỗi №2: Tác dụng phụ trong stream. Trong parallelStream không được thay đổi biến hay collection bên ngoài — bạn sẽ gặp race condition và bug khó lường.
Lỗi №3: Bỏ qua ngoại lệ. Nếu không xử lý lỗi từ Future.get() hoặc ForkJoinTask, bạn sẽ không biết vì sao tác vụ không hoàn thành.
Lỗi №4: Quên shutdown() của ExecutorService. Nếu không kết thúc rõ ràng, ứng dụng có thể “treo” khi thoát.
Lỗi №5: Dùng collection không thread-safe. Ghi từ nhiều luồng vào ArrayList thường — con đường thẳng tới lỗi.
Lỗi №6: “Nuốt” InterruptedException. Nếu luồng bị ngắt — hãy tôn trọng điều đó và kết thúc công việc một cách đúng đắn.
Lỗi №7: Logic đồng bộ quá phức tạp. Các khối synchronized dư thừa dẫn đến deadlock/livelock. Ưu tiên các trừu tượng mức cao.
GO TO FULL VERSION