Guarded Waiting Patterns in Java: Thread-Safe State Transitions
Part 13 of 15 in Mastering Java Network Programming
The guarded-waiting pattern
When multiple threads share mutable state, you need two things: mutual exclusion so only one thread reads or writes at a time, and a way for threads to sleep until something changes. In Java, that’s synchronized, wait(), and notifyAll() — but using them correctly requires three rules that are easy to get wrong.
The first rule is obvious but easy to forget: wait() and notify() can only be called while holding the object’s monitor (inside a synchronized block or method). Violate that and you get IllegalMonitorStateException at runtime.
The second rule is the one that trips people up: always wrap wait() calls in a while loop checking the guard condition, never an if. Spurious wakeups can (and do) happen — the JVM doesn’t need a reason to wake a thread. If you use if, a spurious wakeup skips your guard check and lets the thread proceed with stale state.
The third rule is about InterruptedException: don’t swallow it, and don’t just pass it up without restoring the interrupt status. The proper pattern is Thread.currentThread().interrupt() before re-throwing or returning.
This post walks through a bounded buffer (producer/consumer) that demonstrates blocking when the buffer fills or empties, and a state machine that shows guard conditions preventing invalid transitions — both running in a single program to make the timing visible.
The code
The bounded buffer uses synchronized methods with wait()/notifyAll() pairs. The producer blocks when the buffer reaches capacity; the consumer blocks when it’s empty. Each wakeup re-checks the guard condition in a while loop.
The state machine (TaskDispatcher) has four states — WAITING, READY, PROCESSING, DONE — and each transition method uses a while-loop guard to verify the current state before proceeding. The waiter thread calls waitResult() which loops until DONE is reached.
// Demonstrates guarded waiting patterns with thread-safe state transitions.
// Shows synchronized locks for wait/notify, while-loop guards against
// spurious wakeups, and correct InterruptedException handling.
import java.util.LinkedList;
import java.util.List;
/**
* A bounded buffer using the classic guarded-waiting pattern:
* - synchronized block ensures only one thread accesses state at a time
* - wait() in a while-loop checks the guard condition on every wakeup
* - notifyAll() wakes all waiters when state changes
* - InterruptedException resets the interrupt flag before re-throwing
*/
public class GuardedStateTransition {
/** Shared bounded buffer with guarded access */
static class BoundedBuffer<T> {
private final List<T> items = new LinkedList<>();
private final int capacity;
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
// Put blocks when full, removes from the buffer (head removal)
public synchronized void put(T item) throws InterruptedException {
while (items.size() == capacity) {
System.out.println("[PUT] Buffer FULL (" + items.size() + "/"
+ capacity + "), waiting...");
wait();
}
items.add(item);
int size = items.size();
System.out.println("[PUT] Added '" + item + "' (buffer "
+ size + "/" + capacity + ")");
notifyAll(); // wake consumers waiting to take
}
// Take blocks when empty, removes from the buffer (head removal)
public synchronized T take() throws InterruptedException {
while (items.isEmpty()) {
System.out.println("[TAKE] Buffer EMPTY, waiting...");
wait();
}
@SuppressWarnings("unchecked")
T item = (T) items.remove(0);
int size = items.size();
System.out.println("[TAKE] Removed '" + item + "' (buffer "
+ size + "/" + capacity + ")");
notifyAll(); // wake producers waiting to put
return item;
}
public synchronized int size() {
return items.size();
}
}
/** State machine that transitions based on guard conditions */
static class TaskDispatcher {
private enum State { WAITING, READY, PROCESSING, DONE }
private State state = State.WAITING;
private String result = null;
public synchronized void setReady() throws InterruptedException {
while (state != State.WAITING) {
System.out.println("[DISP] Cannot setReady (in " + state
+ "), waiting for WAITING...");
wait();
}
state = State.READY;
System.out.println("[DISP] -> READY (task submitted)");
notifyAll();
}
public synchronized void startProcessing() throws InterruptedException {
while (state != State.READY) {
System.out.println("[DISP] Cannot start (in " + state
+ "), waiting for READY...");
wait();
}
state = State.PROCESSING;
System.out.println("[DISP] -> PROCESSING");
notifyAll();
}
public synchronized void complete(String r) throws InterruptedException {
while (state != State.PROCESSING) {
System.out.println("[DISP] Cannot complete (in " + state
+ "), waiting for PROCESSING...");
wait();
}
result = r;
state = State.DONE;
System.out.println("[DISP] -> DONE (result=" + result + ")");
notifyAll();
}
public synchronized String waitForResult() throws InterruptedException {
while (state != State.DONE) {
if (state == State.WAITING)
System.out.println("[WAITER] Waiting for task submission...");
else if (state == State.READY)
System.out.println("[WAITER] Waiting for processing start...");
else if (state == State.PROCESSING)
System.out.println("[WAITER] Waiting for result...");
wait();
}
System.out.println("[WAITER] Result received: " + result);
return result;
}
public synchronized State getState() {
return state;
}
}
// ---- Main: exercise both patterns ----
public static void main(String[] args) throws Exception {
System.out.println("=== Guarded State Transitions ===\n");
// --- Part 1: Bounded buffer (producer/consumer) ---
System.out.println("--- Bounded Buffer Demo (capacity=3) ---");
BoundedBuffer<Integer> buffer = new BoundedBuffer<>(3);
boolean[] producerDone = {false};
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 6; i++) {
buffer.put(i);
Thread.sleep(50); // slow production
}
System.out.println("[PRODUCER] All items sent.");
producerDone[0] = true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("[PRODUCER] Interrupted!");
}
}, "producer");
// Consumer sleeps longer than producer produces -> buffer fills up
Thread consumer = new Thread(() -> {
try {
int consumed = 0;
while (!producerDone[0] || buffer.size() > 0) {
if (buffer.size() == 0 && producerDone[0]) break;
Integer item = buffer.take();
consumed++;
System.out.println("[CONSUMER] Processed " + item);
Thread.sleep(200); // slow consumption -> forces blocking
}
System.out.println("[CONSUMER] Done. Consumed: " + consumed);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("[CONSUMER] Interrupted!");
}
}, "consumer");
producer.start();
consumer.start();
producer.join();
consumer.join();
// --- Part 2: State machine ---
System.out.println("\n--- State Machine Demo ---");
TaskDispatcher dispatcher = new TaskDispatcher();
Thread waiter = new Thread(() -> {
try {
String result = dispatcher.waitForResult();
System.out.println("[WAITER] Final answer: " + result);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("[WAITER] Interrupted while waiting!");
}
}, "waiter");
// Advance the state machine from another thread
Thread dispatcherThread = new Thread(() -> {
try {
System.out.println("\n[MAIN] Setting state to READY...");
Thread.sleep(200);
dispatcher.setReady();
System.out.println("[MAIN] Starting processing...");
Thread.sleep(200);
dispatcher.startProcessing();
System.out.println("[MAIN] Completing with result...");
Thread.sleep(200);
dispatcher.complete("42");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("[DISP-THREAD] Interrupted!");
}
}, "dispatcher-worker");
waiter.start();
dispatcherThread.start();
waiter.join();
dispatcherThread.join();
System.out.println("\n=== All transitions completed successfully ===");
}
}
The Thread.sleep() calls stretch timing so you can see blocking behavior in the video. Each catch block for InterruptedException restores the interrupt flag via Thread.currentThread().interrupt() before printing.
Running it
Here’s what happens when the program runs:
The bounded buffer demo shows the producer filling all three slots ([PUT] Added '4' (buffer 3/3)) before hitting the guard. The next put() call prints [PUT] Buffer FULL (3/3), waiting... and blocks — it only resumes when the consumer removes an item and calls notifyAll(), which wakes the producer back up. All six items eventually pass through, and the consumer prints Consumed: 6.
The state machine demo shows clean guarded transitions:
[WAITER] Waiting for task submission...
[MAIN] Setting state to READY...
[DISP] -> READY (task submitted)
[MAIN] Starting processing...
[WAITER] Waiting for processing start...
[DISP] -> PROCESSING
[MAIN] Completing with result...
[WAITER] Waiting for result...
[DISP] -> DONE (result=42)
Each state is only entered after the while-loop guard confirms the previous transition happened. If the dispatcher thread tried to call startProcessing() before setReady(), it would print [DISP] Cannot start (in WAITING), waiting for READY... and block instead of corrupting state.
Takeaway
The guarded-waiting pattern is three rules wearing one idiom: hold the monitor, loop in a while guard (not an if), and restore the interrupt flag. Together they ensure that a thread only proceeds when its precondition is truly met — no spurious wakeups bypass the check, no race conditions sneak through an unlocked section, and cancellation propagates correctly.