ReentrantLock — tryLock, timeout và fairness khi synchronized chưa đủ
ReentrantLock mở tryLock, timeout, lockInterruptibly và fairness mà synchronized không có — đổi lại bạn phải tự unlock trong finally ở mọi nhánh thoát.
TL;DR: synchronized nên là lựa chọn mặc định, nhưng nó không cho bạn nói "thử giành, bận thì thôi", "chờ tối đa 50ms", hay "đang chờ thì cho phép hủy". ReentrantLock mở đúng các khả năng đó — tryLock, timeout, lockInterruptibly, fairness — bằng cách biến khóa thành một object bình thường, mà vẫn giữ nguyên bảo đảm của synchronized: nhiều nhất một thread giữ khóa, và nhả khóa happens-before giành lại. Cái giá: JVM không tự nhả nữa, nên bạn phải unlock ở mọi nhánh thoát, trong finally. Quên một lần là treo cả hệ thống. Vì thế có đúng một khuôn mẫu được chấp nhận — lock() ngay trước try, unlock() trong finally — và chỉ chuyển sang explicit lock khi cần đích danh một khả năng intrinsic lock không có.
1. synchronized thiếu những gì?
CAS và các lớp Atomic* ở bài trước cho atomicity mà không cần khóa, nhưng chỉ trên đúng một biến. Khi một invariant trải trên nhiều biến, hoặc một compound action phải bao trọn vài thao tác như một khối, ta buộc quay về khóa — synchronized ở bài volatile & synchronized, nơi JVM tự giành tự nhả một intrinsic lock canh mọi truy cập vào state mutable.
Nó nên là lựa chọn mặc định, nhưng bộ giới hạn của nó cứng nhắc. Bạn không nói được "thử giành, bận thì thôi", cũng không nói được "chờ tối đa 50 mili giây rồi bỏ cuộc". Thread đang block chờ intrinsic lock không đáp ứng interrupt: như ở bài Thread API & vòng đời, cờ interrupt vẫn được set, nhưng thread đứng đó đến khi giành được khóa mới có cơ hội đọc cờ. Bạn không chọn được thứ tự công bằng giữa các thread xếp hàng. Và giành khóa ở method này rồi nhả ở method kia thì gần như không viết nổi bằng cú pháp khối lồng nhau.
Gói java.util.concurrent.locks mở ra đúng những khả năng đó — tryLock, timeout, lockInterruptibly, fairness, nhiều Condition trên một khóa — bằng cách biến khóa từ một construct của ngôn ngữ thành một object bình thường. ReentrantLock tái nhập đúng như intrinsic lock, và cho y hệt bảo đảm của synchronized: nhiều nhất một thread giữ được khóa, và nhả khóa happens-before giành lại chính khóa đó — lo cả atomicity lẫn visibility. Cái giá: JVM không tự nhả nữa, nên bạn phải unlock ở mọi nhánh thoát, kể cả khi có exception. Quên một lần là treo cả hệ thống. Vì vậy có đúng một khuôn mẫu được chấp nhận:
lock.lock(); // lock la mot Lock, vd new ReentrantLock()
try {
// ... thao tac tren shared state ...
} finally {
lock.unlock(); // luon nha, ke ca khi than try nem exception
}
Lệnh lock() phải nằm ngay trước khối try, không phải bên trong nó. Nếu một lệnh giữa lock() và try ném exception, hoặc lock() đứng làm dòng đầu trong try rồi ném khi đang giành, thì finally vẫn chạy unlock() lên một khóa thread này chưa hề giữ — ReentrantLock ném IllegalMonitorStateException, che mất exception gốc.
Thay chính khóa đó vào BookingService của capstone TicketFlow — bản v1 ở bài volatile & synchronized dùng Monitor Pattern trên một private lock object — cho ra bản tương đương, khóa nay tường minh.
private final ReentrantLock lock = new ReentrantLock(); // @GuardedBy: events + sold
public Booking book(String eventId, String userId) {
lock.lock();
try {
int current = sold.getOrDefault(eventId, 0);
if (current >= events.get(eventId).capacity()) throw new SoldOutException(eventId);
sold.put(eventId, current + 1); // compound action: check-then-act
return new Booking(eventId, userId, current + 1);
} finally { lock.unlock(); }
}
Bản này dài dòng và dễ sai hơn synchronized — thứ vốn gọn hơn, không bao giờ quên nhả, và hiện rõ hơn trong thread dump. Lý do duy nhất đáng đánh đổi: cần đích danh một khả năng intrinsic lock không có. Bốn khả năng đó, và cách chọn giữa chúng:

