[Java Masterclass] Giải ngố Java Multithreading - Từ Memory Visibility đến tuyệt kĩ ThreadPool
Chào anh em, nếu ở các bài trước chúng ta đã bàn về Queue, Race Condition hay Idempotency ở cấp độ "Kiến trúc hệ thống" (System Architecture), thì hôm nay chúng ta sẽ "xuống xác" ở một tầng sâu hơn, đen tối hơn: Tầng phần cứng và OS (Operating System) thông qua Java Multithreading.
Rất nhiều anh em code Java (hoặc C#) khi nghe sếp bảo: "Cái hàm export Excel này chạy chậm quá, em cho nó chạy multi-thread đi" là nhắm mắt nhắm mũi gõ ngay new Thread(() -> export()).start();. Chạy thì có vẻ nhanh đấy, cho đến một ngày đẹp trời server báo CPU 100% hoặc văng OutOfMemoryError đỏ rực màn hình.
Hôm nay, mình sẽ dắt anh em đi từ những ảo giác lừa lọc của CPU Cache, cho đến cách quản lý luồng chuyên nghiệp bằng ThreadPool.
1. Cú lừa vĩ đại mang tên "Memory Visibility" (Tính nhìn thấy của bộ nhớ)
Trước khi code đa luồng, bạn phải hiểu cách CPU làm việc. Giả sử bạn có 1 server 2 nhân (Core 1 và Core 2). Bạn chạy đoạn code sau:
public class VisibilityProblem {
private static boolean isRunning = true;
public static void main(String[] args) throws InterruptedException {
// Thread 1: Chạy vòng lặp vô tận chừng nào isRunning còn là true
Thread backgroundWorker = new Thread(() -> {
while (isRunning) {
// Đang làm việc miệt mài...
}
System.out.println("Worker đã dừng!");
});
backgroundWorker.start();
Thread.sleep(1000); // Main thread nghỉ 1 giây
// Thread 2 (Main Thread): Ra lệnh dừng!
isRunning = false;
System.out.println("Đã set isRunning = false");
}
}
Sách giáo khoa bảo: Sau 1 giây, biến isRunning đổi thành false, vòng lặp while sẽ kết thúc và in ra "Worker đã dừng!".
Thực tế vả vào mặt: Nếu bạn chạy đoạn code này, rất có thể vòng lặp while sẽ chạy vô tận (Infinite Loop), chương trình không bao giờ dừng lại!
Tại sao lại có ma thuật này? Đó là do CPU Cache. RAM (Main Memory) thì quá chậm so với tốc độ của CPU, nên mỗi Core của CPU đều có một bộ nhớ đệm riêng siêu tốc (L1, L2 Cache).
Thread 1(chạy ở Core 1) copy biếnisRunning = truetừ RAM lên Cache của Core 1 để đọc cho nhanh.Main Thread(chạy ở Core 2) sửa biếnisRunning = false. Sửa ở đâu? Nó sửa ở Cache của Core 2, chưa kịp đẩy xuống RAM!- Thế là Core 1 cứ cắm đầu đọc cái biến
isRunningcũ mèm trong Cache của nó. Nó bị "mù" trước sự thay đổi của Core 2. Đó gọi là lỗi Memory Visibility.
2. Keyword volatile thần thánh - Kẻ phá vỡ Cache
Để chữa căn bệnh mù lòa này, Java cung cấp keyword volatile. Bạn chỉ cần sửa lại:
private static volatile boolean isRunning = true;
Chữ volatile (dễ bay hơi) này gửi một chỉ thị tàn nhẫn xuống CPU: "Ê Core 1 và Core 2, tuyệt đối KHÔNG ĐƯỢC CACHE cái biến này! Mỗi lần đọc là phải lội xuống tận RAM mà đọc. Mỗi lần ghi là phải ghi thẳng xuống RAM cho thằng khác còn thấy!"
Bùm! Lỗi được fix. Chương trình dừng đúng như dự kiến.
Cảnh báo cực mạnh:
volatileCHỈ giải quyết bài toán "nhìn thấy nhau" (Visibility). Nó KHÔNG giải quyết được bài toán tranh chấp dữ liệu (Race Condition). Bạn viếtvolatile int count = 0;rồi cho 10 luồng cùngcount++thì data vẫn nát như tương. Lúc đó phải dùngAtomicIntegerhoặc khối lệnhsynchronized(giống y chang tư duy dùng Atomic Lock trên Redis ở bài trước).
3. Tại sao new Thread().start() lại là một tội ác?
Hiểu xong RAM và CPU rồi, giờ bàn đến OS.
Mỗi lần bạn gọi new Thread(), Java phải xin hệ điều hành cấp phát một luồng thực thi cấp thấp (OS Thread).
- Tốn RAM: Mỗi Thread đẻ ra sẽ ngốn khoảng 1MB RAM cho Stack Memory. Bạn cho vòng lặp tạo 10,000 threads -> bay màu 10GB RAM. Server
OutOfMemorychết đứng. - Tốn CPU vì Context Switching: Nếu server bạn chỉ có 4 Cores, nhưng bạn lại tạo ra 1000 threads đang chạy. CPU sẽ phải liên tục "chạy nhảy" giữa 1000 thằng này (mỗi thằng cho chạy 1 mili-giây rồi đổi). Hành động "chuyển ngữ cảnh" (Context Switching) này cực kỳ tốn sức. Cuối cùng, CPU dành 80% thời gian để "chuyển luồng" chứ chả tính toán được cái tích sự gì.
4. Vị Cứu Tinh: ThreadPool (Executor Framework)
Để giải quyết thảm họa tạo luồng bừa bãi, các kĩ sư thiết kế ra mẫu hình ThreadPool (Hồ chứa luồng).
Giả sử bạn đang làm hệ thống soát vé tự động (AFC) cho ga Metro. Bạn không thể thấy có 1000 khách xếp hàng là lập tức đập tường xây thêm 1000 cái cổng xoay soát vé được (đây là hành động new Thread()).
Cách làm chuẩn mực là: Bạn chỉ xây cố định 10 cái cổng xoay (Core Pool Size). Dù có 100 hay 10,000 khách đến, họ đều phải ngoan ngoãn xếp hàng vào một hàng chờ (Blocking Queue). 10 cái cổng xoay đó cứ hoạt động liên tục, xử lý xong khách này thì gọi khách tiếp theo trong hàng chờ.
Trong Java, mô hình đó nằm ở Interface ExecutorService:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MetroStation {
public static void main(String[] args) {
// Tạo một hồ chứa đúng 10 luồng (10 cổng soát vé)
ExecutorService threadPool = Executors.newFixedThreadPool(10);
// Có 1000 hành khách quẹt thẻ
for (int i = 1; i <= 1000; i++) {
final int passengerId = i;
// Nhét task vào Queue, 10 luồng kia sẽ tự động chia nhau ra xử lý
threadPool.execute(() -> {
System.out.println("Đang xử lý vé cho khách: " + passengerId
+ " bởi " + Thread.currentThread().getName());
// Logic check vé, trừ tiền các kiểu...
});
}
// Đừng quên đóng cửa ga khi hết khách (không nhận thêm task mới)
threadPool.shutdown();
}
}
Lợi ích tuyệt đối của ThreadPool:
- Tái sử dụng (Reuse): Tạo Thread rất tốn kém. ThreadPool tạo sẵn 10 Threads và xài đi xài lại, giúp tăng tốc độ đáng kể.
- Giới hạn tài nguyên: Chặn đứng nguy cơ OutOfMemory vì số lượng luồng bị ghim ở một mức an toàn (VD: 10, 50, 100). Dữ liệu bị dồn vào Queue, nếu Queue đầy thì ta cấu hình cơ chế từ chối (Reject Policy) thay vì để sập cả server.
- Dễ quản lý: Tách bạch rõ ràng giữa "Task cần làm" (
Runnable) và "Người thực thi" (Thread).
Tổng kết & Gợi ý bài sau
Làm việc với Multithreading cũng giống như cho nhiều xe chạy cùng lúc ở ngã tư không có đèn đỏ vậy. Bạn vừa phải lo xe này đâm xe kia (Race Condition), vừa phải lo kẹt xe (Deadlock), lại vừa phải tối ưu tốc độ (ThreadPool).
Nhưng khoan đã, khi bạn cấu hình ThreadPool quá to hoặc chương trình sinh ra quá nhiều rác (Objects vô nghĩa) trong quá trình xử lý đa luồng, bộ nhớ Heap của Java sẽ bị nghẽn. Lúc này, một "công nhân vệ sinh" tàng hình sẽ xuất hiện và dọn dẹp, đôi khi nó làm hệ thống của bạn khựng lại cả vài giây.
Bạn có biết "công nhân" đó là ai không? 👉 Bài 4: Bóc trần Java Garbage Collection (GC) - Tại sao server đang chạy mượt tự nhiên bị "đứng hình"?
Cảm ơn anh em đã kiên nhẫn đọc đến đây. Kiến thức low-level hơi khoai nhưng nắm được rồi thì phỏng vấn hay debug đều tự tin ăn đứt đối thủ! Upvote và theo dõi series để không bỏ lỡ phần sau nhé!
All rights reserved