Lock: khi synchronized không còn đủ

4 câu hỏi~25 phútTừ khóa: monitor, ReentrantLock, tryLock, lockInterruptibly, fair lock, Condition, ReadWriteLock, StampedLock

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:

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:

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:

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

synchronizedReentrantLock
DạngTừ khóa JVMClass trong java.util.concurrent.locks
Nhả khóaTự động, kể cả exceptionTự unlock() trong finally
tryLock / timeoutKhôngCó
Interrupt khi chờKhônglockInterruptibly()
FairKhôngTùy chọn
Điều kiện chờ1 (wait/notify)Nhiều Condition
Trạng thái khi chờBLOCKEDWAITING

7. Khi nào dùng cái gì

Công cụDùng khiLưu ý
synchronizedMặc định, đoạn code ngắnTự nhả khóa, không timeout
ReentrantLockCần tryLock, timeout, interrupt, nhiều ConditionPhải unlock trong finally
ReentrantReadWriteLockĐọc nhiều, ghi ítGhi có thể bị đói nếu đọc liên tục
StampedLockĐọc cực nhiều, cần tối ưuKhông reentrant
AtomicXxx, ConcurrentHashMapMột biến / một map đơn giảnKhông cần lock tự viết

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.

Đọ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. synchronized là từ khóa của JVM, tự nhả khóa khi ra khỏi block kể cả có exception, code gọn. ReentrantLock là class, phải tự unlock() trong finally, nhưng có thêm tryLock có timeout để tránh deadlock, lockInterruptibly, fair lock và nhiều Condition. Em dùng synchronized mặc định, chuyển sang ReentrantLock khi cần các tính năng đó.

🇬🇧 English:

Both are exclusive, reentrant locks that guarantee visibility. synchronized is a JVM keyword; it releases the lock automatically when leaving the block, even on exceptions, and the code is concise. ReentrantLock is a class; I must call unlock() in a finally block, but it adds tryLock with a timeout to avoid deadlocks, lockInterruptibly, fairness and multiple Conditions. I use synchronized by default and switch to ReentrantLock when 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ó StampedLock vớ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 StampedLock with 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.locks chỉ 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 UPDATE hoặc optimistic lock, hoặc dùng distributed lock như Redis.

🇬🇧 English:

No. All locks in java.util.concurrent.locks only 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 UPDATE or optimistic locking, or use a distributed lock such as Redis.

Cách nhớ ngắn gọn


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ể.