Loading learning content...
Having understood why monitors exist and what advantages they offer, we now turn to how they are structured. A monitor is not just an abstract concept—it has concrete components that work together to provide thread-safe data abstraction.
A monitor consists of five essential components:
This page dissects each component, explains how they interact, and demonstrates how to combine them into well-designed monitors. Understanding monitor structure is essential for both implementing monitors and using them effectively in concurrent programs.
By the end of this page, you will understand: (1) The five components of a monitor and their roles, (2) How components interact during concurrent execution, (3) The lifecycle of a monitor from creation to use, (4) Design patterns for structuring monitor code, and (5) Common structural mistakes and how to avoid them.
The private data of a monitor represents the shared state that the monitor protects. This is the reason the monitor exists—without shared data, there would be nothing to synchronize.
Characteristics of Private Data:
Accessibility: Only code within the monitor can access these variables. External code has no direct access path.
Visibility: From the perspective of monitor procedures, private data appears as normal instance variables. The locking is invisible.
Consistency: Private data can be temporarily inconsistent during a procedure, but must be consistent when the procedure returns (or when waiting on a condition).
Composition: Private data can include primitive types, objects, collections, and even other monitors (with care).
1234567891011121314151617181920212223242526272829303132333435363738
monitor BankAccount { private: // ===== PRIMITIVE DATA ===== int balance; // Simple counter string accountId; // Identifier boolean frozen; // State flag // ===== COMPLEX DATA STRUCTURES ===== Transaction[] history; // List of transactions Map<string, int> limits; // Spending limits by category // ===== CONDITION-RELATED STATE ===== condition sufficientFunds; // Wait when balance too low condition accountActive; // Wait when frozen // ===== DERIVED/CACHED DATA ===== int dailySpent; // Refreshed daily Timestamp lastActivity; // Track usage} monitor ThreadPool { private: // ===== WORK QUEUE ===== Queue<Task> pendingTasks; // Tasks waiting for workers // ===== WORKER MANAGEMENT ===== Thread[] workers; // Worker thread references int activeWorkers; // Currently executing tasks int maxWorkers; // Pool size limit // ===== LIFECYCLE STATE ===== boolean shutdown; // Pool is shutting down // ===== CONDITIONS ===== condition taskAvailable; // Workers wait here condition workerAvailable; // Submitters wait here condition allComplete; // For orderly shutdown}Data Invariants:
Monitor private data typically satisfies invariants—conditions that must always hold when the monitor is not actively executing a procedure. Examples:
balance >= 0 (or: balance >= -overdraftLimit)0 <= count <= capacityactiveWorkers <= maxWorkersInvariants can be temporarily violated during a procedure (e.g., balance goes negative during a transfer before the credit is applied), but must be restored before:
wait() on a condition variableThis discipline ensures external observers never see inconsistent state.
Always document the invariants your monitor maintains. These serve as a contract that all procedures must respect. When debugging, checking invariants at procedure boundaries helps quickly identify where corruption occurred.
Public procedures form the interface of the monitor—the only way external code can interact with the monitor's data. Every public procedure automatically executes under the monitor's lock.
Procedure Categories:
deposit(), enqueue())getBalance(), size())withdraw() waits for sufficient balance)close(), shutdown())Procedure Design Principles:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
public class BoundedBuffer<T> { private T[] buffer; private int count, in, out; // ===== MUTATOR: Modifies state ===== public synchronized void put(T item) throws InterruptedException { // Precondition wait while (count == buffer.length) { wait(); // Buffer full, wait for space } // Mutation buffer[in] = item; in = (in + 1) % buffer.length; count++; // Postcondition signal notifyAll(); // Notify waiting consumers } // ===== ACCESSOR: Reads state, returns value ===== public synchronized int size() { // Pure accessor - no modification, no waiting return count; } public synchronized boolean isEmpty() { return count == 0; } // ===== CONDITIONAL MUTATOR: Waits for condition ===== public synchronized T get() throws InterruptedException { // Precondition wait while (count == 0) { wait(); // Buffer empty, wait for items } // Mutation T item = buffer[out]; buffer[out] = null; // Help GC out = (out + 1) % buffer.length; count--; // Postcondition signal notifyAll(); // Notify waiting producers return item; } // ===== TRY-VARIANT: Non-blocking attempt ===== public synchronized Optional<T> tryGet() { if (count == 0) { return Optional.empty(); // Don't wait, just return } T item = buffer[out]; buffer[out] = null; out = (out + 1) % buffer.length; count--; notifyAll(); return Optional.of(item); } // ===== TIMED-VARIANT: Wait with timeout ===== public synchronized Optional<T> get(long timeoutMs) throws InterruptedException { long deadline = System.currentTimeMillis() + timeoutMs; while (count == 0) { long remaining = deadline - System.currentTimeMillis(); if (remaining <= 0) { return Optional.empty(); // Timeout expired } wait(remaining); // Wait with timeout } T item = buffer[out]; buffer[out] = null; out = (out + 1) % buffer.length; count--; notifyAll(); return Optional.of(item); }}Private methods within a monitor also execute under the lock (when called from public methods). They should not call wait() unless carefully designed, as they may be called from contexts where waiting is inappropriate.
The implicit lock is the invisible mechanism that enforces mutual exclusion. Unlike explicit locks that programmers must acquire and release, the monitor lock is managed automatically by the runtime.
Lock Properties:
Single lock per monitor: All procedures of a monitor share one lock. No procedure can execute concurrently with any other procedure of the same monitor instance.
Reentrant: A thread holding the lock can call other procedures of the same monitor (or the same procedure recursively) without deadlock.
Invisible acquisition/release: Lock operations happen at procedure boundaries, invisible in code.
Exception-safe release: The lock is released even if the procedure throws an exception.
Reentrancy in Detail:
Reentrancy prevents self-deadlock when monitor methods call each other. The lock tracks the current owner and a count:
12345678910111213141516171819202122232425262728293031323334353637383940
public class ReentrantExample { private int value = 0; public synchronized void increment() { // Lock acquired, count = 1 value++; logValue(); // Calls another synchronized method // Returns from logValue, count = 1 } // Lock released, count = 0 public synchronized void logValue() { // Same thread, already holds lock // Lock count = 2 (incremented) System.out.println("Value: " + value); } // Lock count decremented to 1 (NOT released) public synchronized void complexOperation() { // Lock count = 1 increment(); // Lock count = 2 // Inside increment: count = 3 during logValue // Returns from increment: count = 1 decrement(); // Lock count = 2 // Returns from decrement: count = 1 } // Lock count = 0, released public synchronized void decrement() { value--; }} // The call tree during complexOperation()://// complexOperation() [lock count: 1]// └── increment() [lock count: 2]// └── logValue() [lock count: 3]// ← return [lock count: 2]// ← return [lock count: 1]// └── decrement() [lock count: 2]// ← return [lock count: 1]// ← return [lock count: 0, RELEASED]| Event | Lock Count Before | Action | Lock Count After |
|---|---|---|---|
| Enter from outside | 0 (unlocked) | Acquire lock | 1 |
| Call internal method | 1+ | Increment count | Previous + 1 |
| Return from internal | 2+ | Decrement count | Previous - 1 |
| Return to outside | 1 | Release lock | 0 (unlocked) |
| Exception in procedure | 1+ | Decrement; release if 0 | Decremented or 0 |
| Call wait() | 1+ | Release lock (special) | 0 (waiting) |
| Wake from wait() | 0 (was waiting) | Reacquire lock | Previous count |
In Java, the implicit lock is associated with the object instance (for synchronized methods) or a specific object (for synchronized blocks). Different instances have different locks. Static synchronized methods use the Class object as the lock.
While the implicit lock provides mutual exclusion, it doesn't address condition synchronization—the need for threads to wait until certain conditions are true. Condition variables fill this gap.
The Problem Condition Variables Solve:
Consider a bounded buffer's get() operation. If the buffer is empty, the thread must wait. Without condition variables:
// WRONG: Spin waiting (wastes CPU)
public synchronized T get() {
while (count == 0) {
// Busy wait - BAD!
}
// ... proceed
}
But we can't just release the lock and recheck:
// WRONG: Race condition
public T get() {
while (true) {
synchronized(this) {
if (count > 0) break;
}
// Gap here - state might change!
Thread.sleep(10); // Sleep without lock
}
// More races...
}
Condition Variables to the Rescue:
Condition variables allow a thread to atomically: (1) release the lock, (2) add itself to a wait queue, (3) block until signaled, and (4) reacquire the lock when awakened.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// Java's built-in wait/notify on object monitor public class SimpleBuffer<T> { private T[] buffer; private int count; public synchronized void put(T item) throws InterruptedException { while (count == buffer.length) { wait(); // Release lock, wait, reacquire } buffer[in] = item; count++; notifyAll(); // Wake all waiting threads } public synchronized T get() throws InterruptedException { while (count == 0) { wait(); // Release lock, wait, reacquire } T item = buffer[out]; count--; notifyAll(); // Wake all waiting threads return item; }} // LIMITATION: Java's intrinsic monitor has only ONE condition per object// All waiters go to the same queue. notifyAll wakes everyone. ||||||Java (Multiple Conditions)||||||// Using java.util.concurrent.locks for multiple conditions import java.util.concurrent.locks.*; public class OptimizedBuffer<T> { private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); // Separate queue private final Condition notEmpty = lock.newCondition(); // Separate queue private T[] buffer; private int count, in, out; public void put(T item) throws InterruptedException { lock.lock(); try { while (count == buffer.length) { notFull.await(); // Wait on specific condition } buffer[in] = item; in = (in + 1) % buffer.length; count++; notEmpty.signal(); // Signal only consumers } finally { lock.unlock(); } } public T get() throws InterruptedException { lock.lock(); try { while (count == 0) { notEmpty.await(); // Wait on specific condition } T item = buffer[out]; out = (out + 1) % buffer.length; count--; notFull.signal(); // Signal only producers return item; } finally { lock.unlock(); } }} // With separate conditions:// - Producers only signal consumers, not other producers// - Consumers only signal producers, not other consumers// - More efficient: fewer spurious wakeupsWait/Signal Semantics:
wait(): Current thread releases the monitor lock, joins the condition's wait queue, and blocks. When signaled, it reacquires the lock before continuing.
signal() (notify): Wakes one waiting thread. The signaled thread doesn't run immediately—it waits to reacquire the lock.
broadcast() (notifyAll): Wakes all waiting threads. They compete to reacquire the lock.
The While Loop Requirement:
Always use while, never if, for condition checks:
// WRONG:
if (count == 0) wait(); // What if another thread grabs the item first?
// CORRECT:
while (count == 0) wait(); // Recheck after waking
Multiple threads may be awakened; only one can proceed with the desired condition.
Operating systems and JVMs may wake threads without explicit signal calls (spurious wakeups). The while-loop pattern handles this gracefully by rechecking the condition. An if-check would proceed incorrectly after a spurious wakeup.
Initialization code sets up the monitor's private data when the monitor is created. While seemingly straightforward, proper initialization requires care to ensure thread safety from the first moment.
Initialization Concerns:
this escape: Don't let this reference escape during construction12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Safe monitor initialization public class ThreadSafeCounter { // INITIALIZATION CONCERNS ADDRESSED: // 1. Final fields - guaranteed visibility after construction private final String name; // 2. Mutable state - initialized to known value private int value; // 3. Computed initial state private int maxValue; // GOOD: Simple constructor, no this-escape public ThreadSafeCounter(String name, int initial, int max) { // Validate first if (initial < 0 || max < initial) { throw new IllegalArgumentException("Invalid bounds"); } this.name = name; // Final field set this.value = initial; // Mutable state set this.maxValue = max; // Computed value set // DO NOT: // registry.register(this); // this-escape // new Thread(this::run).start(); // this-escape // listeners.add(this); // this-escape } // If registration is needed, use factory method: public static ThreadSafeCounter create(String name, int initial, int max, Registry registry) { ThreadSafeCounter counter = new ThreadSafeCounter(name, initial, max); // Now fully constructed - safe to publish registry.register(counter); return counter; } // Operations as usual public synchronized void increment() { if (value < maxValue) { value++; } } public synchronized int getValue() { return value; }} // ANTI-PATTERN: This-escape in constructorpublic class BrokenCounter { private int value; public BrokenCounter(Executor executor) { // BUG: 'this' escapes before construction finishes! executor.execute(() -> { // This lambda captures 'this' and may run before // the constructor completes, seeing partial state System.out.println("Value: " + this.value); }); this.value = 42; // May not have run when lambda executes }}Safe Publication Patterns:
When sharing a newly-created monitor with other threads, ensure proper publication:
| Technique | Mechanism | Example |
|---|---|---|
| Final field | JMM final field semantics | private final Buffer buffer = new Buffer(); |
| Volatile field | Volatile write visibility | volatile Buffer shared; shared = new Buffer(); |
| Synchronized block | Lock release visibility | synchronized(lock) { shared = new Buffer(); } |
| Static initializer | Class initialization lock | static Buffer buf = new Buffer(); |
| ConcurrentHashMap | Internal synchronization | map.put(key, new Buffer()); |
Fields that are set during initialization and never change should be declared final. This provides both documentation of intent and thread-safety guarantees. JVM guarantees visibility of final fields after construction completes.
Understanding how monitor components interact during execution is key to mastering monitors. Let's trace through a complete example showing all components working together.
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Complete monitor with all components interactingpublic class Semaphore { // ===== PRIVATE DATA ===== private int permits; // Current available permits private final int maxPermits; // Upper bound (final = immutable) // ===== CONDITION (implicit in Java's wait/notify) ===== // Threads wait here when permits == 0 // ===== INITIALIZATION ===== public Semaphore(int initialPermits) { if (initialPermits < 0) { throw new IllegalArgumentException("Permits cannot be negative"); } this.permits = initialPermits; this.maxPermits = initialPermits; } // ===== PUBLIC PROCEDURE: acquire() ===== public synchronized void acquire() throws InterruptedException { // STEP 1: Check condition (loop for spurious wakeups) while (permits == 0) { // STEP 2: Wait releases lock, blocks, will reacquire when signaled wait(); } // STEP 3: Modify private data (now under lock, condition is true) permits--; } // ===== PUBLIC PROCEDURE: release() ===== public synchronized void release() { // STEP 1: Modify private data if (permits < maxPermits) { permits++; } // STEP 2: Signal waiters (condition may now be true for them) notify(); // Wake one waiter } // ===== ACCESSOR: availablePermits() ===== public synchronized int availablePermits() { return permits; // Direct read under lock }}Trace: Two threads using the Semaphore
Initial state: permits = 1
| Time | Thread A | Thread B | permits | Lock State |
|---|---|---|---|---|
| T0 | → acquire() | (running elsewhere) | 1 | Free |
| T1 | Acquires lock | 1 | Held by A | |
| T2 | while(permits==0) → false | 1 | Held by A | |
| T3 | permits-- → 0 | 0 | Held by A | |
| T4 | Returns from acquire() | → acquire() | 0 | Free → Held by B |
| T5 | (using resource) | while(permits==0) → true | 0 | Held by B |
| T6 | wait() - releases lock, blocks | 0 | Free (B waiting) | |
| T7 | Done using resource | (blocked) | 0 | |
| T8 | → release() | (blocked) | 0 | Held by A |
| T9 | permits++ → 1 | (blocked) | 1 | Held by A |
| T10 | notify() - marks B runnable | (runnable) | 1 | Held by A |
| T11 | Returns from release() | (waiting for lock) | 1 | Free → Held by B |
| T12 | Reacquires lock, continues | 1 | Held by B | |
| T13 | while(permits==0) → false | 1 | Held by B | |
| T14 | permits-- → 0 | 0 | Held by B | |
| T15 | Returns from acquire() | 0 | Free |
Note how: (1) The lock is held during all data access, (2) wait() atomically releases lock and blocks, (3) After notify(), Thread B doesn't run until Thread A releases the lock, (4) Thread B rechecks the condition after reacquiring the lock.
Certain patterns recur when structuring monitors. Recognizing these patterns helps design new monitors and understand existing ones.
Pattern 1: Guarded Methods
The most common pattern: wait until a condition is true, perform action, signal that conditions may have changed.
public synchronized void guardedMethod() throws InterruptedException {
while (!condition) {
wait();
}
// ... action ...
notifyAll();
}
Pattern 2: Balking
Instead of waiting for a condition, return or throw immediately if the condition isn't met:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// PATTERN 1: Guarded Methodpublic synchronized void guarded() throws InterruptedException { while (!precondition()) { wait(); } action(); notifyAll();} // PATTERN 2: Balking - refuse if not readypublic synchronized boolean balkingMethod() { if (!precondition()) { return false; // Balk: refuse to proceed } action(); return true;} // PATTERN 3: Guarded Suspension with State Machinepublic class StatefulMonitor { private enum State { IDLE, RUNNING, PAUSED, STOPPED } private State state = State.IDLE; public synchronized void start() throws InterruptedException { while (state != State.IDLE) { if (state == State.STOPPED) { throw new IllegalStateException("Cannot restart stopped monitor"); } wait(); // Wait until IDLE } state = State.RUNNING; notifyAll(); } public synchronized void pause() { if (state == State.RUNNING) { state = State.PAUSED; notifyAll(); } } public synchronized void stop() { state = State.STOPPED; notifyAll(); // Wake everyone, they should exit }} // PATTERN 4: Single Producer/Consumer with Handoffpublic class Handoff<T> { private T item = null; private boolean hasItem = false; public synchronized void give(T value) throws InterruptedException { while (hasItem) { wait(); // Wait for previous item to be taken } item = value; hasItem = true; notify(); // Signal consumer } public synchronized T take() throws InterruptedException { while (!hasItem) { wait(); // Wait for item } T result = item; item = null; hasItem = false; notify(); // Signal producer return result; }} // PATTERN 5: Resource Poolpublic class ResourcePool<R> { private final Queue<R> available; private final int maxSize; private int currentSize; public synchronized R acquire() throws InterruptedException { while (available.isEmpty()) { if (currentSize < maxSize) { // Can create new resource R resource = createResource(); currentSize++; return resource; } wait(); // At capacity, wait for return } return available.poll(); } public synchronized void release(R resource) { available.offer(resource); notify(); // Wake one waiter }}When designing a new monitor, identify which pattern(s) it follows. This guides the structure and helps avoid reinventing (and possibly mis-implementing) common solutions.
We have completed our exploration of the Monitor Concept—the foundational module for understanding monitors and condition variables. This page examined the five structural components of a monitor and how they work together. Let's consolidate:
Module 1 Complete: Monitor Concept
Across this module's five pages, we have built a comprehensive understanding of monitors:
This foundation prepares you for the next module: Condition Variables, where we will dive deep into the mechanisms that enable threads to wait for specific conditions within the structured safety of monitors.
You now have a complete understanding of the monitor concept—what monitors are, why they exist, how they're structured, and why they represent an advancement over lower-level primitives. The next module on Condition Variables will build on this foundation, exploring the wait/signal mechanisms that enable sophisticated thread coordination.