Java Internals & Concurrency/ReentrantLock & Condition — khi synchronized là chưa đủ
9/39
Bài 9 / 39~13 phútConcurrency cơ bảnMiễn phí lượt xem

ReentrantLock & Condition — khi synchronized là chưa đủ

ReentrantLock mở ra tryLock, timeout, lockInterruptibly và fairness; Condition cho nhiều wait set độc lập trên một khóa. Đổi lại, JVM không tự nhả khóa cho bạn nữa — unlock phải nằm trong finally, sai một lần là treo hệ thống.

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. Condition thay wait/notify với điểm mạnh quyết định: một khóa tạo được nhiều wait set độc lập, đánh thức đúng nhóm thread đang chờ đúng điều kiện. Cái giá: bạn tự unlock, đúng chỗ, trong finally, mọi nhánh thoát — và await luôn phải nằm trong vòng while vì spurious wakeup. Quên một trong hai kỷ luật đó là bug treo hệ thống.

1. Giới thiệu

Bài trước khép lại ở một ranh giới rõ ràng. CAS và các lớp Atomic* cho ta atomicity mà không cần khóa, nhẹ và ít contention, nhưng chỉ trên đúng một biến. Một khi nhiều biến cùng tham gia một invariant, hoặc một compound action phải bao trọn vài thao tác như một khối, atomic riêng lẻ bó tay và ta buộc phải quay về khóa. Cánh cửa đó dẫn thẳng đến synchronized, cơ chế khóa ta đã đào sâu ở bài volatile & synchronized: gói state mutable vào trong, canh mọi truy cập bằng một intrinsic lock, và để JVM tự giành tự nhả.

synchronized đủ tốt cho phần lớn trường hợp, và nên là lựa chọn mặc định. Nhưng nó có một bộ giới hạn cứng nhắc mà nhiều năm production sẽ làm bạn va phải. Khi giành một intrinsic lock, bạn không có cách nào nói "thử giành, nếu đang bận thì thôi". Bạn không có cách nào nói "chờ tối đa 50 mili giây rồi bỏ cuộc". Một thread đang block chờ intrinsic lock thì không đáp ứng interrupt: như ta đã thấy ở bài Thread API & vòng đời, cờ interrupt vẫn được set, nhưng thread không thoát khỏi cuộc chờ — nó cứ đứng đó đến khi giành được khóa rồi 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 đang xếp hàng. Và một thread phải giành khóa ở method này rồi nhả ở method kia thì gần như không viết nổi với cú pháp khối lồng nhau của synchronized.

Luận điểm trung tâm của bài có thể nói thẳng. Gói java.util.concurrent.locks mở ra đúng những khả năng đó - tryLock, timeout, interruptible, fairness, nhiều Condition trên một khóa - bằng cách đưa khóa từ một construct của ngôn ngữ thành một object bình thường bạn cầm trong tay. Cái giá phải trả đi kèm cũng thẳng thắn không kém: JVM không còn tự nhả khóa cho bạn nữa. Bạn phải tự unlock, đúng chỗ, trong mọi nhánh thoát, kể cả khi có exception. Quên một lần là treo cả hệ thống.

Ta sẽ tiếp tục với BookingService của capstone TicketFlow. Ở bài volatile & synchronized, v1 của nó dùng Monitor Pattern khóa trên một private lock object để bảo vệ compound action của book. Thử thay chính khóa đó bằng ReentrantLock là cách trực tiếp nhất để thấy mỗi khả năng mới mở ra điều gì mà synchronized không cho.

2. ReentrantLock

ReentrantLock là explicit lock cơ bản nhất, và như tên gọi, nó cũng tái nhập - reentrant - đúng như intrinsic lock. Nó hiện thực interface Lock, vốn chỉ là một cách diễn đạt khóa dưới dạng method thay vì dưới dạng khối cú pháp. Về ngữ nghĩa loại trừ lẫn nhau và bảo đảm memory, một ReentrantLock cho cùng những gì synchronized cho: tại một thời điểm nhiều nhất một thread giữ được khóa, và việc nhả khóa happens-before việc giành lại chính khóa đó, nên nó lo cả atomicity lẫn visibility y hệt.

