Các lớp phổ biến trong java.util.concurrent
Bài viết này trình bày về các lớp thường gặp trong gói java.util.concurrent, bao gồm giao diện Callable dùng để tạo luồng, khóa ReentrantLock, cơ chế Semaphore và ứng dụng của CountDownLatch trong việc phân chia tác vụ, đồng thời thảo luận vấn đề an toàn luồng trong các lớp tập hợp.
1. Giao diện Callable
Một phương pháp khác để tạo luồng thực thi:
- Runnable: Biểu diễn một nhiệm vụ thông qua phương thức
run(), không trả về giá trị - Callable: Biểu diễn một nhiệm vụ thông qua phương thức
call(), trả về giá trị cụ thể với kiểu được xác định qua tham số generics
Khi làm việc với đa luồng:
- Nếu chỉ quan tâm đến quá trình thực thi → sử dụng
Runnable(ví dụ: thread pool, bộ định thời) - Nếu quan tâm đến kết quả tính toán → sử dụng
Callable(ví dụ: cho một luồng thực hiện phép tính 1+2+3+...+1000)
Lưu ý: Không thể truyền trực tiếp đối tượng Callable vào constructor của Thread, cần bao bọc nó bằng FutureTask trước.
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> task = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int total = 0;
for(int i = 0; i <= 1000; i++) {
total += i;
}
return total;
}
};
FutureTask<Integer> wrapper = new FutureTask<>(task);
Thread worker = new Thread(wrapper);
worker.start();
Integer outcome = wrapper.get(); // Giống join(), chờ nếu chưa hoàn thành
System.out.println(outcome);
}
2. Khóa ReentrantLock
Khóa có thể nhập lại (re-entrant), ít được sử dụng hơn synchronized nhưng là lựa chọn thay thế cho việc khóa tài nguyên.
Quá trình khóa được chia thành hai phương thức:
lock()unlock()
Các tính năng vượt trội so với synchronized:
- Cung cấp phương thức
tryLock()lock(): Nếu không khóa được sẽ chờ đợi vô hạntryLock(): Nếu khóa thất bại sẽ trả về false hoặc có thể thiết lập thời gian chờ
- Hỗ trợ cả chế độ khóa công bằng và không công bằng
- Được xác định qua constructor
- Có cơ chế chờ báo hiệu thông qua lớp Condition
Nhược điểm rõ ràng: Dễ quên gọi unlock(), nên sử dụng finally để đảm bảo giải phóng khóa.
Sự khác biệt giữa synchronized và ReentrantLock:
- synchronized: Đối tượng khóa có thể là bất kỳ đối tượng nào
- ReentrantLock: Đối tượng khóa chính là bản thân instance
Nếu nhiều luồng gọi lock trên các instance ReentrantLock khác nhau, sẽ không xảy ra cạnh tranh khóa.
Trong phát triển thực tế, vẫn ưu tiên synchronized vì có nhiều tối ưu sẵn.
3. Semaphore (Tín hiệu)
Một khái niệm quan trọng trong lập trình đồng thời.
Đây là một bộ đếm mô tả số lượng tài nguyên khả dụng.
- P operation (acquire): Xin một tài nguyên khả dụng, giảm bộ đếm đi 1
- V operation (release): Trả lại một tài nguyên, tăng bộ đếm lên 1
Khi bộ đếm bằng 0, việc thực hiện P operation sẽ chờ đợi cho đến khi có luồng khác thực hiện V operation.
Thực chất, khóa là một trường hợp đặc biệt của tín hiệu (bộ đếm nhị phân: 0 hoặc 1).
public static void main(String[] args) throws InterruptedException {
Semaphore resourcePool = new Semaphore(4);
resourcePool.acquire();
System.out.println("P operation executed");
resourcePool.acquire();
System.out.println("P operation executed");
resourcePool.acquire();
System.out.println("P operation executed");
resourcePool.acquire();
System.out.println("P operation executed");
// Bộ đếm bằng 0, sẽ chờ đợi
resourcePool.acquire();
System.out.println("P operation executed");
}
4. CountDownLatch
Dành cho các kịch bản cụ thể.
Ví dụ: Trình tải file đa luồng chia nhỏ file lớn thành nhiều phần, mỗi luồng tải một phần riêng biệt để tăng tốc độ.
Giả sử dùng 10 luồng để tải:
Khi nào thì xem như tải xong?
→ Khi cả 10 luồng đều hoàn thành.
public static void main(String[] args) throws InterruptedException {
CountDownLatch barrier = new CountDownLatch(10);
for(int i = 0; i < 10; i++) {
final int workerId = i;
Thread worker = new Thread(() -> {
System.out.println("Worker " + workerId + " started");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Worker " + workerId + " finished");
// Tương tự như vận động viên cán đích
barrier.countDown();
});
worker.start();
}
// Luồng chính chờ tất cả các luồng hoàn thành
barrier.await();
System.out.println("All worker tasks completed");
}
Vấn đề an toàn luồng trong các lớp tập hợp
Các lớp nào là an toàn trong môi trường đa luồng?
- An toàn luồng: Vector, Hashtable, Stack
- Không an toàn luồng: Các lớp tập hợp còn lại
Lưu ý: Vector và Hashtable là các lớp từ thời kỳ đầu của Java.
Có khóa không chắc chắn an toàn, không khóa cũng không chắc chắn không an toàn - cần phân tích từng trường hợp cụ thể.
Sự khác biệt giữa HashMap, Hashtable và ConcurrentHashMap:
- HashMap: Không an toàn luồng
- Hashtable: An toàn luồng, các phương thức quan trọng đều được đánh dấu synchronized
- ConcurrentHashMap: Bảng băm an toàn luồng
Hashtable vs ConcurrentHashMap:
Hashtable:
Áp dụng synchronized trực tiếp lên phương thức → tương đương khóa trên this object.
→ Mọi thao tác trên cùng một instance đều phải khóa → cạnh tranh khóa gay gắt → mức độ đồng thời thấp.
Trong bảng băm có nhiều chuỗi (bucket), nếu hai thao tác sửa đổi ảnh hưởng đến hai chuỗi khác nhau thì không có vấn đề an toàn luồng.
Nhưng không thể hoàn toàn bỏ qua khóa, vì nếu hai luồng cùng chèn vào hai chuỗi khác nhau có thể gây ra vấn đề.
Giải pháp: Gán một khóa riêng cho mỗi chuỗi.
Vì số lượng chuỗi trong bảng băm khá nhiều, xác suất hai luồng cùng thao tác trên một chuỗi là thấp → giảm đáng kể chi phí khóa.
Do synchronized có thể khóa bất kỳ đối tượng nào, nên có thể sử dụng nút đầu tiên của mỗi chuỗi làm đối tượng khóa.
ConcurrentHashMap cải tiến:
- Giảm kích thước khóa: Mỗi chuỗi có một khóa riêng, hầu hết trường hợp không xảy ra cạnh tranh khóa
- Sử dụng rộng rãi CAS: Không tạo ra xung đột khóa
- Chỉ khóa khi ghi, đọc không khóa: Khi một luồng đọc và một luồng ghi, có thể đọc dữ liệu cũ hoặc mới nhất, nhưng đảm bảo không đọc "dữ liệu phân nửa"
- Tối ưu mở rộng: Mở rộng dần dần thay vì ngay lập tức
Hashtable: Khi mở rộng, di chuyển tất cả phần tử trong một lần → tốn thời gian, dễ gây đình trệ.
ConcurrentHashMap: Hóa đơn thành nhiều phần nhỏ, khi cần mở rộng tạo mảng mới, di chuyển dần từ mảng cũ sang mảng mới.
- Thêm phần tử: Chèn vào mảng mới
- Xóa phần tử: Xóa từ cả hai mảng
- Tìm kiếm: Tìm trong cả hai mảng
- Sửa phần tử: Sửa trên mảng mới
Mỗi thao tác kích hoạt một phần việc di chuyển, cuối cùng hủy mảng cũ.
Phân đoạn khóa (Segmented Locking):
Trước Java 8, ConcurrentHashMap sử dụng khóa phân đoạn - hiệu quả nhưng không tốt bằng khóa từng chuỗi, và mã nguồn phức tạp.
CopyOnWriteArrayList:
Viết khi sao chép (Copy-on-Write).
Nhiều luồng cùng sửa một biến → chắc chắn xảy ra vấn đề an toàn luồng.
Nhiều luồng sửa các biến khác nhau → có an toàn?
Nhiều luồng chỉ đọc → không có vấn đề an toàn luồng.
Khi có luồng sửa đổi → tạo bản sao riêng.
Nếu quá trình sửa chậm, các luồng khác vẫn đọc từ dữ liệu cũ.
Sau khi sửa xong, thay thế ArrayList cũ bằng ArrayList mới.
Không có khóa, sử dụng mô hình:
Tạo bản sao → Sửa bản sao → Thay thế bằng bản sao đã sửa