Back to Blog
Java

Using the Java Condition Interface for Thread Coordination

java condition interface: Learn how the Java Condition interface works with Lock to coordinate threads, including await and signal methods, a producer-consumer example...

JavaConcurrencyLockConditionThread Synchronization
Illustration of the Java Condition interface coordinating two threads with a lock and await/signal operations.

java condition interface requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

The Condition interface in Java, part of the java.util.concurrent.locks package, provides a more flexible and powerful way to coordinate threads than the traditional wait and notify methods on Object. It is always used in conjunction with a Lock, and it allows multiple wait sets per lock, which is often necessary for complex synchronization logic. This article explains how the Condition interface works, how to use it correctly, and where it fits in your concurrency toolbox.

The Role of Condition in Java Concurrency

The Condition interface is bound to a Lock instance. You obtain a Condition by calling lock.newCondition(). This relationship is fundamental: a Condition cannot exist independently of a Lock. The lock provides the mutual exclusion, while the Condition provides the mechanism for threads to wait for a specific state and to be notified when that state changes.

Unlike the intrinsic wait/notify mechanism, a single Lock can have multiple Condition objects. This allows you to separate different waiting threads into distinct wait sets. For example, in a bounded buffer, you might have one Condition for "buffer not full" and another for "buffer not empty". This separation prevents the common problem of waking up all threads when only one type of condition has changed.

Creating a Condition from a ReentrantLock

The most common implementation of Lock is ReentrantLock. Here is how you create a Condition:

import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class SharedResource { private final Lock lock = new ReentrantLock(); private final Condition condition = lock.newCondition(); // ... }

The Condition object is created from the lock and is forever associated with that lock. You must hold the lock before calling any of the Condition's methods. Failing to do so will throw an IllegalMonitorStateException, just like calling wait() without holding the object's monitor.

Await, Signal, and SignalAll: Core Operations

The Condition interface defines three primary methods for waiting and signaling: await(), signal(), and signalAll().

  • await() causes the current thread to wait until it is signaled or interrupted. The thread releases the lock atomically and enters the wait set for this Condition. When the thread wakes up, it reacquires the lock before returning from await. This is similar to Object.wait().

  • signal() wakes up one thread waiting on this Condition. The choice of which thread is unspecified, but if the lock has a fairness policy, the longest-waiting thread is chosen.

  • signalAll() wakes up all threads waiting on this Condition. This is often safer than signal() when you cannot guarantee that the woken thread will be the one that can make progress.

There are also timed versions: await(long time, TimeUnit unit) and awaitNanos(long nanosTimeout), which wait for a limited duration. These are useful for avoiding indefinite blocking.

A Producer-Consumer Example Using Condition

Let's implement a simple bounded buffer using two Conditions: one for "not full" and one for "not empty". This is a classic use case.

import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class BoundedBuffer<T> { private final Queue<T> queue = new LinkedList<>(); private final int capacity; private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public BoundedBuffer(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } T item = queue.remove(); notFull.signal(); return item; } finally { lock.unlock(); } } }

Notice that we use while loops instead of if around the await() calls. This is essential because a thread can wake up spuriously, or another thread may have consumed the resource before the waiting thread reacquired the lock. The loop rechecks the condition after every wake-up.

Condition vs. Object.wait/notify: Key Differences

AspectCondition (with Lock)Object.wait/notify (with synchronized)
Multiple wait setsYes, via multiple Conditions per LockNo, only one wait set per object
Lock acquisitionMust hold the associated LockMust hold the object's monitor
FairnessCan be configured via ReentrantLockNot configurable
Timed waitsawait(long, TimeUnit) and awaitNanos()wait(long) only
Interrupt handlingawait() throws InterruptedExceptionwait() throws InterruptedException
FlexibilityMore granular controlSimpler but limited

The table highlights the main advantages. The ability to have multiple wait sets is often the deciding factor when you need to coordinate threads that are waiting for different conditions.

Common Pitfalls and How to Avoid Them

One of the most frequent mistakes is calling await() or signal() without holding the associated lock. This results in an IllegalMonitorStateException. Always acquire the lock in a try block and release it in finally, as shown in the example.

Another pitfall is using signal() when signalAll() is required. If you signal only one thread and that thread cannot make progress (e.g., it was waiting for a different condition), you may cause a deadlock. In the bounded buffer example, using signal() on notEmpty is safe because only one thread can consume an item, but if multiple threads are waiting on different conditions, you need to be careful.

Spurious wakeups are another concern. The Java Language Specification allows await() to return without a corresponding signal(). The only safe way to handle this is to recheck the condition in a loop, as we did with while.

Fairness, Performance, and Interrupt Handling

The ReentrantLock can be created with a fairness policy. A fair lock grants access to the longest-waiting thread. This affects how signal() chooses which thread to wake: with a fair lock, the thread that has been waiting the longest is selected. This can reduce starvation but may lower throughput.

Performance-wise, Condition is generally more efficient than wait/notify when you have many threads waiting on different conditions, because it avoids waking up threads that cannot proceed. However, the overhead of using a Lock and Condition is slightly higher than intrinsic synchronization for simple cases. In practice, the difference is negligible unless you are in a very hot path.

Interrupt handling is important: await() throws InterruptedException if the thread is interrupted while waiting. This is similar to wait(). You should decide whether to propagate the exception or restore the interrupt status, depending on your application's requirements.

When to Use Condition vs. Higher-Level Abstractions

The Condition interface is a low-level primitive. In many cases, you can use higher-level classes from java.util.concurrent that encapsulate the same logic. For example, ArrayBlockingQueue provides a thread-safe bounded buffer with put and take methods, and it handles all the waiting and signaling internally. Similarly, Semaphore, CountDownLatch, and CyclicBarrier cover common coordination patterns.

Use Condition directly when you need custom synchronization logic that does not fit these abstractions. For instance, if you need to wait for a complex state that involves multiple variables, or if you need multiple distinct wait sets that are not easily modeled by a single queue or semaphore. The bounded buffer example could be replaced with ArrayBlockingQueue, but if you need to add priority or other custom behavior, Condition gives you the control.

When you do use Condition, always document the locking protocol clearly, and prefer signalAll() over signal() unless you have a strong reason to believe that only one thread can make progress. The cost of waking a few extra threads is usually lower than the risk of a missed signal.

java condition interface: Practical Usage and Code Examples | RYUSLOG DEV