2.1 lock/unlock và kỷ luật try/finally

Khác biệt đầu tiên lộ ra ngay ở cách dùng. Với synchronized, khi luồng điều khiển rời khối - bằng return, bằng break, hay vì một exception bay ra - JVM nhả khóa giúp bạn. Với Lock, không có ai làm hộ. Nếu một exception bay ra giữa lock()unlock() mà bạn không nhả, khóa sẽ bị giữ vĩnh viễn, và mọi thread khác cần nó sẽ treo. Vì vậy có đúng một khuôn mẫu được chấp nhận, và mọi sai lệch khỏi nó đều đáng nghi:

Lock lock = new ReentrantLock();

lock.lock();
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ó. Lý do tinh tế: nếu lock() thành công rồi một điều gì đó giữa lock()try ném exception, hoặc nếu bạn đặt lock() làm dòng đầu trong try mà nó ném khi đang giành, thì khối finally vẫn chạy unlock() lên một khóa thread này chưa hề giữ, và ReentrantLock sẽ ném IllegalMonitorStateException, che mất exception gốc. Đặt lock() ngay trước try loại bỏ khả năng đó.

Viết lại BookingService bằng ReentrantLock cho thấy nó tương đương Monitor Pattern, chỉ là khóa nay tường minh:

public class BookingService {
    private final ReentrantLock lock = new ReentrantLock();
    private final Map<String, Event> events = new HashMap<>();   // @GuardedBy("lock")
    private final Map<String, Integer> sold = new HashMap<>();    // @GuardedBy("lock")

    public Booking book(String eventId, String userId) {
        lock.lock();
        try {
            Event event = events.get(eventId);
            if (event == null) throw new IllegalArgumentException("No event: " + eventId);
            int current = sold.getOrDefault(eventId, 0);
            if (current >= event.capacity()) throw new SoldOutException(eventId);
            sold.put(eventId, current + 1);
            return new Booking(eventId, userId, current + 1);
        } finally {
            lock.unlock();
        }
    }
}

So với bản synchronized Monitor Pattern ở bài volatile & synchronized, đoạn này dài dòng hơn và dễ sai hơn. Nếu chỉ cần đúng bấy nhiêu chức năng, synchronized vẫn hơn: nó gọn, JVM không bao giờ quên nhả, và công cụ chẩn đoán như thread dump hiển thị nó tốt hơn. Lý do duy nhất đáng đánh đổi sự an toàn cú pháp đó là khi ta cần một trong những khả năng mà intrinsic lock không có. Đó là phần còn lại của mục này.

2.2 tryLock: thử 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. Điều này biến một thao tác vốn có thể treo vô hạn thành một thao tác có thể lập trình xoay quanh.

Một analogy đời thường. lock() giống như xếp hàng ở quầy thanh toán và đứng đó đến lượt mình, dù phải đợi bao lâu. tryLock() giống như liếc qua quầy: nếu trống thì vào, còn nếu có người thì quay đi làm việc khác ngay, không đứng chờ. Quyền quyết định "có chờ hay không" giờ nằm trong tay bạn, chứ không bị ngôn ngữ áp đặt. Cùng analogy đó trải ra đủ bốn cách giành khóa của bài này:

