1. Lớp ReentrantLock: khóa linh hoạt
Từ khóa synchronized rất phù hợp cho các trường hợp cơ bản: nhanh và đơn giản để bảo vệ một phương thức hoặc khối mã. Nhưng đôi khi ta muốn nhiều hơn:
- Điều khiển khóa một cách tường minh (ví dụ: thử chiếm khóa, nếu không được — không chờ).
- Phân tách quyền “đọc” và “ghi” đối với tài nguyên.
- Có thể ngắt việc chờ khóa.
- Chẩn đoán ai và khi nào đã chiếm hoặc nhả khóa.
Cho các nhiệm vụ như vậy, các lớp ReentrantLock và ReadWriteLock đã ra đời. Chúng mang lại nhiều quyền kiểm soát và khả năng hơn so với synchronized cổ điển.
Nó là gì?
ReentrantLock là một lớp hiện thực giao diện Lock. Nó hoạt động gần giống synchronized, nhưng có thêm nhiều “tiện ích”. Khác biệt chính — việc điều khiển khóa là tường minh: bạn tự gọi các phương thức lock() và unlock().
Điểm thú vị — từ reentrant nghĩa là một luồng có thể chiếm cùng một khóa nhiều lần liên tiếp mà không gây deadlock lẫn nhau. Điều này hữu ích nếu phương thức đệ quy gọi lại chính nó hoặc làm việc với một khóa dùng chung trong chuỗi lời gọi.
Cú pháp sử dụng
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int value = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Chiếm khóa
try {
value++;
} finally {
lock.unlock(); // Nhất định phải nhả khóa!
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
Lưu ý:
Gọi lock() và unlock() luôn phải đi kèm cấu trúc try...finally. Nếu quên gọi unlock(), không luồng nào khác có thể vào khối được bảo vệ — bạn sẽ có “kẹt xe vĩnh viễn”.
Khả năng của ReentrantLock
Thử chiếm khóa:
Có thể thử chiếm khóa mà không chờ vô hạn:
if (lock.tryLock()) {
try {
// Xử lý
} finally {
lock.unlock();
}
} else {
// Không lấy được khóa — làm việc khác
}
Chờ với timeout:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
// Đã lấy trong 100 ms
}
Kiểm tra khóa có đang bị chiếm không:
if (lock.isLocked()) { ... }
Chẩn đoán hàng đợi chờ, “tính công bằng” của khóa và các tiện ích khác.
3. Ví dụ: tăng bộ đếm với ReentrantLock
Hãy mở rộng ứng dụng console của chúng ta (ví dụ, mô phỏng xử lý đơn hàng từ các luồng khác nhau). So sánh cách làm việc với synchronized và với ReentrantLock.
Ví dụ với synchronized
public class OrderCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Ví dụ tương tự với ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Lợi ích là gì?
- Có thể thử chiếm khóa và không chờ vô hạn (tryLock()).
- Có thể hiện thực logic phức tạp: ví dụ, chiếm nhiều khóa theo một thứ tự xác định (hữu ích cho các cấu trúc dữ liệu phức tạp).
- Có thể “mở khóa” từ chỗ khác (nhưng phải cực kỳ cẩn trọng — luôn nhớ unlock()!).
4. ReadWriteLock: khóa cho đọc và ghi
Nó là gì?
ReadWriteLock không chỉ là khóa, mà là bộ phân phối quyền truy cập thông minh. Hiện thực chính của nó là ReentrantReadWriteLock, và nó chia khóa thành hai loại: cho đọc và cho ghi.
Khi các luồng chỉ đọc dữ liệu và không ai thay đổi gì, chúng có thể thoải mái làm việc cùng nhau — đọc không cản trở đọc. Nhưng ngay khi có ai đó sửa đổi, tất cả những người còn lại phải chờ: ghi chỉ cho phép một người và yêu cầu “yên tĩnh”.
Cách tiếp cận này đặc biệt hữu ích ở nơi có rất nhiều lần đọc nhưng ít khi ghi — ví dụ, danh mục sản phẩm mà người dùng liên tục xem nhưng chỉ thỉnh thoảng mới được cập nhật.
Cú pháp sử dụng
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ProductCatalog {
private final Map<String, String> products = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void addProduct(String id, String name) {
rwLock.writeLock().lock();
try {
products.put(id, name);
} finally {
rwLock.writeLock().unlock();
}
}
public String getProduct(String id) {
rwLock.readLock().lock();
try {
return products.get(id);
} finally {
rwLock.readLock().unlock();
}
}
}
Ví dụ sử dụng trong ứng dụng của chúng ta
Giả sử có cơ sở dữ liệu đơn hàng, mọi luồng đều đọc (ví dụ để tìm đơn), nhưng thỉnh thoảng lại có đơn mới đến (thao tác ghi).
import java.util.*;
import java.util.concurrent.locks.*;
public class OrderDatabase {
private final List<String> orders = new ArrayList<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Thêm đơn hàng (cần writeLock)
public void addOrder(String order) {
rwLock.writeLock().lock();
try {
orders.add(order);
} finally {
rwLock.writeLock().unlock();
}
}
// Lấy bản sao tất cả đơn hàng (có thể đọc song song)
public List<String> getOrders() {
rwLock.readLock().lock();
try {
// Trả về bản sao để tránh race condition
return new ArrayList<>(orders);
} finally {
rwLock.readLock().unlock();
}
}
}
Điều gì đang diễn ra?
- Chừng nào không ai ghi, cả nghìn luồng cũng có thể đồng thời đọc danh sách đơn hàng.
- Khi một luồng bắt đầu thêm đơn — các thao tác đọc bị chặn để tránh dữ liệu “không nhất quán”.
5. So sánh: dùng cái gì khi nào?
| Kịch bản | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| Đồng bộ đơn giản | ✔ | ✔ | ✖ (dư thừa) |
| Cần timeout/thử chiếm khóa | ✖ | ✔ | ✔ |
| Nhiều thao tác đọc, ít ghi | ✖ | ✖ | ✔ (tăng đáng kể) |
| Cần chẩn đoán/thống kê | ✖ | ✔ | ✔ |
| Khóa đệ quy | ✔ | ✔ (khả năng tái nhập) | ✔ |
Kết luận:
- Trường hợp đơn giản — dùng synchronized.
- Cần linh hoạt — ReentrantLock.
- Kịch bản “đọc nhiều, ghi ít” — ReadWriteLock.
6. Trực quan hóa: sơ đồ hoạt động của ReadWriteLock
flowchart LR
subgraph Đọc
T1[Luồng 1] -- Đọc --> Orders
T2[Luồng 2] -- Đọc --> Orders
T3[Luồng 3] -- Đọc --> Orders
end
subgraph Ghi
T4[Luồng 4] -- Ghi (addOrder) --> Orders
end
Orders[Danh sách đơn hàng]
style Orders fill:#f9f,stroke:#333,stroke-width:2px
Miễn là không có luồng nào ghi, mọi luồng đều có thể đọc đồng thời. Khi có thao tác ghi, các luồng còn lại sẽ chờ cho đến khi writeLock hoàn tất.
7. Đặc điểm triển khai và lưu ý
“Công bằng” (fairness)
Với ReentrantLock và ReentrantReadWriteLock có thể bật chế độ “công bằng” (fair mode): các luồng được phục vụ theo thứ tự hàng đợi, không phải “ai nhanh tay thì được”. Điều này ngăn chặn hiện tượng “đói tài nguyên” của luồng, nhưng có thể làm giảm hiệu năng.
Lock fairLock = new ReentrantLock(true); // true — chế độ công bằng
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);
Bẫy tiềm ẩn
- Quên unlock: Nếu không gọi unlock(), bạn sẽ có khóa vĩnh viễn. Luôn dùng try...finally.
- Ngoại lệ bên trong vùng khóa: Dù có ngoại lệ xảy ra trong khối mã, khóa vẫn phải được nhả!
- Lạm dụng ReadWriteLock: Với các collection nhỏ hoặc nếu hầu như luôn ghi — ReadWriteLock không mang lại nhiều lợi ích, mà còn làm mã phức tạp hơn.
8. Lỗi thường gặp
Lỗi số 1: quên gọi unlock()
Lỗi phổ biến và “khó chịu” nhất — quên gọi unlock() sau khi đã chiếm khóa. Kết quả — khóa vĩnh viễn, các luồng “treo”. Luôn dùng try...finally, kể cả khi bạn nghĩ rằng “ở đây chẳng thể hỏng được”.
Lỗi số 2: dùng ReadWriteLock nơi không cần thiết
Nếu hầu như không có đọc song song, còn ghi thì thường xuyên, ReadWriteLock chỉ làm mã phức tạp thêm và giảm hiệu năng. Chỉ dùng khi thực sự có nhiều độc giả đồng thời.
Lỗi số 3: chiếm nhiều khóa theo các thứ tự khác nhau
Nếu mã của bạn chiếm nhiều Lock (ví dụ cho nhiều đối tượng), hãy luôn làm điều đó theo cùng một thứ tự ở mọi luồng. Nếu không, bạn có thể gặp deadlock — các luồng chờ nhau vô hạn.
Lỗi số 4: thay synchronized bằng ReentrantLock “cho có”
Đừng thay tất cả synchronized bằng Lock một cách thiếu suy nghĩ — điều đó không phải lúc nào cũng tăng tốc chương trình và có thể làm mã kém dễ đọc hơn.
Lỗi số 5: quên về tính tái nhập (reentrancy)
Nếu cùng một luồng gọi lock() nhiều lần liên tiếp — điều này bình thường với ReentrantLock, nhưng đừng quên rằng unlock() cũng phải được gọi bấy nhiêu lần!
GO TO FULL VERSION