Monitor Object — Middle Level¶
Source: POSA2 — Pattern-Oriented Software Architecture, Vol. 2 (Schmidt et al.) · Doug Lea, Concurrent Programming in Java Category: Concurrency — "Patterns for coordinating work across threads, cores, and machines." Prerequisite: junior
Table of Contents¶
- Introduction
- When to Use Monitor Object
- When NOT to Use Monitor Object
- Real-World Cases
- Code Examples — Production-Grade
- Deep Dive:
notify()vsnotifyAll()and Multiple Conditions - Deep Dive: Reentrancy and Nested Monitor Lockout
- Deep Dive: Interruption, Cancellation, and Timed Waits
- Trade-offs
- Alternatives Comparison
- Refactoring to Monitor Object
- Pros & Cons (Deeper)
- Edge Cases
- Tricky Points
- Best Practices
- Tasks (Practice)
- Summary
- Related Topics
- Diagrams
Introduction¶
At the junior level you learned the shape of a Monitor Object: one lock, condition waiting, while-not-if. At this level the questions get sharper. Which signaling primitive should you use, and why does notify() so often cause subtle deadlocks? When do you graduate from the intrinsic lock to an explicit ReentrantLock with multiple Conditions? How do you make a Monitor responsive to cancellation and timeouts so it doesn't hang during shutdown? And critically — when is the whole pattern the wrong tool, because a single object-wide lock has become your throughput ceiling?
We'll work through these with production-grade Java. The thread running point throughout: a Monitor Object trades concurrency for simplicity. It serializes access through one lock. That's a feature when correctness matters more than raw parallelism, and a liability when it doesn't.
When to Use Monitor Object¶
- Shared mutable state with read-modify-write invariants that must hold atomically (counters, buffers, pools, registries).
- Coordination via conditions — threads must block until some state becomes true ("not empty", "below capacity", "leader elected").
- You want the synchronization encapsulated so callers write plain method calls and can't reach around the lock.
- Moderate contention where the simplicity of one lock outweighs the cost of serialization.
- You're building a custom synchronizer that
java.util.concurrentdoesn't provide off the shelf.
When NOT to Use Monitor Object¶
- High-contention hot paths — a single object lock serializes everything; use lock striping,
ConcurrentHashMap, or lock-free structures instead. - Read-mostly workloads — a
ReadWriteLockorStampedLocklets readers run in parallel; a Monitor forces them to queue. - A ready-made
java.util.concurrenttype exists —ArrayBlockingQueue,Semaphore,CountDownLatch,Phaserare battle-tested Monitor implementations. Don't reinvent them. - Long-running or blocking operations — never hold a Monitor lock across I/O; if your method is long work, consider Active Object so the caller isn't blocked.
- Pure message-passing designs (actors, channels) where you avoid shared mutable state entirely.
Real-World Cases¶
java.util.concurrent.ArrayBlockingQueueis a Monitor Object: oneReentrantLock, twoConditions (notEmpty,notFull). Reading its source is the best Monitor tutorial in existence.- HikariCP / database connection pools block borrowers on a condition until a connection is returned — Monitor semantics with timeouts.
- Logging frameworks' bounded async appenders use a Monitor-backed queue between application threads and the I/O writer thread.
- In-memory caches with bounded size (an LRU with capacity) guard eviction + insertion as one critical section.
Code Examples — Production-Grade¶
Bounded buffer with ReentrantLock and two Conditions¶
The intrinsic-lock version from junior had one weakness: a single condition forces notifyAll(), waking producers and consumers indiscriminately. With explicit conditions, you signal only the threads that can actually proceed.
import java.util.concurrent.locks.*;
public final class BoundedBuffer<T> {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Object[] items;
private int head, tail, count;
public BoundedBuffer(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException("capacity > 0");
items = new Object[capacity];
}
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await(); // wait on the RIGHT condition
}
items[tail] = x;
tail = (tail + 1) % items.length;
count++;
notEmpty.signal(); // wake exactly one consumer
} finally {
lock.unlock(); // ALWAYS unlock in finally
}
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await();
}
T x = (T) items[head];
items[head] = null;
head = (head + 1) % items.length;
count--;
notFull.signal(); // wake exactly one producer
return x;
} finally {
lock.unlock();
}
}
}
Two improvements over the intrinsic version: (1) signal() instead of signalAll() is now safe and efficient, because each condition has exactly one kind of waiter; (2) the try/finally makes lock release explicit and exception-safe. This is essentially how ArrayBlockingQueue is written.
Timed, cancellable take with offer/poll semantics¶
import java.util.concurrent.TimeUnit;
public T poll(long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
lock.lock();
try {
while (count == 0) {
if (nanos <= 0L) return null; // timed out → balk
nanos = notEmpty.awaitNanos(nanos); // returns remaining time
}
@SuppressWarnings("unchecked") T x = (T) items[head];
items[head] = null;
head = (head + 1) % items.length;
count--;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
awaitNanos returns the remaining nanoseconds, letting the while loop subtract elapsed time across spurious wakeups — the correct way to implement a deadline. Returning null on timeout is the Balking pattern grafted onto the Monitor.
Deep Dive: notify() vs notifyAll() and Multiple Conditions¶
This is the most consequential design choice in a Monitor.
With the intrinsic lock (one condition queue per object): all waiters — producers and consumers — sleep on the same monitor. A notify() wakes one arbitrary waiter. Suppose the buffer just became non-empty and you notify(). The JVM might wake a producer (who was waiting for "not full"). The producer re-checks, sees the buffer is still full (or now non-full but irrelevant), and goes back to sleep — without having helped the waiting consumer. The signal is consumed by the wrong thread. With more waiters this becomes a stuck system. Hence: with a single condition and heterogeneous waiters, use notifyAll().
notify() is safe only when all three hold: 1. Uniform waiters — every thread waiting on the monitor is waiting for the same condition. 2. One-in, one-out — a single state change enables exactly one waiter. 3. No condition variable confusion — there's only one logical condition.
The clean fix is multiple Condition objects on a ReentrantLock. Each condition has its own wait queue, so notFull.signal() can only wake a producer and notEmpty.signal() can only wake a consumer. Now signal() is both correct and cheaper than signalAll() (no thundering herd).
| Approach | Wake cost | Correctness risk | Use when |
|---|---|---|---|
notifyAll() (intrinsic) | All waiters wake, most re-sleep | Low | Mixed waiters, simple code |
notify() (intrinsic) | One waiter | High — wrong-thread wakeup | Only uniform waiters |
signal() per Condition | One correct waiter | Low | Distinct conditions (preferred) |
Deep Dive: Reentrancy and Nested Monitor Lockout¶
Reentrancy: Java's intrinsic lock and ReentrantLock are reentrant — a thread already holding the lock can re-acquire it. This is why a synchronized method can call another synchronized method on the same object without self-deadlock. It's essential: without reentrancy, internal helper calls would hang the very thread that holds the lock.
Nested monitor lockout is the dangerous cousin. Consider thread T holding lock A, then entering a synchronized method on object B (lock B) and calling B.wait(). wait() releases only lock B — T keeps lock A. Now any thread that needs lock A to ever produce the condition B is waiting for can't get it. T sleeps forever holding A; the producer blocks on A. Deadlock.
// DANGEROUS: waiting while holding an outer lock
synchronized (a) { // holds A
synchronized (b) { // holds B
while (!ready) b.wait(); // releases ONLY B; still holds A → lockout risk
}
}
Rules to avoid it: - Never call wait()/await() while holding more than one lock. - Don't hold a lock when calling out to code you don't control (it might try to acquire your lock or wait). - Establish and document a global lock ordering when multiple locks are unavoidable.
Deep Dive: Interruption, Cancellation, and Timed Waits¶
A Monitor that ignores interruption hangs on shutdown. Object.wait(), Condition.await(), and awaitNanos() all throw InterruptedException when the thread is interrupted while blocked.
public T take() throws InterruptedException { // propagate, don't swallow
lock.lock();
try {
while (count == 0) notEmpty.await(); // throws on interrupt → caller can cancel
...
} finally {
lock.unlock();
}
}
If you absolutely cannot propagate, restore the flag so higher layers still see the cancellation:
try {
...
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // re-assert; never silently swallow
return; // or fall through to a safe state
}
ReentrantLock also offers awaitUninterruptibly() (rarely correct) and tryLock(timeout) for deadlock-avoidant acquisition. Prefer interruptible waits so your service shuts down cleanly.
Trade-offs¶
- Simplicity vs throughput. One lock is dead simple and correct, but serializes everything. Scaling means giving that up (striping, lock-free).
notifyAllcorrectness vs cost.notifyAll()is the safe default but wakes everyone (thundering herd). Multiple conditions buy back efficiency at the cost of more code.- Encapsulation vs flexibility. Hiding the lock inside the object is clean but prevents callers from composing multiple operations atomically (you'd need a coarser method or an exposed lock).
- Intrinsic vs explicit lock.
synchronizedis concise and auto-releases on exception;ReentrantLockadds multiple conditions, timed/interruptible/tryLock, and fairness — at the cost of mandatorytry/finally.
Alternatives Comparison¶
| Mechanism | Concurrency | Conditions | Best for |
|---|---|---|---|
Monitor Object (synchronized / ReentrantLock) | One thread at a time | ✓ (wait/await) | Shared state + condition blocking |
ReadWriteLock / StampedLock | Parallel reads | Limited | Read-mostly state |
Semaphore | N permits | Counting only | Resource pools, rate gates |
BlockingQueue | Internally a Monitor | Built-in | Producer–consumer (use this, don't rebuild) |
| Atomics / CAS | Lock-free | None | Single-variable counters/flags |
| Active Object | Own thread, async | Via queue | Decoupling call from execution |
| Immutable + copy-on-write | Fully parallel reads | None | Rarely-changing shared config |
Refactoring to Monitor Object¶
Symptom: a class with shared fields touched by multiple threads, guarded by ad-hoc synchronized blocks, busy-wait loops, or Thread.sleep() polling.
Before — busy-wait polling (burns CPU, high latency):
public T take() {
while (true) {
synchronized (this) {
if (count > 0) { /* remove and return */ }
}
Thread.sleep(10); // poll — wastes CPU, adds latency
}
}
Steps: 1. Make all shared state private. 2. Route every access through synchronized methods (no field access from outside). 3. Replace each sleep-poll loop with while (!condition) wait();. 4. After every state mutation, notifyAll() (or signal() on the right Condition). 5. If waiters are heterogeneous or contention is high, switch to ReentrantLock + multiple Conditions. 6. Add interruptible/timed variants for cancellation.
After: the guarded-suspension version from Code Examples — no polling, no wasted CPU, threads sleep until exactly the moment they can proceed.
Pros & Cons (Deeper)¶
| ✓ Pros | ✗ Cons |
|---|---|
| Encapsulated synchronization; callers see plain methods. | One lock = serialization bottleneck under contention. |
| Condition variables express "block until" cleanly. | notify misuse is a classic deadlock source. |
| Reentrant — internal calls are safe. | Nested monitors risk lockout; needs lock ordering. |
ReentrantLock adds timed/interruptible/fair acquisition. | More ceremony (try/finally) than synchronized. |
| Runs in caller's thread — no extra thread overhead. | Slow methods block callers; bad for long work. |
Edge Cases¶
- Signal before wait (lost wakeup): keep state-change + signal under the same lock, and re-check with
while, so a waiter either sees the new state or is reliably woken. signalAllthundering herd: many waiters wake, contend, most re-sleep. Use distinct conditions to scope wakeups.- Timeout arithmetic: must subtract elapsed time across spurious wakeups (
awaitNanosreturns the remainder) or a deadline can be silently extended. - Exception mid-critical-section: leaves state consistent only if you mutate atomically;
finallyreleases the lock but won't undo partial writes — order mutations so an exception can't leave a half-updated invariant. - Capacity-zero / single-slot buffers: degenerate cases where producer and consumer must strictly hand off; ensure your
whileconditions still terminate.
Tricky Points¶
signal()on aConditionrequires holding the lock, just likenotify(). Forgetting throwsIllegalMonitorStateException.- Each
Conditionis bound to oneLock. You can'tawaiton a condition from a different lock. - Fairness has a cost.
new ReentrantLock(true)grants the lock in FIFO order, eliminating starvation but lowering throughput. Default (non-fair) is faster; use fair only when starvation is observed. - Mesa semantics still apply with
Condition.await()can return spuriously and the state may be stale — thewhileloop is mandatory here too.
Best Practices¶
- Prefer
ReentrantLock+ namedConditions when you have distinct wait reasons. - Always
lock()outsidetryandunlock()insidefinally. - Use
signal()per dedicated condition; fall back tosignalAll()/notifyAll()only with mixed waiters. - Make all waits interruptible; propagate or restore the interrupt flag.
- Provide timed variants (
poll/offer) so callers aren't stuck forever. - Keep critical sections minimal; never block on I/O under the lock.
- Reach for
java.util.concurrentbefore hand-rolling — and read its source to learn the pattern.
Tasks (Practice)¶
- Rewrite the intrinsic-lock bounded buffer to use
ReentrantLock+ two conditions; provesignal()is now safe. - Add
offer(x, timeout)andpoll(timeout)with correct deadline arithmetic. - Make all waits interruptible and write a test that cancels a blocked
take(). - Build a fixed-size connection pool with
borrow()/release()that blocks when exhausted and times out. - Demonstrate a nested-monitor lockout in a unit test, then fix it by removing the outer wait.
Summary¶
The Monitor Object's correctness hinges on three middle-level decisions. First, how you signal: notifyAll()/signalAll() is the safe default with mixed waiters, but multiple Condition objects on a ReentrantLock let you signal() exactly the right waiter — correct and cheap. Second, how you handle blocking: make waits interruptible and timed so the Monitor cooperates with cancellation and shutdown. Third, whether to use the pattern at all: a single object lock is a throughput ceiling, so under high contention or read-mostly access, reach for ReadWriteLock, atomics, lock striping, or a ready-made java.util.concurrent synchronizer. Master ReentrantLock + Condition and the notify/notifyAll decision, and you can build any custom synchronizer correctly.
Related Topics¶
- Active Object — when method execution should leave the caller's thread.
- Producer–Consumer — the bounded buffer in its natural habitat.
- Balking — return immediately instead of waiting; pairs with timed Monitor methods.
Diagrams¶
Two-condition routing — signal reaches only the right waiters:
Nested monitor lockout:
In this topic
- junior
- middle
- senior
- professional