Đờ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âulock() — block vô hạn, như synchronized
Liếc qua quầy, bận thì đi làm việc khác ngaytryLock() — trả về false lập tức, không chờ
Chờ tối đa 5 phút, quá thì bỏ đitryLock(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 ngaylockInterruptibly() — đáp ứng interrupt trong lúc chờ
💡 Cách nhớ

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ó với deadlock. Deadlock kinh điển xảy ra khi hai thread giành hai khóa theo thứ tự ngược nhau và mỗi bên giữ một cái rồi chờ cái kia mãi mãi. Với synchronized ta không có lối thoát nào ngoài việc cẩn thận áp một thứ tự giành khóa toàn cục. Với tryLock ta có thể 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 giua hai su kien, giu ca hai khoa ...
                        return true;
                    } finally {
                        to.unlock();
                    }
                }
            } finally {
                from.unlock();   // khong gom du khoa -> nha cai da gianh, thu lai
            }
        }
        // tranh livelock: lui mot quang ngau nhien roi thu lai
        Thread.sleep(ThreadLocalRandom.current().nextLong(1, 10));
    }
}

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, ta phải đề phòng livelock - tình huống các thread cứ giành rồi nhả nhịp nhàng đến mức không ai tiến lên được - bằng một quãng lùi ngẫu nhiên giữa các lần thử.

2.3 Timeout: chờ có giới hạn

Biến thể tryLock(long time, TimeUnit unit) đứng giữa lock() chờ mãi và tryLock() không chờ chút nào: nó chờ tối đa một khoảng rồi bỏ cuộc nếu vẫn chưa giành được. Đây là khác biệt quyết định trong hệ thống có ràng buộc thời gian. Một request đặt vé có SLA 200 mili giây thì việc xếp hàng vô hạn sau một khóa đang kẹt là vô nghĩa - thà thất bại nhanh và trả lỗi cho client 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 {
        // ... compound action cua book ...
        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 ta tuyệt đối không được unlock. Khối try/finally chỉ bao phần sau khi đã chắc chắn giành được. Đây cũng là lý do timeout là một trong những lý do chính đáng nhất để bỏ synchronized mà chuyển sang ReentrantLock.

2.4 lockInterruptibly: khóa biết nghe lệnh hủy

Một thread đang block trong synchronized chờ intrinsic lock thì không đáp ứng interrupt: gọi interrupt() lên nó chỉ đặt cờ interrupt - cơ chế cooperative cancellation ta đã học ở bài Thread API & vòng đời - chứ không kéo nó ra khỏi trạng thái chờ; cờ chỉ được đọc khi cuối cùng nó cũng giành được khóa. Trong một hệ thống cần hủy task gọn gàng - người dùng bấm Cancel, request bị timeout ở tầng trên, service đang shutdown - đó là một lỗ hổng về liveness.

lockInterruptibly() giành khóa nhưng để ngỏ cho interrupt: nếu thread bị interrupt trong lúc đang chờ, nó lập tức ném InterruptedException và thoát khỏi cuộc chờ thay vì kẹt lại.

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();
    }
}

Với nó, một worker đang xếp hàng sau một khóa kẹt vẫn có thể bị kéo về để dọn dẹp và kết thúc, thay vì trở thành một thread không bao giờ chết.

2.5 Fairness

Constructor new ReentrantLock(true) tạo một fair lock. Khóa công bằng cấp quyền theo đúng thứ tự thời gian các thread vào hàng đợi: ai chờ lâu nhất được phục vụ trước. Khóa mặc định - new ReentrantLock() hoặc new ReentrantLock(false) - là unfair, cho phép một thread vừa tới chen ngang giành khóa ngay nếu đúng lúc khóa vừa được nhả và hàng đợi chưa kịp được đánh thức. Intrinsic lock của synchronized 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, đôi khi cao hơn nhiều. Lý do nằm ở chi phí đánh thức: khi một thread đang ngủ ở đầu hàng đợi được chọn để trao khóa, phải mất một quãng thời gian không nhỏ để nó được scheduler đánh thức và thực sự chạy. Trong quãng trống đó, một thread đang hoạt động sẵn nếu được phép chen ngang sẽ giành khóa, làm xong phần việc ngắn, và nhả ra trước cả khi thread kia tỉnh giấc. Barging - chen ngang - như vậy tận dụng được những khe thời gian mà fairness bỏ phí.

Chỉ chọn fair lock khi bạn thực sự cần chống starvation, tức khi giữ khóa lâu hoặc khoảng cách giữa các lần giành đủ lớn để hàng đợi có ý nghĩa. Với hầu hết 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.

3. Condition: chờ và đánh thức có điều kiện

Intrinsic lock đi kèm bộ ba wait/notify/notifyAll để một thread chờ cho đến khi một điều kiện thành đúng rồi được thread khác đánh thức. Cơ chế đó hoạt động, nhưng một intrinsic lock chỉ có đúng một wait set: mọi thread chờ vì bất kỳ lý do gì đều nằm chung một chỗ, và notifyAll đánh thức tất cả dù phần lớn sẽ kiểm tra điều kiện, thấy chưa thỏa, rồi ngủ lại. Đó là lãng phí, và đôi khi là nguồn lỗi tinh vi.

Condition là phiên bản explicit của wait set, và điểm mạnh là một Lock có thể tạo ra nhiều Condition độc lập. await() thay cho wait(), signal() thay cho notify(), signalAll() thay cho notifyAll(). Một Condition luôn gắn với một Lock, và quy tắc bất di bất dịch là: phải đang giữ lock thì mới được gọi await/signal, và await sẽ tự nhả lock trong lúc chờ rồi tự giành lại trước khi trả về - hệt như wait với intrinsic lock.

Hãy hình dung một waitlist cho sự kiện đã hết vé: khi có vé bị hủy và quay lại kho, ta muốn đánh thức đúng những người đang chờ vé, chứ không đánh thức cả những thread đang chờ vì lý do khác.

public class WaitlistBookingService {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition seatAvailable = lock.newCondition();   // wait set rieng cho "co ve"
    private int remaining;

    public WaitlistBookingService(int capacity) { this.remaining = capacity; }

    public Booking bookOrWait(String eventId, String userId) throws InterruptedException {
        lock.lock();
        try {
            while (remaining == 0) {       // luon cho trong vong while, khong phai if
                seatAvailable.await();     // nha lock, ngu; tinh day thi da giu lai lock
            }
            remaining--;
            return new Booking(eventId, userId, remaining);
        } finally {
            lock.unlock();
        }
    }

    public void release() {
        lock.lock();
        try {
            remaining++;
            seatAvailable.signal();        // danh thuc dung mot nguoi dang cho ve
        } finally {
            lock.unlock();
        }
    }
}

Hành trình của một thread chờ vé qua await/signal đáng vẽ ra cho rõ, vì nó đi qua hai hàng đợi khác nhau - condition queue (nơi nằm chờ điều kiện) và entry queue (nơi xếp hàng giành lại khóa):

sequenceDiagram
    participant C as Consumer (bookOrWait)
    participant L as Lock + Condition
    participant P as Producer (release)

    C->>L: lock() - gianh khoa
    C->>C: kiem tra: remaining == 0
    C->>L: await() - nha khoa, vao condition queue
    Note over C: ngu trong condition queue
    P->>L: lock() - gianh khoa (vua duoc nha)
    P->>P: remaining++
    P->>L: signal() - chuyen 1 thread cho<br/>tu condition queue sang entry queue
    P->>L: unlock()
    Note over C: tinh day o entry queue, gianh lai khoa
    L-->>C: await() tra ve (dang giu khoa)
    C->>C: while kiem tra lai: remaining > 0 ?
    C->>L: unlock()

Để ý bước cuối: signal không trao khóa thẳng cho thread đang chờ, nó chỉ chuyển thread đó sang hàng đợi giành khóa. Giữa lúc được signal và lúc thực sự chạy lại, một thread thứ ba hoàn toàn có thể chen vào lấy mất vé. Đó là lý do thứ nhất khiến vòng while quanh await không phải tùy chọn. Lý do thứ hai là spurious wakeup - một thread có thể tỉnh dậy mà không hề có ai signal, hiện tượng được chính Javadoc của await thừa nhận và cho phép. Cả hai lý do dẫn về cùng một kỷ luật: điều kiện phải được kiểm tra lại sau mỗi lần await trả về, và while làm đúng điều đó còn if thì không.

Sức mạnh thật sự lộ ra khi có nhiều điều kiện chờ khác nhau trên cùng một khóa. Một bounded buffer kinh điển cần hai: producer chờ khi buffer đầy, consumer chờ khi buffer rỗng. Với intrinsic lock chỉ có một wait set, mọi notify đánh thức lẫn lộn cả hai phía. Với hai Condition - notFullnotEmpty - producer chỉ đánh thức consumer và ngược lại, không ai bị dựng dậy vô ích:

public class BoundedBuffer<T> {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();   // producer cho o day
    private final Condition notEmpty = lock.newCondition();   // consumer cho o day
    private final Object[] items;
    private int putIndex, takeIndex, count;

    public BoundedBuffer(int capacity) { this.items = new Object[capacity]; }

    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) notFull.await();   // buffer day -> producer ngu
            items[putIndex] = item;
            putIndex = (putIndex + 1) % items.length;
            count++;
            notEmpty.signal();                               // danh thuc dung MOT consumer
        } finally {
            lock.unlock();
        }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) notEmpty.await();             // buffer rong -> consumer ngu
            T item = (T) items[takeIndex];
            items[takeIndex] = null;
            takeIndex = (takeIndex + 1) % items.length;
            count--;
            notFull.signal();                                // danh thuc dung MOT producer
            return item;
        } finally {
            lock.unlock();
        }
    }
}

Đó là tách wait set mà synchronized không làm được. Và đoạn code trên gần như nguyên văn skeleton của ArrayBlockingQueue trong JDK - khi gặp lại nó ở bài blocking queues, bạn sẽ thấy quen từng dòng.

Cuối cùng, await dễ bị nhầm với Thread.sleep vì cả hai đều "cho thread ngủ", nhưng chúng khác nhau ở điểm cốt tử: await nhả khóa trong lúc chờ rồi tự giành lại trước khi trả về, còn sleep ôm nguyên mọi khóa đang giữ mà ngủ. Một thread sleep trong critical section là một thread chặn tất cả các thread khác đúng bằng thời gian nó ngủ; một thread await thì nhường khóa cho người khác làm việc - đó là điều cho phép release() ở trên giành được khóa để cộng vé và signal.

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 - mọi thread cần nó treo theo.

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.

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.

Nhầm 3: chờ điều kiện bằng if thay vì while. Spurious wakeup hoặc thread thứ ba chen ngang giữa signal và lúc thread tỉnh chạy lại - điều kiện đã sai trở lại mà code vẫn đi tiếp.

✅ Luôn while (chua_thoa_dieu_kien) condition.await(); - kiểm tra lại sau mỗi lần tỉnh.

Nhầm 4: đổ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 về phần dễ sai - quên unlock, thread dump khó đọc hơn - 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

📚 Deep Dive Oracle

Spec / reference chính thức:

  • Lock (Java 21 API) — hợp đồng memory synchronization của interface: unlock happens-before lock kế tiếp, tương đương monitor của JLS §17.4.
  • ReentrantLock (Java 21 API) — Javadoc ghi thẳng khuyến nghị lock() trước try và phân tích fairness/throughput.
  • Condition (Java 21 API) — spurious wakeup được thừa nhận chính thức ở đây, kèm yêu cầu "await phải trong vòng lặp".
  • Java Concurrency in Practice (Goetz et al.), chương 13 — phân tích đánh đổi synchronized vs ReentrantLock chi tiết nhất, vẫn đúng sau gần hai thập kỷ.

