1. Đi sâu vào Deadlock
Deadlock (“khóa chéo”) — là tình huống khi hai hoặc nhiều luồng chờ nhau vô hạn: mỗi luồng giữ một tài nguyên và cố lấy tài nguyên khác vốn đang bị luồng bên cạnh giữ. Kết quả là không ai có thể tiếp tục làm việc và chương trình “treo”. Nó giống như hai chiếc xe trên một cây cầu hẹp đối đầu nhau: chừng nào không có chiếc nào lùi lại — thì không ai đi qua được.
Trong Java, deadlock không phải là ngoại lệ (Exception), mà là trạng thái “treo” của chương trình. Các luồng không kết thúc và không sập với lỗi, chúng chỉ chờ nhau vô hạn. Vì vậy lỗi kiểu này rất khó chịu: chúng chỉ xuất hiện “trong những điều kiện nhất định”.
Bốn điều kiện dẫn tới deadlock
- 1. Loại trừ lẫn nhau (Mutual Exclusion)
Mỗi thời điểm chỉ một luồng có thể chiếm tài nguyên (ví dụ: object monitor, tệp). - 2. Giữ và chờ (Hold and Wait)
Luồng giữ một tài nguyên và cố lấy tài nguyên thứ hai mà không nhả tài nguyên đầu. - 3. Không thể thu hồi (No Preemption)
Không thể “giật” tài nguyên từ luồng: chỉ chính luồng đó mới có thể giải phóng. - 4. Chờ vòng tròn (Circular Wait)
Có một chuỗi khép kín, nơi mỗi luồng chờ tài nguyên do luồng tiếp theo trong chuỗi giữ.
Deadlock chỉ xảy ra khi TẤT CẢ các điều kiện này đồng thời đúng. Chỉ cần phá vỡ một điều kiện — khóa chéo sẽ không thể xảy ra.
2. Ví dụ mã có deadlock
Sơ đồ
- Có hai tài nguyên: lock1 và lock2 (các đối tượng thông thường).
- Luồng A trước tiên chiếm lock1, sau đó cố lấy lock2.
- Luồng B trước tiên chiếm lock2, sau đó cố lấy lock1.
Ví dụ (đừng làm thế trong dự án của bạn!)
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// Luồng 1
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Luồng 1: đã chiếm lock1");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("Luồng 1: đang cố chiếm lock2");
synchronized (lock2) {
System.out.println("Luồng 1: đã chiếm lock2");
}
}
});
// Luồng 2
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Luồng 2: đã chiếm lock2");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("Luồng 2: đang cố chiếm lock1");
synchronized (lock1) {
System.out.println("Luồng 2: đã chiếm lock1");
}
}
});
thread1.start();
thread2.start();
}
}
Điều gì xảy ra khi chạy?
- Luồng 1 chiếm lock1, luồng 2 — lock2.
- Cả hai “ngủ” 100 mili giây, kịp chiếm khóa đầu tiên.
- Sau đó mỗi luồng cố chiếm khóa thứ hai, khóa này đang do luồng kia giữ.
- Cả hai luồng chờ nhau vô hạn — dẫn tới deadlock.
Trong console bạn sẽ thấy:
Luồng 1: đã chiếm lock1
Luồng 2: đã chiếm lock2
Luồng 1: đang cố chiếm lock2
Luồng 2: đang cố chiếm lock1
Vì sao xảy ra deadlock? Phân tích chi tiết
- Loại trừ lẫn nhau: mỗi khóa chỉ có thể được một luồng giữ tại một thời điểm.
- Giữ và chờ: Luồng 1 giữ lock1 và chờ lock2; Luồng 2 giữ lock2 và chờ lock1.
- Không thể thu hồi: không ai có thể “giật” khóa từ bên ngoài — chỉ khi thoát khỏi khối synchronized.
- Chờ vòng tròn: việc chờ đợi khép kín theo vòng giữa hai luồng.
3. Cách tránh và phòng ngừa deadlock
Chiếm tài nguyên theo cùng một thứ tự
Quy tắc quan trọng nhất: nếu nhiều luồng cần chiếm nhiều tài nguyên — hãy luôn làm điều đó theo cùng một thứ tự. Ví dụ: luôn lock1 rồi lock2 — cho tất cả các nơi trong mã.
public void doSomething() {
Object firstLock = lock1;
Object secondLock = lock2; // thứ tự thống nhất "lock1 -> lock2"
synchronized (firstLock) {
synchronized (secondLock) {
// Làm việc với cả hai tài nguyên
}
}
}
Với thứ tự thống nhất sẽ không thể có chờ vòng tròn — điều kiện dẫn tới deadlock bị phá vỡ.
Dùng tryLock với timeout (ReentrantLock)
Khi không biết trước sẽ cần những tài nguyên nào, hãy dùng ReentrantLock và phương thức tryLock để không chờ vô hạn.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public void doWork() {
try {
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// Vùng tới hạn
} finally {
lock2.unlock();
}
} else {
System.out.println("Không thể chiếm lock2, sẽ rollback");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("Không thể chiếm lock1, sẽ rollback");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Ưu điểm: có thể hủy thao tác và thử lại sau. Nhược điểm: mã phức tạp hơn, nhưng loại trừ deadlock.
Giảm tối đa thời gian giữ khóa
Giữ khóa càng ngắn càng tốt. Chỉ làm phần cần thiết bên trong synchronized/lock; mọi thứ khác đưa ra ngoài vùng tới hạn.
Tránh khóa lồng nhau
Càng ít synchronized/lock lồng nhau thì nguy cơ deadlock càng thấp. Nếu có thể — dùng một khóa cho các tài nguyên liên quan.
Sử dụng các cấu trúc thread-safe sẵn có
Thư viện chuẩn đã giải quyết nhiều bài toán: các collection như ConcurrentHashMap và các lớp khác trong java.util.concurrent giúp giảm rủi ro deadlock và đơn giản hóa mã.
4. Chẩn đoán deadlock
Thread Dump và jstack
Thread Dump — ảnh chụp trạng thái của tất cả các luồng trong JVM.
Lấy từ console:
jstack <pid>
Trong đó <pid> — là định danh tiến trình Java (có thể biết qua jps).
Trong IDE thường có nút “Thread Dump” trên bảng gỡ lỗi.
Trong dump hãy tìm:
- Trạng thái BLOCKED hoặc WAITING.
- Các thông điệp dạng waiting to lock ... và locked ....
- Cụm từ của JVM: "Found one Java-level deadlock:" — khi phát hiện khóa chéo.
Ví dụ một đoạn dump:
"Thread-1":
waiting to lock monitor 0x000000001e4000, (object 0x7f8a5c00, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e3000, (object 0x7f8a5c10, a java.lang.Object),
which is held by "Thread-1"
Đó chính là deadlock: các luồng “giữ” lẫn nhau.
VisualVM và các công cụ khác
- VisualVM — công cụ miễn phí để phân tích luồng và phát hiện deadlock.
- Java Mission Control / Flight Recorder — giám sát và profiling nâng cao.
Trong VisualVM bạn có thể xem cây luồng, trạng thái của chúng và nhận thông báo về khóa chéo.
Bảng: “Làm sao để không rơi vào deadlock”
| Nguyên nhân deadlock | Cách tránh |
|---|---|
| Chiếm tài nguyên theo các thứ tự khác nhau | Luôn tuân thủ một thứ tự chiếm tài nguyên thống nhất |
| synchronized lồng nhau | Giảm tối đa mức lồng nhau, nếu có thể dùng một khóa |
| Giữ khóa quá lâu | Giảm công việc bên trong vùng tới hạn |
| Sử dụng nhiều khóa cùng lúc | Dùng tryLock với timeout và có khả năng rollback |
| Collection không thread-safe | Sử dụng collection concurrent/thread-safe (ConcurrentHashMap và v.v.) |
5. Các lỗi thường gặp khi làm việc với deadlock
Lỗi số 1: Chiếm tài nguyên theo các thứ tự khác nhau. Nguyên nhân phổ biến nhất — các luồng lấy khóa theo các thứ tự khác nhau. Dù “có vẻ nhanh hơn”, hãy tuân thủ một thứ tự thống nhất — nếu không, chờ vòng tròn là điều chắc chắn.
Lỗi số 2: synchronized lồng nhau mà không cần thiết. Lồng nhau dư thừa làm tăng rủi ro khóa chéo và làm mã phức tạp hơn. Hãy đơn giản hóa mô hình khóa.
Lỗi số 3: Bỏ qua tryLock và timeout. Khóa bằng synchronized sẽ khiến luồng chờ vô hạn. Nếu không chắc chắn, hãy dùng tryLock với timeout và logic rollback.
Lỗi số 4: Thực thi mã quá lâu bên trong khóa. Yêu cầu mạng, I/O và tính toán nặng trong khi giữ khóa làm tăng mạnh khả năng gặp vấn đề. Đưa chúng ra ngoài vùng tới hạn.
Lỗi số 5: Không phân tích Thread Dump. Thread Dump là trợ thủ tốt nhất khi tìm khóa chéo. Hãy dùng jstack và phân tích trạng thái BLOCKED/WAITING, các chuỗi “holding/waiting to lock”.
GO TO FULL VERSION