2. tryLock: thử giành mà không cam kết chờ
tryLock() cố giành khóa và trả về ngay lập tức: true nếu giành được, false nếu khóa đang bận. Nó không bao giờ block, nên biến một thao tác vốn có thể treo vô hạn thành thao tác lập trình xoay quanh được. Quyền quyết định "có chờ hay không" giờ nằm trong tay bạn, và một analogy quầy thanh toán trải ra đủ bốn cách giành khóa:
| Đời thường (quầy thanh toán) | Cách giành khóa |
|---|---|
| Xếp hàng và đứng đến lượt, dù bao lâu | lock() — block vô hạn, như synchronized |
| Liếc qua quầy, bận thì đi làm việc khác ngay | tryLock() — trả về false lập tức, không chờ |
| Chờ tối đa 5 phút, quá thì bỏ đi | tryLock(5, TimeUnit.MINUTES) — chờ có giới hạn |
| Đang xếp hàng, có điện thoại khẩn thì rời hàng ngay | lockInterruptibly() — đáp ứng interrupt trong lúc chờ |
synchronized chỉ có đúng dòng đầu của bảng. Ba dòng còn lại — không chờ, chờ có hạn, chờ nhưng hủy được — là ba lý do chính đáng nhất để chuyển sang ReentrantLock.
Khả năng này quan trọng nhất khi đối phó deadlock — hai thread giành hai khóa theo thứ tự ngược nhau, mỗi bên giữ một cái rồi chờ cái kia mãi mãi. Với synchronized không có lối thoát nào ngoài việc áp một thứ tự giành khóa toàn cục. Với tryLock, ta giành cơ hội rồi rút lui có trật tự nếu không gom đủ khóa:
boolean transferSeat(ReentrantLock from, ReentrantLock to) throws InterruptedException {
while (true) {
if (from.tryLock()) {
try {
if (to.tryLock()) {
try { /* ... chuyen cho, dang giu ca hai khoa ... */ return true; }
finally { to.unlock(); }
}
} finally { from.unlock(); } // khong gom du khoa -> nha cai da gianh, thu lai
}
Thread.sleep(ThreadLocalRandom.current().nextLong(1, 10)); // lui ngau nhien
}
}
Vì không thread nào giữ một khóa rồi block vô hạn chờ khóa thứ hai, vòng deadlock không thể hình thành. Đổi lại phải đề phòng livelock — các thread giành rồi nhả nhịp nhàng đến mức không ai tiến lên — bằng quãng lùi ngẫu nhiên giữa các lần thử.
3. Timeout, lockInterruptibly và fairness
Biến thể tryLock(long time, TimeUnit unit) đứng giữa lock() chờ mãi và tryLock() không chờ: nó chờ tối đa một khoảng rồi bỏ cuộc. Trong hệ thống có ràng buộc thời gian, đó là khác biệt quyết định — một request đặt vé có SLA 200 mili giây thì xếp hàng vô hạn sau khóa đang kẹt là vô nghĩa; thà thất bại nhanh và trả lỗi còn hơn để client treo.
public Booking bookWithDeadline(String eventId, String userId, long timeoutMs) throws InterruptedException {
if (!lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) {
throw new BookingBusyException(eventId); // qua han, khong giu khoa -> khong duoc unlock
}
try { return doBook(eventId, userId); }
finally { lock.unlock(); }
}
Để ý một điểm dễ sai: khi tryLock trả về false vì hết hạn, thread không giữ khóa, nên tuyệt đối không được unlock. Khối try/finally chỉ bao phần sau khi đã chắc chắn giành được.
Thread đang block trong synchronized không đáp ứng interrupt: gọi interrupt() lên nó chỉ đặt cờ — cơ chế cooperative cancellation ở bài Thread API & vòng đời — chứ không kéo nó ra khỏi cuộc chờ. Trong hệ thống cần hủy task gọn gàng (bấm Cancel, request timeout ở tầng trên, service shutdown), đó là lỗ hổng liveness. lockInterruptibly() giành khóa nhưng để ngỏ cho interrupt: bị interrupt lúc đang chờ, nó lập tức ném InterruptedException và thoát. Nhờ nó, worker xếp hàng sau một khóa kẹt vẫn bị kéo về để dọn dẹp, thay vì thành thread không bao giờ chết.
public Booking bookCancellable(String eventId, String userId) throws InterruptedException {
lock.lockInterruptibly(); // cho khoa, nhung chiu nghe interrupt
try { return doBook(eventId, userId); }
finally { lock.unlock(); }
}
Còn new ReentrantLock(true) tạo một fair lock: cấp quyền đúng thứ tự vào hàng đợi, ai chờ lâu nhất được phục vụ trước. Khóa mặc định là unfair, cho phép thread vừa tới chen ngang nếu đúng lúc khóa vừa nhả và hàng đợi chưa kịp được đánh thức; intrinsic lock cũng không công bằng và không cho bạn lựa chọn. Phản trực giác là unfair gần như luôn cho throughput cao hơn, vì chi phí đánh thức: thread ngủ ở đầu hàng đợi mất một quãng không nhỏ để scheduler dựng dậy, và trong quãng trống đó một thread đang chạy sẵn nếu được chen ngang sẽ giành khóa, làm xong việc ngắn rồi nhả trước cả khi thread kia tỉnh. Barging tận dụng đúng khe thời gian mà fairness bỏ phí. Chỉ trả giá fairness khi thời gian giữ khóa đủ dài để starvation thành vấn đề thật; với workload đặt vé nơi mỗi lần giữ khóa chỉ vài chục nano giây, unfair là lựa chọn đúng.
Còn
Condition— nhiều wait set độc lập trên một khóa, thay chowait/notify— là nửa sau của explicit lock, tách ra ở bài 11b.
4. Pitfall tổng hợp
❌ Nhầm 1: unlock ngoài finally. Exception bay ra giữa chừng là khóa bị giữ vĩnh viễn.
lock.lock();
doBook(eventId, userId); // nem exception -> khong bao gio unlock
lock.unlock();
✅ lock() ngay trước try, unlock() trong finally - khuôn mẫu duy nhất được chấp nhận.
Đoạn dưới định dùng timeout nhưng có một lỗi làm nó ném exception khi khóa đang bận. Trước khi đọc tiếp: lỗi nằm ở đâu, và unlock() sẽ ném ra exception gì khi tryLock hết hạn? Viết ra câu trả lời của bạn.
❌ Nhầm 2: unlock khi tryLock thất bại. Hết timeout, thread không giữ khóa - gọi unlock ném IllegalMonitorStateException.
lock.tryLock(50, TimeUnit.MILLISECONDS); // bo qua ket qua tra ve
try {
doBook(eventId, userId); // co the chay khi KHONG giu khoa
} finally {
lock.unlock(); // nem IllegalMonitorStateException neu tryLock fail
}
✅ Kiểm tra giá trị trả về; chỉ vào khối try/finally khi tryLock trả về true (xem bookWithDeadline ở §3).
❌ Nhầm 3: đổi sang ReentrantLock chỉ vì "nghe mạnh hơn". Không dùng khả năng nào của nó thì bạn chỉ nhận phần dễ sai (quên unlock, thread dump khó đọc) mà không nhận được gì.
✅ Ở lại với synchronized đến khi cần đích danh một khả năng: tryLock, timeout, interruptible, fairness, hoặc nhiều wait set.
5. 📚 Deep Dive Oracle
Spec / reference chính thức:
- Lock (Java 21 API) — hợp đồng memory synchronization: unlock happens-before lock kế tiếp, tương đương monitor của JLS §17.4.
- ReentrantLock (Java 21 API) — Javadoc khuyến nghị thẳng
lock()trướctry, và phân tích fairness/throughput. - Java Concurrency in Practice (Goetz et al.), chương 13 — phân tích đánh đổi
synchronizedvsReentrantLock.
Ghi chú: Javadoc của ReentrantLock nói thẳng vì sao unfair là mặc định — barging cho throughput cao hơn ngoại trừ khi hold time đủ dài để starvation thành vấn đề.
6. Liên hệ các bài khác
- Bài 02 — Thread API & vòng đời: interrupt và cooperative cancellation — nền để hiểu vì sao
lockInterruptiblylà nâng cấp về liveness. - Bài 08 — volatile & synchronized: Monitor Pattern và intrinsic lock mà
ReentrantLockthay thế tường minh; acquisition count ở đó chính làstatemà bài 12 mổ xẻ. - Bài 10 — Atomic & CAS: ranh giới "một biến" đẩy ta về khóa — và CAS quay lại ở bài 12 với vai trò viên gạch của AQS.
- Bài 11b — Condition: nửa sau của explicit lock — nhiều wait set độc lập trên cùng một khóa.
- Bài 12 — ReadWriteLock, StampedLock & AQS: tách read/write, optimistic read, và bộ khung AQS bên dưới mọi explicit lock.
7. Tóm tắt
synchronizedvẫn là mặc định; chỉ dùngReentrantLockkhi cần khả năng mà intrinsic lock không có (tryLock, interrupt, fair, Condition).- Khuôn mẫu bắt buộc:
lock()ngay trướctry,unlock()trongfinally— JVM không tự nhả explicit lock;unlock()khi không giữ khóa némIllegalMonitorStateException. tryLock()không chờ: chống deadlock vì không thread nào vừa giữ khóa vừa chờ vô hạn. Cần backoff ngẫu nhiên nếu tất cả fail cùng lúc — tránh livelock (các thread thử mãi cùng nhịp).tryLock(time, unit)chờ có giới hạn cho SLA; trảfalse= không giữ khóa, không được gọiunlock(). Fair lock chống starvation nhưng giảm throughput đáng kể.
8. Tự kiểm tra
- Q1Vì sao lock() phải đặt ngay trước khối try, thay vì làm dòng đầu tiên bên trong try?
- Q2Vì sao unlock() bắt buộc nằm trong finally? Chuyện gì xảy ra nếu quên?
- Q3tryLock giúp phá vòng deadlock bằng cách nào, và đổi lại phải đề phòng rủi ro gì?
- Q4Fair lock đánh đổi gì để chống starvation? Vì sao unfair lock thường cho throughput cao hơn?
- Q5Khi nào nên ở lại với synchronized thay vì chuyển sang ReentrantLock?
Bài tiếp theo: Condition — nhiều wait set trên một khóa
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên