Guarded block với wait/notify trên Java Monitor Pattern
Java Monitor Pattern gom state sau một khóa; wait/notify biến monitor mỗi object thành guarded block — nhả khóa chờ điều kiện, kiểm trong while, notifyAll mặc định.
TL;DR: Khi đã có synchronized, câu hỏi kế là rải khóa ở đâu. Java Monitor Pattern trả lời: gói toàn bộ state mutable vào một object, canh bằng một private final Object lock duy nhất, để mọi method chạm state đều giữ cùng khóa — khi ấy chứng minh class an toàn là việc cục bộ, không phải đọc cả chương trình. Bước xa hơn là chờ một điều kiện: wait/notify gắn với intrinsic lock của mỗi object cho phép một thread nhả khóa và treo tới khi thread khác báo điều kiện đã đổi — guarded block. Hai luật sống còn: luôn kiểm điều kiện trong vòng while (spurious và stolen wakeup), và mặc định notifyAll thay vì notify.
1. Java Monitor Pattern gói state lại thế nào?
synchronized cho một object cả hai bảo đảm cùng lúc: chỉ một thread vào được vùng tới hạn (loại trừ lẫn nhau), và nhả khóa happens-before giành lại khóa đó (visibility). Câu hỏi bài này trả lời: đặt khóa ấy ở đâu, và làm sao chờ một điều kiện thành đúng.
BookingService v0 của TicketFlow là check-then-act trên state dùng chung: đọc số vé đã bán, kiểm còn chỗ, rồi ghi tăng. Với concert-01 chỉ còn một chỗ, hai thread cùng chạy có thể cùng thấy còn chỗ rồi cùng bán — hai vé cho một ghế, phá invariant "không vượt capacity". Bài Atomicity đã chỉ ra đổi HashMap thành ConcurrentHashMap không cứu được, vì cụm compound action vẫn không nguyên tử.
Đẩy nguyên tắc đóng gói tới tận cùng cho ra một mẫu gọn: Java Monitor Pattern. Gói toàn bộ state mutable vào bên trong đối tượng, canh bằng một khóa, sao cho mọi method chạm state đều giữ cùng khóa đó. Khi ấy có đúng một nơi áp đặt kỷ luật đồng bộ, và ta kết luận class an toàn mà không phải đọc cả chương trình. Đây chính là lời giải đúng cho BookingService, và là bước v1 trong capstone — Monitor Pattern quây cả cụm vào một khóa:
public class BookingService {
private final Object lock = new Object();
@GuardedBy("lock") private final Map<String, Event> events = new HashMap<>();
@GuardedBy("lock") private final Map<String, Integer> sold = new HashMap<>();
public Booking book(String eventId, String userId) {
synchronized (lock) {
Event event = events.get(eventId);
if (event == null) throw new IllegalArgumentException("Không có sự kiện: " + eventId);
int current = sold.getOrDefault(eventId, 0); // check ...
if (current >= event.capacity()) throw new SoldOutException(eventId);
sold.put(eventId, current + 1); // ... roi act, cung mot khoi
return new Booking(eventId, userId, current + 1);
}
}
public int soldCount(String eventId) { // reader cung PHAI khoa
synchronized (lock) {
return sold.getOrDefault(eventId, 0);
}
}
}
Kịch bản xen kẽ ở v0 không còn: nếu A đã vào, B phải chờ tới khi A ghi xong và nhả khóa; B vào sau đọc current = 1, thấy 1 >= 1, và đúng đắn ném SoldOutException. Cách nói chặt hơn: book đã khóa có một linearization point — thời điểm ghi sold bên trong khối — trước đó chưa khách nào giữ chỗ, sau đó chỗ đã thuộc booking này, không tồn tại trạng thái "ở giữa" mà thread khác quan sát được.
Để ý hai chi tiết. Thứ nhất, soldCount cũng synchronized trên cùng lock, vì reader không khóa có thể đọc giá trị cũ. Thứ hai, dùng private final Object lock thay vì khóa trên this: khóa trên this cho bất kỳ code bên ngoài nào cầm tham chiếu BookingService cũng synchronized trên nó, vô tình tham gia — đúng hoặc sai — vào chính sách khóa của ta, thậm chí gây deadlock; chính kỹ thuật client-side locking ở bài Delegation & concurrent collections khai thác cánh cửa này một cách cố ý. Một lock riêng tư đóng kín chính sách đồng bộ trong đúng một file; @GuardedBy("lock") ghi lại biến nào canh bởi khóa nào — một dòng rẻ hơn nhiều so với một đêm truy lỗi race condition trên production.
2. wait/notify: chờ một điều kiện mà không đốt CPU?
Đôi khi một thread không chỉ cần loại trừ lẫn nhau mà còn cần chờ một điều kiện thành đúng trước khi tiếp: worker chờ hàng đợi có việc, consumer chờ buffer có dữ liệu. Mẫu này gọi là guarded block - khối được canh bởi một điều kiện. Cách ngây thơ là quay vòng bận:
// PHAN VI DU — quay vong ban, dot CPU vo ich
synchronized (lock) {
while (!conditionHolds()) { /* lap suong, giu khoa, chan ca nguoi khac */ }
doWork();
}
Cách này vừa đốt CPU vừa tệ hơn: nó giữ khóa trong lúc quay, nên thread duy nhất có thể làm điều kiện thành đúng lại không vào được. Cơ chế đúng là wait/notify, gắn liền với intrinsic lock của mỗi object.
wait() làm ba việc nguyên tử với nhau: nhả khóa đang giữ, đưa thread vào trạng thái chờ, treo nó ở đó. Khi thread khác gọi notify()/notifyAll() trên cùng object, thread đang chờ được đánh thức, giành lại khóa, rồi chạy tiếp từ ngay sau wait. Nhả khóa khi chờ là điểm mấu chốt — nó cho thread khác cơ hội vào và thay đổi điều kiện. Cả ba wait/notify/notifyAll chỉ được gọi khi đang giữ khóa của chính object đó, nếu không ném IllegalMonitorStateException.
public class BookingQueue {
private final Object lock = new Object();
@GuardedBy("lock") private final Deque<Request> queue = new ArrayDeque<>();
public void submit(Request r) {
synchronized (lock) {
queue.addLast(r);
lock.notifyAll(); // danh thuc worker dang cho
}
}
public Request take() throws InterruptedException {
synchronized (lock) {
while (queue.isEmpty()) { // while, KHONG phai if
lock.wait(); // nha khoa va cho; tinh day thi gianh lai
}
return queue.removeFirst();
}
}
}
Chữ ký take khai báo ném InterruptedException vì wait là blocking method: một thread treo vô hạn trong wait set cần đường thoát khi bị interrupt(), và đường đó là tỉnh dậy với InterruptedException — đúng cơ chế cooperative cancellation đã mổ ở bài Thread API và vòng đời.
sequenceDiagram
participant A as Thread A (worker)
participant M as Monitor cua lock
participant B as Thread B (submitter)
A->>M: giu lock, thay queue rong
A->>M: wait() - nha lock, vao wait set
Note over A: A treo, KHONG giu lock
B->>M: gianh lock, addLast(request)
B->>M: notifyAll() - A roi wait set, sang entry queue
B->>M: roi khoi synchronized, nha lock
M->>A: A tranh lai lock thanh cong
A->>A: tinh day sau wait(), kiem lai dieu kien trong whileChi tiết quan trọng nhất, cũng là lỗi kinh điển nhất: điều kiện phải kiểm trong vòng while, không bao giờ trong if. Hai lý do đều thật. Thứ nhất là spurious wakeup: đặc tả JVM cho phép một thread đang wait tỉnh dậy mà không ai notify — với if, thread chạy tiếp dù điều kiện chưa đúng; với while, nó kiểm lại, thấy vẫn sai, chờ tiếp. Thứ hai là quan sát đã cũ (stolen wakeup), còn phổ biến hơn — đừng nhầm với lost wakeup, tức khi notify xảy ra trước lúc thread kịp wait nên tín hiệu mất hẳn. Ở đây, giữa lúc một thread được đánh thức và lúc nó thực sự giành lại khóa, một thread thứ ba có thể chen vào làm điều kiện thành sai trở lại, ví dụ lấy mất phần tử nó định lấy. Vì giữa notify và lúc tỉnh dậy luôn có một quãng nhả-rồi-giành-lại khóa, trạng thái có thể đã đổi; while buộc kiểm lại sau khi đã cầm khóa nên không bao giờ hành động trên một quan sát đã cũ.
Còn lựa chọn notify với notifyAll. notify đánh thức một thread đang chờ (JVM chọn); notifyAll đánh thức tất cả để chúng cùng tranh khóa rồi tự kiểm điều kiện. notify rẻ hơn nhưng chỉ an toàn khi mọi thread chờ trên cùng object đều chờ đúng một điều kiện và đánh thức một thread là đủ. Nếu các thread chờ điều kiện khác nhau, notify có thể đánh thức nhầm một thread mà điều kiện chưa đúng — nó kiểm while rồi ngủ lại, còn thread thật sự nên chạy thì không bao giờ được gọi. Mặc định an toàn là notifyAll, chỉ hạ xuống notify khi đã chứng minh điều kiện đồng nhất.
Trong code thật hiếm khi nên tự viết wait/notify: java.util.concurrent từ Java 5 đã đóng gói các mẫu này thành building block đã kiểm chứng như BlockingQueue với put/take. Hiểu wait/notify vẫn cần để đọc được chúng từ bên trong.
3. Liên hệ các bài khác
- volatile & synchronized —
synchronizedlà intrinsic lock mà cả Monitor Pattern lẫnwait/notifydựng lên trên; đọc trước bài này để hiểu cây cầu happens-before của khóa. - Atomicity — race condition
v0củaBookingServicemà Monitor Pattern vá; nơi định nghĩa check-then-act và compound action. - Thread API và vòng đời —
waitnémInterruptedException; cơ chế interrupt và cooperative cancellation nằm ở đó. - Blocking queues & producer-consumer —
BlockingQueuecủajava.util.concurrentchính là guarded blockwait/notifyđã đóng gói sẵn, đúng-mọi-lần. - Delegation & concurrent collections — kỹ thuật client-side locking dựa trên khóa public, và vì sao khóa
private finalcủa Monitor Pattern đóng lại cánh cửa đó.
4. Tóm tắt
- Java Monitor Pattern đóng gói state mutable sau một
private finallock duy nhất, để mọi method chạm state đều giữ cùng khóa — bướcv1vá race condition của TicketFlow, và làm việc chứng minh an toàn thành cục bộ. - Reader cũng phải khóa;
bookđã khóa có một linearization point nên không ai quan sát được trạng thái "ở giữa". - Khóa trên
private final Objectthay vìthisđể đóng kín chính sách đồng bộ trong một file; khóa public mở cửa cho client-side locking mong manh. wait/notifymở rộng monitor để chờ một điều kiện:wait()nhả khóa rồi treo,notify/notifyAllđánh thức để giành lại khóa — rẻ hơn quay vòng bận vì không giữ khóa lúc chờ.- Hai luật không được quên: luôn kiểm điều kiện trong vòng
while(spurious + stolen wakeup), và mặc địnhnotifyAll. Code thật nên dùngBlockingQueuethay vì tự viết.
5. Tự kiểm tra
Q1Vì sao điều kiện chờ phải được kiểm trong vòng while quanh wait(), không bao giờ trong if?▸
Hai lý do độc lập. Spurious wakeup: đặc tả JVM cho phép một thread đang wait tỉnh dậy mà không có ai notify — với if, thread sẽ chạy tiếp dù điều kiện chưa đúng.
Quan sát đã cũ (stolen wakeup): giữa lúc được notify và lúc thực sự giành lại được khóa, luôn tồn tại một quãng thread không giữ khóa. Một thread thứ ba có thể chen vào quãng đó và làm điều kiện sai trở lại — ví dụ lấy mất phần tử trong queue. while buộc kiểm lại điều kiện sau khi đã cầm khóa, nên thread không bao giờ hành động trên một quan sát đã hết hạn.
Q2wait() làm những việc gì, và vì sao việc nó nhả khóa lại là điểm mấu chốt của guarded block?▸
wait() làm ba việc nguyên tử với nhau: nhả khóa đang giữ, đưa thread vào wait set, và treo nó ở đó. Khi được notify, thread thức dậy, giành lại khóa, rồi chạy tiếp từ ngay sau wait.
Nhả khóa là mấu chốt vì điều kiện thread đang chờ chỉ có thể đổi nhờ một thread khác vào cùng vùng khóa và sửa state. Nếu wait giữ khóa trong lúc treo (như vòng quay bận), thread duy nhất có thể làm điều kiện thành đúng lại bị chặn ngoài cửa — chờ mãi không thoát. Chính vì có quãng nhả-rồi-giành-lại khóa này mà điều kiện phải được kiểm lại trong while.
Q3Vòng quay bận (busy-wait) giữ khóa trong lúc chờ sai ở đâu, ngoài việc đốt CPU?▸
Đốt CPU chỉ là triệu chứng nhẹ. Lỗi nặng hơn: vòng quay nằm bên trong khối synchronized nên nó giữ khóa suốt thời gian quay. Mà điều kiện nó chờ chỉ đổi được nếu một thread khác vào cùng khối khóa và sửa state — thread đó không bao giờ vào được vì khóa đang bị vòng quay giữ chặt.
Kết cục là deadlock thực dụng: thread chờ mãi một điều kiện mà chính nó đang chặn không cho ai làm cho đúng. wait() giải đúng chỗ này bằng cách nhả khóa trong lúc treo.
Q4Vì sao take() phải khai báo ném InterruptedException? Điều gì xảy ra với thread đang wait khi bị interrupt?▸
wait là blocking method: một thread có thể treo vô hạn trong wait set nếu điều kiện không bao giờ thành đúng. Nó cần một đường thoát, và đường đó là interrupt().
Khi một thread đang wait bị interrupt, nó tỉnh dậy, giành lại khóa, rồi ném InterruptedException ngay tại wait — buộc method khai báo ném exception này. Đây chính là cơ chế cooperative cancellation: thay vì bị giết cưỡng bức, thread nhận tín hiệu và tự dọn dẹp rồi thoát. Nuốt InterruptedException mà không xử lý là bug — nó xóa mất tín hiệu hủy.
Q5Khi nào dùng notify thay notifyAll là an toàn? Rủi ro nếu chọn sai là gì?▸
notify chỉ an toàn khi mọi thread chờ trên cùng object đều chờ đúng một điều kiện và đánh thức một thread là đủ — uniform waiters. Khi đó nó rẻ hơn vì tránh đánh thức hàng loạt thread chỉ để chúng kiểm điều kiện rồi ngủ lại.
Nếu các thread chờ những điều kiện khác nhau, notify có thể đánh thức nhầm một thread mà điều kiện của nó chưa đúng; thread đó kiểm while rồi ngủ tiếp, còn thread đáng lẽ phải chạy thì không bao giờ được gọi — một dạng lost wakeup làm hệ thống treo từng phần. Mặc định an toàn là notifyAll, chỉ hạ xuống notify khi chứng minh được điều kiện đồng nhất.
Q6Vì sao Java Monitor Pattern nên khóa trên một private final Object lock thay vì khóa trên this?▸
Khóa trên this là khóa công khai: bất kỳ code nào cầm tham chiếu tới object đều có thể synchronized trên nó — vô tình tham gia vào chính sách khóa của class, giữ khóa lâu bất thường, thậm chí gây deadlock. Muốn kiểm chứng khóa công khai được dùng đúng, phải soi cả chương trình.
Một private final Object lock đóng kín chính sách đồng bộ trong đúng một file: chỉ code của class chạm được khóa, nên việc chứng minh tính đúng đắn trở thành việc cục bộ. Cái giá là từ bỏ khả năng client-side locking — vốn chỉ nên là kỹ thuật có chủ đích, không phải cửa mở mặc định.
Bài tiếp theo: Atomic & CAS — đồng bộ lock-free cho thao tác đơn
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