Ghi chú: Javadoc của Condition đáng đọc trọn vẹn — nó vừa là spec vừa là bài giảng ngắn về vì sao wait set tách rời quan trọng, với chính ví dụ bounded buffer hai condition.

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 lockInterruptibly là một nâng cấp về liveness so với chờ intrinsic lock.
  • Bài 06 — volatile & synchronized: Monitor Pattern và intrinsic lock mà ReentrantLock thay thế tường minh; acquisition count ở đó chính là state mà bài sau mổ xẻ.
  • Bài 07 — Atomic & CAS: ranh giới "một biến" đẩy ta về khóa — và CAS quay lại ở bài sau với vai trò viên gạch của AQS.
  • Bài 09 — ReadWriteLock, StampedLock & AQS: nửa sau của câu chuyện explicit lock — tách read/write, optimistic read, và bộ khung AQS bên dưới mọi khóa.
  • Bài 11 — Blocking queues: ArrayBlockingQueue hiện thực đúng pattern hai Condition (notFull/notEmpty) của mục 3 — đọc source nó sau bài này sẽ thấy quen.

7. Tóm tắt

  • synchronized vẫn là mặc định; chỉ chuyển sang ReentrantLock khi cần đích danh một khả năng intrinsic lock không có.
  • Khuôn mẫu bắt buộc: lock() ngay trước try, unlock() trong finally - JVM không tự nhả explicit lock, quên một nhánh thoát là treo hệ thống.
  • tryLock() thử không chờ - vũ khí chống deadlock vì không thread nào giữ một khóa rồi block vô hạn chờ khóa thứ hai; đổi lại phải backoff ngẫu nhiên để tránh livelock.
  • tryLock(time, unit) chờ có giới hạn cho hệ thống có SLA; khi trả về false thread không giữ khóa - không được unlock.
  • lockInterruptibly() đáp ứng interrupt trong lúc chờ - chờ intrinsic lock thì cờ interrupt chỉ được set chứ thread không thoát khỏi cuộc chờ.
  • Fair lock chống starvation nhưng trả giá throughput vì chi phí đánh thức; unfair (mặc định) cho barging tận dụng khe thời gian đó.
  • Condition cho nhiều wait set độc lập trên một khóa - đánh thức đúng nhóm thread; await nhả khóa trong lúc chờ (khác sleep) và luôn phải nằm trong vòng while vì spurious wakeup và kẻ chen ngang.

8. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao lock() phải đặt ngay trước khối try, thay vì làm dòng đầu tiên bên trong try?
Nếu lock() nằm trong try và ném exception ngay trong lúc giành (hoặc một lệnh nào đó chen giữa lock()try ném), khối finally vẫn chạy unlock() lên một khóa mà thread chưa hề giữ. ReentrantLock khi đó ném IllegalMonitorStateException, và exception này che mất exception gốc — bạn debug một lỗi giả thay vì lỗi thật. Đặt lock() ngay trước try bảo đảm finally chỉ tồn tại khi khóa chắc chắn đã được giữ.
Q2
Vì sao unlock() bắt buộc nằm trong finally? Chuyện gì xảy ra nếu quên?
Khác synchronized — nơi JVM tự nhả khóa khi luồng điều khiển rời khối bằng bất kỳ đường nào — explicit lock không có ai nhả hộ. Nếu exception bay ra giữa lock()unlock()unlock không nằm trong finally, khóa bị giữ vĩnh viễn: thread ném exception đi tiếp, nhưng mọi thread khác cần khóa đó sẽ block mãi mãi. Hệ thống treo dần từng phần, và thread dump chỉ cho thấy một đám thread chờ một khóa mà chủ của nó đã đi mất. finally là cách duy nhất bảo đảm mọi nhánh thoát đều đi qua unlock.
Q3
tryLock giúp phá vòng deadlock bằng cách nào, và đổi lại phải đề phòng rủi ro gì?
Deadlock cần bốn điều kiện, trong đó có "giữ và chờ": thread giữ khóa A rồi block vô hạn chờ khóa B. tryLock() phá đúng điều kiện đó — không gom đủ khóa thì nhả ngay cái đã giành rồi thử lại, nên không thread nào vừa giữ vừa chờ vô hạn, vòng chờ khép kín không thể hình thành. Rủi ro đổi lại là livelock: các thread giành rồi nhả nhịp nhàng với nhau, ai cũng bận rộn nhưng không ai tiến lên. Thuốc giải là một quãng lùi ngẫu nhiên (randomized backoff) giữa các lần thử để phá thế nhịp.
Q4
Fair lock đánh đổi gì để chống starvation? Vì sao unfair lock thường cho throughput cao hơn?
Fair lock cấp khóa đúng thứ tự hàng đợi, nên không thread nào bị bỏ đói — nhưng trả giá bằng chi phí đánh thức: thread ở đầu hàng đợi đang ngủ, từ lúc được chọn đến lúc scheduler thực sự cho nó chạy là một quãng trống mà khóa nằm im không ai dùng. Unfair lock cho phép một thread đang chạy sẵn chen ngang (barging) giành khóa trong chính quãng trống đó, làm xong việc ngắn và nhả ra trước cả khi thread kia tỉnh. Tận dụng được các khe thời gian này là nguồn throughput vượt trội của unfair. Chỉ trả giá fairness khi thời gian giữ khóa đủ dài để starvation thành vấn đề thật.
Q5
Condition.await() khác Thread.sleep() ở những điểm nào?
Khác biệt cốt tử: await() nhả khóa trong lúc chờ và tự giành lại trước khi trả về, còn sleep() ôm nguyên mọi khóa đang giữ mà ngủ — thread khác không thể vào critical section để thay đổi điều kiện. Thứ hai, await được đánh thức có chủ đích bằng signal khi điều kiện có thể đã thỏa, còn sleep chỉ tỉnh khi hết giờ — chờ điều kiện bằng sleep là polling, vừa chậm vừa tốn. Thứ ba, await bắt buộc gọi khi đang giữ lock gắn với Condition, còn sleep gọi ở đâu cũng được.
Q6
Vì sao điều kiện chờ phải kiểm tra trong vòng while quanh await(), dùng if thì sai ở đâu?
Có hai lý do độc lập. Một, signal không trao khóa thẳng cho thread đang chờ mà chỉ chuyển nó sang hàng đợi giành khóa — giữa lúc được signal và lúc thực sự chạy lại, một thread thứ ba có thể chen vào lấy mất tài nguyên, khiến điều kiện sai trở lại. Hai, spurious wakeup: thread có thể tỉnh dậy mà không hề có ai signal, hiện tượng được Javadoc của await chính thức cho phép. Với if, cả hai trường hợp đều khiến code đi tiếp trên một điều kiện không còn đúng; while bắt kiểm tra lại sau mỗi lần tỉnh, chỉ đi tiếp khi điều kiện thật sự thỏa.
Q7
Khi nào nên ở lại với synchronized thay vì chuyển sang ReentrantLock?
Khi bạn không cần đích danh khả năng nào trong danh sách: tryLock, timeout, interruptible, fairness, nhiều wait set, hay giành/nhả khóa ở hai method khác nhau. Lúc đó synchronized thắng trên mọi mặt còn lại: JVM tự nhả khóa nên không có lớp bug quên-unlock, code gọn hơn, và thread dump hiển thị intrinsic lock tốt hơn cho chẩn đoán. Đổi sang ReentrantLock "cho mạnh" mà không dùng khả năng nào của nó là nhận thêm bề mặt lỗi mà không nhận thêm giá trị.

Bài tiếp theo: ReadWriteLock, StampedLock & AQS — khi đọc áp đảo ghi

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

Đặt 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