Chiến lược và cơ chế nâng cấp khóa của synchronized
Trong Java, synchronized sử dụng các chiến lược khóa thích ứng để tối ưu hóa hiệu suất, bao gồm: khóa thiên vị (biased lock), khóa nhẹ (lightweight lock), khóa nặng (heavyweight lock), khóa tự quay (spin lock), khóa có thể vào lại (reentrant lock) và khóa không công bằng (unfair lock).
Quá trình nâng cấp khóa
Trạng thái của một đối tượng khi sử dụng synchronized sẽ trải qua quá trình nâng cấp单向 (không thể hạ cấp):
Vô khóa -> Khóa thiên vị -> Khóa nhẹ (Spin lock) -> Khóa nặng
1. Khóa thiên vị (Biased Lock)
Khóa thiên vị áp dụng cho kịch bản chỉ có một luồng truy cập vào tài nguyên được đồng bộ hóa. Thay vì thực sự áp đặt khóa, JVM chỉ đánh dấu một cờ nhẹ trong phần header của đối tượng (Object Header) để ghi nhận luồng này là "chủ sở hữu".
- Ưu điểm: Loại bỏ hoàn toàn chi phí đồng bộ hóa khi không có cạnh tranh, giúp luồng sở hữu truy cập tài nguyên với hiệu suất gần như không khóa.
- Nâng cấp: Khi có luồng thứ hai tham gia cạnh tranh, khóa thiên vị sẽ bị hủy và nâng cấp lên khóa nhẹ.
2. Khóa nhẹ (Lightweight Lock / Spin Lock)
Áp dụng khi có sự cạnh tranh khóa nhưng số lượng luồng tranh chấp không quá lớn và thời gian giữ khóa ngắn. Luồng không giành được khóa sẽ không bị chặn (block) ngay lập tức mà sẽ thực hiện vòng lặp tự quay (spin) để liên tục kiểm tra xem khóa đã được giải phóng chưa.
- Ưu điểm: Giảm thiểu chi phí chuyển đổi ngữ cảnh (context switch) giữa luồng và hệ điều hành.
- Nhược điểm: Tiêu tốn tài nguyên CPU do vòng lặp liên tục.
Nếu JVM phát hiện số lượng luồng cạnh tranh vượt quá một ngưỡng nhất định, hoặc thời gian tự quay quá lâu, khóa sẽ tiếp tục được nâng cấp lên khóa nặng.
3. Khóa nặng (Heavyweight Lock)
Khóa nặng dựa trên cơ chế chặn (blocking) của hệ điều hành. Luồng không giành được khóa sẽ bị đưa vào trạng thái chờ (WAITING/BLOCKED), nhường lại CPU cho các luồng khác. Khi luồng giữ khóa giải phóng, hệ điều hành sẽ đánh thức một luồng trong hàng đợi.
Các tối ưu hóa cấp trình biên dịch
- Loại bỏ khóa (Lock Elimination): Trình biên dịch JIT phân tích mã nguồn, nếu phát hiện biến được khóa chỉ tồn tại trong phạm vi cục bộ của một luồng (không bị chia sẻ), nó sẽ loại bỏ hoàn toàn mã khóa.
- Mở rộng phạm vi khóa (Lock Coarsening): Nếu nhiều khối mã liên tiếp đều sử dụng cùng một khóa đối tượng, trình biên dịch sẽ gộp chúng thành một khối mã lớn duy nhất để giảm chi phí liên tục acquiring/releasing khóa.
Cơ chế CAS (Compare And Swap) và vấn đề ABA
CAS (Compare And Swap) là một chỉ thị nguyên tử của CPU, được sử dụng để thực hiện các thao tác đồng bộ hóa mà không cần sử dụng khóa truyền thống (lock-free).
Nguyên lý hoạt động của CAS
CAS bao gồm 3 toán hạng: địa chỉ bộ nhớ (V), giá trị kỳ vọng (A) và giá trị mới (B). Nó sẽ cập nhật V thành B chỉ khi giá trị hiện tại của V bằng A.
boolean compareAndSwap(MemoryLocation memory, Object expectedVal, Object newVal) {
if (memory.getValue() == expectedVal) {
memory.setValue(newVal);
return true;
}
return false;
}
Ứng dụng của CAS
- Lớp nguyên tử (Atomic Classes): Các lớp như
AtomicIntegersử dụng CAS để thực hiện các thao tác tăng/giảm giá trị một cách an toàn luồng mà không cầnsynchronized. - Tự triển khai Spin Lock:
public class CustomSpinLock {
private volatile Thread currentHolder = null;
public void acquireLock() {
Thread callingThread = Thread.currentThread();
while (!compareAndSwap(currentHolder, null, callingThread)) {
// Liên tục tự quay cho đến khi giành được quyền sở hữu
}
}
public void releaseLock() {
currentHolder = null;
}
}
Vấn đề ABA và giải pháp
CAS chỉ kiểm tra giá trị bộ nhớ có thay đổi so với giá trị kỳ vọng hay không. Nếu giá trị thay đổi từ A -> B -> A, CAS sẽ thất bại trong việc phát hiện sự can thiệp của luồng khác (vấn đề ABA).
Giải pháp: Sử dụng phiên bản (versioning). Thay vì chỉ so sánh giá trị, hãy so sánh cả giá trị và số phiên bản. Mỗi lần cập nhật, số phiên bản sẽ tăng lên (A1 -> B2 -> A3), giúp CAS nhận diện được dữ liệu đã bị thay đổi.
Các công cụ điều phối và đồng bộ hóa luồng
Callable và FutureTask
Khác với Runnable không trả về kết quả, Callable cho phép luồng thực thi trả về một giá trị thông qua phương thức call(). Để sử dụng Callable với Thread, cần thông qua lớp trung gian FutureTask để lấy kết quả bất đồng bộ.
ReentrantLock
Là một khóa có thể vào lại (reentrant) thuộc gói java.util.concurrent.locks, cung cấp nhiều tính năng linh hoạt hơn synchronized:
tryLock(): Cố gắng giành khóa, nếu thất bại sẽ trả vềfalsengay lập tức thay vì chặn luồng.- Khóa công bằng (Fair Lock): Cấp phát khóa theo đúng thứ tự FIFO của hàng đợi chờ.
- Condition: Hỗ trợ chia nhỏ hàng đợi chờ (wait/notify) thành nhiều nhóm điều kiện khác nhau.
Semaphore (Đèn báo)
Quản lý số lượng tài nguyên có thể truy cập đồng thời. Sử dụng thao tác P (acquire) để giảm bộ đếm và V (release) để tăng bộ đếm. Khi bộ đếm về 0, các luồng yêu cầu tiếp theo sẽ bị chặn. Semaphore(1) hoạt động tương đương một khóa nhị phân.
CountDownLatch
Cho phép một hoặc nhiều luồng chờ cho đến khi một tập hợp các thao tác đang được thực hiện ở các luồng khác hoàn thành.
public class BatchTaskCoordinator {
public static void main(String[] args) throws InterruptedException {
int totalChunks = 10;
CountDownLatch latch = new CountDownLatch(totalChunks);
for (int i = 0; i < totalChunks; i++) {
final int chunkId = i;
Thread processor = new Thread(() -> {
try {
Thread.sleep((int) (Math.random() * 500));
System.out.println("Hoàn thành xử lý chunk: " + chunkId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
processor.start();
}
latch.await();
System.out.println("Tất cả các chunk dữ liệu đã được xử lý xong.");
}
}
Cấu trúc dữ liệu an toàn luồng trong Java
Các lớp Collection cũ
Vector, Stack, Hashtable đều an toàn luồng do sử dụng synchronized trên toàn bộ phương thức. Tuy nhiên, điều này gây ra hiệu suất cực kỳ thấp ngay cả trong môi trường đơn luồng. Thay vào đó, nên dùng Collections.synchronizedList(new ArrayList<>()) hoặc CopyOnWriteArrayList.
CopyOnWriteArrayList đảm bảo an toàn luồng bằng cách sao chép toàn bộ mảng ngầm định mỗi khi có thao tác ghi. Phù hợp với kịch bản đọc nhiều, ghi ít (ví dụ: danh sách cấu hình).
ConcurrentHashMap
Thay thế cho Hashtable (khóa toàn bộ bảng) với các tối ưu hóa vượt trội:
- Giảm kích thước khóa (Lock Stripping): Thay vì khóa toàn bộ map, nó khóa riêng biệt từng bucket (node đầu danh sách). Các luồng thao tác trên các bucket khác nhau sẽ không bị chặn lẫn nhau.
- Sử dụng CAS: Các thao tác như đếm số lượng phần tử (size) được tối ưu bằng CAS thay vì dùng khóa.
- Tối ưu hóa quá trình扩容 (Resize): Thay vì khóa toàn bộ map và di chuyển tất cả phần tử cùng lúc (gây tắc nghẽn),
ConcurrentHashMapsử dụng chiến lược "chia để trị". Các luồng sẽ hỗ trợ nhau di chuyển từng phần dữ liệu nhỏ từ mảng cũ sang mảng mới, đảm bảo thời gian phản hồi ổn định.