Lock: khi synchronized không còn đủ
Tiếp đi: ReentrantLock và ReadWriteLock.
Bài Deadlock đã dùng tryLock mà chưa giải thích. Bài này đi vào package java.util.concurrent.locks: có synchronized rồi, sao Java còn cần thêm Lock?
1. Bài toán
Vẫn wallet-service. Có một tác vụ chỉ chạy trong JVM: POST /wallets/{id}/statement tạo sao kê PDF, nặng vài giây. Yêu cầu:
- Cùng một ví thì không được tạo 2 sao kê song song (tốn CPU, file ghi đè nhau).
- Ví khác nhau thì chạy song song bình thường.
- Nếu ví đang bận thì trả
429ngay, không bắt client chờ.
2. Vấn đề: synchronized thiếu gì?
Nhắc lại: mỗi object có một monitor lock. Các thread tranh cùng một monitor mới phải chờ nhau, và thread đang chờ có trạng thái BLOCKED:
Thread
├── sleep(time) → TIMED_WAITING (không nhả lock đang giữ)
├── join() / wait() → WAITING (wait() thì NHẢ monitor)
└── chờ synchronized → BLOCKED
synchronized gọn và an toàn (tự nhả khóa kể cả khi có exception), nhưng với bài toán trên thì hụt:
- Không có timeout / tryLock: thread chờ khóa là chờ mãi, không "thử rồi trả 429" được. Bài trước thấy đây là nguồn deadlock.
- Không ngắt được: thread đang chờ monitor không phản ứng với
interrupt(). - Không có fairness: ai đến trước chưa chắc được trước.
- Chỉ một hàng chờ:
wait/notifydùng chung cho mọi điều kiện. - Đọc cũng phải xếp hàng: 100 thread chỉ đọc vẫn lần lượt từng cái.
3. Giải pháp: ReentrantLock
Cùng ngữ nghĩa với synchronized: độc quyền, reentrant (thread đang giữ khóa lấy lại được), đảm bảo visibility. Khác ở chỗ bạn tự lock()/unlock() và có thêm:
tryLock()/tryLock(timeout): thử lấy, không được thì làm việc khác.lockInterruptibly(): đang chờ mà bị interrupt (ví dụ shutdown) thì thoát ra.new ReentrantLock(true): fair lock, ai chờ lâu nhất được trước.newCondition(): nhiều hàng chờ riêng trên một lock.
4. Code từng bước
Bước 1: Mẫu cơ bản, unlock() bắt buộc trong finally:
private final ReentrantLock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // quên finally + có exception = khóa bị giữ vĩnh viễn
}
}
Bước 2: Một lock cho mỗi ví
@Component
public class WalletLocks {
private final ConcurrentHashMap<Long, ReentrantLock> locks = new ConcurrentHashMap<>();
public ReentrantLock of(Long walletId) {
return locks.computeIfAbsent(walletId, id -> new ReentrantLock());
}
}
(Map này lớn dần theo số ví. Production có thể dùng lock striping, ví dụ Guava Striped<Lock>, để giới hạn số lock.)
Bước 3: Service
@Service
public class StatementService {
private final WalletLocks walletLocks;
public StatementService(WalletLocks walletLocks) { this.walletLocks = walletLocks; }
public byte[] generate(Long walletId) throws InterruptedException {
ReentrantLock lock = walletLocks.of(walletId);
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Statement is being generated");
}
try {
return renderPdf(walletId); // vài giây
} finally {
lock.unlock();
}
}
}
Bước 4: Test bằng curl
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/wallets/1/statement &
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/wallets/1/statement &
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/wallets/2/statement &
wait
# 200 (ví 1, lần đầu)
# 429 (ví 1, đang bận)
# 200 (ví 2, chạy song song)
5. Cách hoạt động
WalletLocks (ConcurrentHashMap)
┌──────────┬───────────────────┐
│ wallet 1 │ ReentrantLock 🔒 │ ← exec-1 đang giữ
│ wallet 2 │ ReentrantLock 🔒 │ ← exec-3 đang giữ
└──────────┴───────────────────┘
exec-1 → tryLock(w1) OK → render PDF ... unlock
exec-2 → tryLock(w1, 200ms) hết giờ → 429
exec-3 → tryLock(w2) OK → render PDF song song với exec-1
Bên trong, ReentrantLock dựa trên AQS (AbstractQueuedSynchronizer): một biến state đếm số lần giữ (reentrant), thread không lấy được thì vào hàng chờ và bị park. Vì vậy trong thread dump, thread chờ ReentrantLock hiện là WAITING (parking), không phải BLOCKED như chờ monitor.
6. So sánh
synchronized | ReentrantLock | |
|---|---|---|
| Dạng | Từ khóa JVM | Class trong java.util.concurrent.locks |
| Nhả khóa | Tự động, kể cả exception | Tự unlock() trong finally |
| tryLock / timeout | Không | Có |
| Interrupt khi chờ | Không | lockInterruptibly() |
| Fair | Không | Tùy chọn |
| Điều kiện chờ | 1 (wait/notify) | Nhiều Condition |
| Trạng thái khi chờ | BLOCKED | WAITING |
7. Khi nào dùng cái gì
| Công cụ | Dùng khi | Lưu ý |
|---|---|---|
synchronized | Mặc định, đoạn code ngắn | Tự nhả khóa, không timeout |
ReentrantLock | Cần tryLock, timeout, interrupt, nhiều Condition | Phải unlock trong finally |
ReentrantReadWriteLock | Đọc nhiều, ghi ít | Ghi có thể bị đói nếu đọc liên tục |
StampedLock | Đọc cực nhiều, cần tối ưu | Không reentrant |
AtomicXxx, ConcurrentHashMap | Một biến / một map đơn giản | Không cần lock tự viết |
- Fair lock ít dùng vì throughput giảm rõ; chỉ bật khi thật sự có starvation.
- Java 21–23 với virtual thread: code trong
synchronizedbị block I/O sẽ "pin" carrier thread,ReentrantLockthì không. Từ Java 24 (JEP 491) đã sửa. - Mọi lock ở đây chỉ có tác dụng trong một JVM. Số dư ví vẫn phải khóa ở DB như bài Race condition. Lock trong JVM hợp với tài nguyên cục bộ như ví dụ sao kê.
Muốn thread chờ đến khi có dữ liệu thì làm sao với Lock?
Đúng chỗ để dùng Condition, phiên bản xịn của wait/notify. Một lock có thể có nhiều condition, mỗi cái một hàng chờ riêng. Ví dụ buffer chứa các job sao kê chờ xử lý:
class BoundedBuffer<T> {
private final Deque<T> items = new ArrayDeque<>();
private final int capacity = 100;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
void put(T x) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) notFull.await();
items.addLast(x);
notEmpty.signal(); // chỉ đánh thức bên lấy
} finally { lock.unlock(); }
}
T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) notEmpty.await();
T x = items.removeFirst();
notFull.signal(); // chỉ đánh thức bên bỏ vào
return x;
} finally { lock.unlock(); }
}
}
producer ──put──> [ buffer ] ──take──> consumer
↑ đầy: chờ notFull ↑ rỗng: chờ notEmpty
Luôn await() trong vòng while, không dùng if, vì thread có thể bị đánh thức giả (spurious wakeup) hoặc điều kiện đã bị thread khác đổi. Thực tế cứ dùng ArrayBlockingQueue có sẵn (bên trong chính là cơ chế này), nhưng hiểu nó để trả lời phỏng vấn.
GET /fees) được đọc hàng nghìn lần/giây, vài phút mới reload một lần. Khóa độc quyền thì phí.Đọc nhiều ghi ít thì khóa thế nào cho nhanh?
Bạn nghĩ đúng: khóa độc quyền cho thao tác đọc là lãng phí. Dùng ReadWriteLock: nhiều thread đọc cùng lúc được, ghi thì độc quyền.
@Component
public class FeeCache {
private final Map<String, BigDecimal> fees = new HashMap<>();
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
public BigDecimal get(String type) {
rw.readLock().lock();
try { return fees.get(type); } finally { rw.readLock().unlock(); }
}
public void reload(Map<String, BigDecimal> fresh) {
rw.writeLock().lock();
try { fees.clear(); fees.putAll(fresh); } finally { rw.writeLock().unlock(); }
}
}
reader 1 ─┐
reader 2 ─┼─ readLock (cùng lúc) writer ── writeLock (một mình, chờ reader xong)
reader 3 ─┘
Cần nhanh hơn nữa, Java 8 có StampedLock với optimistic read: đọc mà không khóa, xong kiểm tra xem có ai ghi chen vào không.
long stamp = sl.tryOptimisticRead();
double cx = x, cy = y;
if (!sl.validate(stamp)) { // có người ghi → đọc lại có khóa
stamp = sl.readLock();
try { cx = x; cy = y; } finally { sl.unlockRead(stamp); }
}
Lưu ý: StampedLock không reentrant và không hỗ trợ Condition, dùng sai rất dễ tự deadlock. Với cache kiểu bảng phí, cách đơn giản hơn nữa là giữ một volatile Map bất biến và thay cả map khi reload (copy-on-write), không cần lock.
Phỏng vấn hỏi về Lock thì trả lời sao?
Q1. synchronized và ReentrantLock khác nhau thế nào?
🇻🇳 Tiếng Việt:
Cả hai đều là khóa độc quyền, reentrant và đảm bảo visibility.
synchronizedlà từ khóa của JVM, tự nhả khóa khi ra khỏi block kể cả có exception, code gọn.ReentrantLocklà class, phải tựunlock()trongfinally, nhưng có thêmtryLockcó timeout để tránh deadlock,lockInterruptibly, fair lock và nhiềuCondition. Em dùngsynchronizedmặc định, chuyển sangReentrantLockkhi cần các tính năng đó.
🇬🇧 English:
Both are exclusive, reentrant locks that guarantee visibility.
synchronizedis a JVM keyword; it releases the lock automatically when leaving the block, even on exceptions, and the code is concise.ReentrantLockis a class; I must callunlock()in afinallyblock, but it addstryLockwith a timeout to avoid deadlocks,lockInterruptibly, fairness and multipleConditions. I usesynchronizedby default and switch toReentrantLockwhen I need those features.
Q2. ReadWriteLock dùng khi nào?
🇻🇳 Tiếng Việt:
Khi dữ liệu được đọc rất nhiều và ít khi ghi, ví dụ cache cấu hình. Nhiều thread có thể giữ read lock cùng lúc, còn write lock là độc quyền. Nhược điểm là writer có thể bị đói nếu reader liên tục. Nếu cần nhanh hơn có
StampedLockvới optimistic read, nhưng nó không reentrant.
🇬🇧 English:
When data is read very often and written rarely, such as a configuration cache. Many threads can hold the read lock at the same time, while the write lock is exclusive. The downside is that writers can starve if readers keep coming. For even better performance there is
StampedLockwith optimistic reads, but it is not reentrant.
Q3. Lock trong Java có đủ cho hệ thống nhiều instance không?
🇻🇳 Tiếng Việt:
Không. Mọi lock trong
java.util.concurrent.lockschỉ có tác dụng trong một JVM. Với dữ liệu dùng chung giữa nhiều instance như số dư ví, em khóa ở database bằng atomic update,SELECT FOR UPDATEhoặc optimistic lock, hoặc dùng distributed lock như Redis.
🇬🇧 English:
No. All locks in
java.util.concurrent.locksonly work inside one JVM. For data shared across instances, such as a wallet balance, I lock at the database level with atomic updates,SELECT FOR UPDATEor optimistic locking, or use a distributed lock such as Redis.
Cách nhớ ngắn gọn
synchronizedmặc định; cần tryLock / timeout / interrupt / Condition thìReentrantLock.lock()ngay trướctry,unlock()trongfinally.await()luôn trongwhile.- Đọc nhiều ghi ít: ReadWriteLock → StampedLock (không reentrant).
- Lock JVM chỉ cho 1 instance.
Tự kiểm tra
Vì sao unlock() phải nằm trong finally?
Nếu có exception mà không unlock, khóa bị giữ mãi và mọi thread sau đều treo.
Thread chờ ReentrantLock hiện trạng thái gì trong thread dump?
WAITING (parking qua AQS), không phải BLOCKED như khi chờ monitor của synchronized.
Vì sao await() phải nằm trong vòng while?
Thread có thể tỉnh dậy giả hoặc điều kiện đã bị thread khác thay đổi, nên phải kiểm tra lại.
Nhược điểm lớn của StampedLock?
Không reentrant và không có Condition; tự lấy lại khóa trong cùng thread là tự deadlock.
Khi nào nên bật fair lock?
Hiếm khi; chỉ khi có starvation thật, vì fair lock giảm throughput đáng kể.