Coordinating threads — making them wait for each other, hand off data, or run in a controlled order — is one of the harder parts of concurrent programming. Java's toolkit spans several layers: JVM-native primitives (wait/notify, join), the java.util.concurrent package (locks, latches, executors, atomics), and newer additions from Project Loom (virtual threads, structured concurrency). This guide covers the full set, organized bottom-up, with the key contract each API makes, why it matters, and a runnable example.
Core idea: thread coordination is about two things: controlling execution and making memory effects visible. Prefer the highest-level API that directly expresses the relationship you need.
1. Basic Thread Coordination
A useful mental model is:
join() -> wait for a thread
sleep() -> wait for time
interrupt() -> request cooperative cancellation
Thread.start() is also a coordination boundary: actions in the calling thread before start() happen-before actions in the started thread.
The oldest tools, defined directly on java.lang.Thread.
Thread.start()
start() schedules a new thread to execute run(). Calling run() directly does not create a new thread.
Thread worker = new Thread(() ->
System.out.println(Thread.currentThread().getName()));
worker.start(); // concurrent execution
// worker.run(); // ordinary method call; no new thread
Thread.join()
Blocks the calling thread until the target thread terminates.
-
join()waits indefinitely;join(millis)/join(millis, nanos)time out — after which the caller resumes even if the thread is still alive, so checkisAlive()if that matters. - Implemented internally via
wait(), so it responds to interruption withInterruptedException.
Thread worker = new Thread(() -> {
System.out.println("Working...");
});
worker.start();
worker.join(); // Main thread blocks here until worker finishes
System.out.println("Worker done, continuing.");
Thread.sleep()
Thread.sleep() pauses the current thread. It is not a synchronization mechanism and does not release monitors or explicit locks held by that thread.
synchronized (lock) {
Thread.sleep(1_000); // lock is still held
}
Pauses the current thread without releasing any locks it holds.
- Sleeps for at least the requested duration — not guaranteed to be exact.
- Clears the interrupt flag when it throws
InterruptedException; re-set it in your catch block if the thread needs to keep behaving as interrupted.
try {
Thread.sleep(500); // Pause for 500 ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupt status
}
Thread.interrupt()
Sets a thread's interrupt flag; does not forcibly stop it.
- If blocked in
wait/sleep/join, the target wakes immediately withInterruptedExceptionand the flag clears. - If running normal code, cooperative polling (
Thread.interrupted()/isInterrupted()) is required to notice it.
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Do work
}
System.out.println("Exiting cleanly on interrupt");
});
worker.start();
worker.interrupt(); // Request cooperative shutdown
2. Memory Visibility: volatile
Not a blocking mechanism, but foundational to coordination: a volatile field guarantees that writes by one thread are immediately visible to reads by others, and prevents the compiler/CPU from reordering around it. Many hand-rolled "flag" coordination patterns (e.g., a running flag checked in a loop) are broken without it.
public class Worker implements Runnable {
private volatile boolean running = true;
@Override
public void run() {
while (running) {
// Do work
}
}
public void stop() {
running = false; // Guaranteed visible to the running thread
}
}
volatile gives visibility, not atomicity — volatile int count; count++; is still a race. For that, use synchronized or the atomic classes below.
3. Monitor-Based Coordination (Intrinsic Locks)
Every object carries an implicit monitor — the original coordination mechanism, defined on Object.
synchronized
Acquires an object's (or a class's, for static methods) intrinsic lock on entry, releases it on exit — even via exception. Reentrant, and establishes happens-before visibility guarantees.
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}
Object.wait() / notify() / notifyAll()
wait() releases the monitor and suspends the thread until notify/notifyAll is called on the same object, or a timeout elapses. Must be called while holding the lock, or it throws IllegalMonitorStateException. Spurious wakeups are allowed by the JLS, so wait() must always sit in a while loop.
public class BoundedCell {
private int value;
private boolean hasValue = false;
public synchronized void put(int v) throws InterruptedException {
while (hasValue) {
wait(); // Wait until the current value is consumed
}
value = v;
hasValue = true;
notifyAll(); // Wake any waiting consumer
}
public synchronized int take() throws InterruptedException {
while (!hasValue) {
wait(); // Wait until a value is produced
}
hasValue = false;
notifyAll(); // Wake any waiting producer
return value;
}
}
notify() wakes exactly one arbitrary waiter; notifyAll() wakes all of them so each re-checks its own condition — generally the safer default.
wait() vs sleep()
sleep()
-> pauses current thread
-> does NOT release a lock
wait()
-> releases the object's monitor
-> waits for notification/timeout/interruption
-> reacquires the monitor before returning
Always guard wait() with a condition loop:
while (!condition) {
wait();
}
The condition must be re-checked after every wake-up.
4. Explicit Lock Coordination (java.util.concurrent.locks)
Added to overcome synchronized's rigidity: interruptible acquisition, timeouts, fairness, and multiple wait-sets per lock.
ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public void updateSharedState() {
lock.lock();
try {
// Critical section
} finally {
lock.unlock(); // Must release manually, unlike synchronized
}
}
// Non-blocking variant
if (lock.tryLock()) {
try {
// Got the lock
} finally {
lock.unlock();
}
} else {
// Do something else instead of blocking
}
When ReentrantLock is preferable
Start with synchronized for simple mutual exclusion. Reach for ReentrantLock when you specifically need capabilities such as:
tryLock()- timed acquisition
- interruptible acquisition
- configurable fairness
- multiple independent
Conditions
Condition — await() / signal() / signalAll()
A ReentrantLock can spawn multiple Conditions, letting you separate "buffer full" from "buffer empty" instead of waking every waiter indiscriminately.
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final int[] buffer = new int[10];
private int count = 0;
private int putIdx = 0;
private int takeIdx = 0;
public void put(int v) throws InterruptedException {
lock.lock();
try {
while (count == buffer.length) {
notFull.await(); // Wait until space is available
}
buffer[putIdx] = v;
putIdx = (putIdx + 1) % buffer.length;
count++;
notEmpty.signal(); // Wake a waiting consumer
} finally {
lock.unlock();
}
}
public int take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await(); // Wait until an item is available
}
int v = buffer[takeIdx];
takeIdx = (takeIdx + 1) % buffer.length;
count--;
notFull.signal(); // Wake a waiting producer
return v;
} finally {
lock.unlock();
}
}
ReadWriteLock / ReentrantReadWriteLock
Splits a lock into a read lock (shared, many readers concurrently) and a write lock (exclusive) — ideal for read-heavy shared state.
private final ReentrantReadWriteLock rwLock =
new ReentrantReadWriteLock();
private final Map<String, String> cache = new HashMap<>();
public String read(String key) {
rwLock.readLock().lock();
try {
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void write(String key, String value) {
rwLock.writeLock().lock();
try {
cache.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
StampedLock (Java 8+)
Adds a third, optimistic read mode: readers don't block at all, they just validate afterward that no write happened concurrently — much higher throughput for read-heavy, short-critical-section workloads than ReentrantReadWriteLock.
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead();
double curX = x;
double curY = y;
if (!stampedLock.validate(stamp)) {
// A write happened during the read
stamp = stampedLock.readLock();
try {
curX = x;
curY = y;
} finally {
stampedLock.unlockRead(stamp);
}
}
return Math.sqrt(curX * curX + curY * curY);
}
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp);
}
}
Note: StampedLock is not reentrant — re-acquiring from the same thread will deadlock.
5. Atomic Variables & Compare-And-Swap (java.util.concurrent.atomic)
Lock-free coordination for single variables, built on hardware CAS (compare-and-swap) instructions — faster than locking under contention for simple counters/flags/references.
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // Atomic ++
counter.compareAndSet(5, 10);
// Set to 10 only if the current value is 5
AtomicReference<String> current =
new AtomicReference<>("idle");
current.compareAndSet("idle", "running");
// Classic state-transition guard
// updateAndGet applies a function atomically,
// retrying under contention
AtomicLong total = new AtomicLong(0);
total.updateAndGet(v -> v + computeDelta());
CAS mental model
A compare-and-set operation follows this conceptual loop:
read current value
↓
calculate new value
↓
CAS(oldValue, newValue)
↓
success?
├── yes -> done
└── no -> retry
CAS is excellent for single-variable state transitions, but becomes harder to reason about when several related variables must change together.
For very high-contention counters, LongAdder / DoubleAdder (also in java.util.concurrent.atomic) outperform AtomicLong by striping the counter across internal cells and summing on read.
LongAdder hits = new LongAdder();
hits.increment(); // Efficient under heavy contention from many threads
long total = hits.sum();
// Read current total; may reflect concurrent updates.
// Exact once updates have quiesced.
6. Synchronization Utilities (java.util.concurrent)
CountDownLatch
One-shot gate: initialized with a count; countDown() decrements it; await() blocks until zero. Cannot be reset.
CountDownLatch readySignal = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(() -> {
// Do setup work
readySignal.countDown();
}).start();
}
readySignal.await();
// Blocks until all 3 workers signal that they are ready
System.out.println("All workers ready, starting run.");
Latch vs Barrier vs Phaser
A useful distinction:
CountDownLatch -> one-shot event gate
CyclicBarrier -> reusable fixed-party checkpoint
Phaser -> reusable checkpoint with dynamic parties/phases
CyclicBarrier
Blocks N threads until all reach the barrier, releases them together, and automatically resets for reuse across multiple phases.
CyclicBarrier barrier = new CyclicBarrier(
4,
() -> System.out.println(
"All 4 threads reached the barrier — merging results"
)
);
Runnable task = () -> {
// Do phase-1 work
try {
barrier.await();
} catch (Exception e) {
// Handle interruption or barrier failure
}
// Do phase-2 work only after everyone has arrived
};
for (int i = 0; i < 4; i++) {
new Thread(task).start();
}
Phaser
Reusable barrier with a dynamic number of parties (register/deregister at runtime) and multiple named phases.
Phaser phaser = new Phaser(1);
// "1" registers the main thread as a party
for (int i = 0; i < 3; i++) {
phaser.register();
new Thread(() -> {
// Phase 0 work
phaser.arriveAndAwaitAdvance();
// Wait for all parties to finish phase 0
// Phase 1 work
phaser.arriveAndDeregister();
// Complete phase 1 and remove this thread
}).start();
}
phaser.arriveAndAwaitAdvance();
// Main thread joins phase 0 completion
phaser.arriveAndDeregister();
// Main thread completes its participation
Semaphore
Maintains a set of permits; not tied to a single owning thread, so it suits resource pools rather than pure mutual exclusion.
Semaphore connectionPool = new Semaphore(5);
// Allows up to 5 concurrent connections
void useConnection() throws InterruptedException {
connectionPool.acquire();
// Acquire one available slot
try {
// Use one of the 5 available connections
} finally {
connectionPool.release();
// Return the slot to the pool
}
}
7. Producer/Consumer: BlockingQueue
Bakes coordination directly into queue operations — put() blocks when full, take() blocks when empty — eliminating hand-written wait/notify logic.
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100);
// Producer
new Thread(() -> {
try {
while (true) {
queue.put(produceItem());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// Consumer
new Thread(() -> {
try {
while (true) {
consume(queue.take());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
Design principle: if a higher-level abstraction already expresses the coordination protocol, prefer it over hand-written wait()/notify().
SynchronousQueue is the zero-capacity special case: every put() must rendezvous directly with a matching take().
8. Task Coordination
ExecutorService
Decouples task submission from thread management.
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Callable<Integer>> tasks = List.of(
() -> compute(1),
() -> compute(2),
() -> compute(3)
);
List<Future<Integer>> results = pool.invokeAll(tasks);
// Blocks until all tasks complete
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
Since Java 21, Executors.newVirtualThreadPerTaskExecutor() gives this same API cheap scaling to huge numbers of blocking tasks via virtual threads.
Executor lifecycle
An ExecutorService is a resource with a lifecycle. In production code, decide who owns it and who shuts it down.
create
↓
submit tasks
↓
shutdown()
↓
awaitTermination()
Future
Future<Integer> future = pool.submit(() -> compute(42));Integer result = future.get(5, TimeUnit.SECONDS); // blocks with timeout
CompletionService
Wraps an ExecutorService and a completion queue so you can process results as they finish, rather than in submission order — invokeAll blocks until everything is done, CompletionService lets you react incrementally.
CompletionService<Integer> ecs =
new ExecutorCompletionService<>(pool);
for (Callable<Integer> task : tasks) {
ecs.submit(task);
}
for (int i = 0; i < tasks.size(); i++) {
Future<Integer> done = ecs.take();
// Returns the Future of whichever task finishes next
System.out.println("Got result: " + done.get());
}
ScheduledExecutorService
Coordinates tasks that run after a delay or on a recurring schedule.
ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(2);
scheduler.schedule(
() -> System.out.println("Runs once after 10 seconds"),
10,
TimeUnit.SECONDS
);
scheduler.scheduleAtFixedRate(
() -> pingHealthCheck(),
0,
30,
TimeUnit.SECONDS
);
CompletableFuture
Besides successful pipelines, learn its failure and cancellation operators:
future
.exceptionally(ex -> fallbackValue())
.whenComplete((value, ex) -> audit(value, ex));
Use thenCompose when the next operation itself returns a CompletableFuture:
fetchUser()
.thenCompose(user -> fetchOrders(user.id()));
Use thenCombine when two independent futures need to be combined.
CompletableFuture
Composable, callback-driven async pipelines.
CompletableFuture<Integer> pipeline = CompletableFuture
.supplyAsync(() -> fetchUserId())
.thenApplyAsync(id -> fetchProfile(id))
.thenApply(profile -> profile.getScore());
pipeline.thenAccept(
score -> System.out.println("Score: " + score)
);
// Wait for several independent futures together
CompletableFuture<Void> all =
CompletableFuture.allOf(future1, future2, future3);
all.join(); // Blocks until all three futures complete
9. Fork/Join Framework (java.util.concurrent)
Coordinates recursive divide-and-conquer parallelism, using a pool of worker threads that steal work from each other's queues (ForkJoinPool) for load balancing.
class SumTask extends RecursiveTask<Long> {
private final int[] arr;
private final int lo;
private final int hi;
SumTask(int[] arr, int lo, int hi) {
this.arr = arr;
this.lo = lo;
this.hi = hi;
}
@Override
protected Long compute() {
if (hi - lo <= 1000) {
long sum = 0;
for (int i = lo; i < hi; i++) {
sum += arr[i];
}
return sum;
}
int mid = (lo + hi) / 2;
SumTask left = new SumTask(arr, lo, mid);
SumTask right = new SumTask(arr, mid, hi);
left.fork();
// Execute the left task asynchronously
long rightResult = right.compute();
// Compute the right task in the current thread
return left.join() + rightResult;
// Wait for the left task and combine both results
}
}
long total = ForkJoinPool.commonPool()
.invoke(new SumTask(bigArray, 0, bigArray.length));
RecursiveAction is the equivalent for tasks with no return value.
10. Low-Level Coordination: LockSupport
The permit-based primitive that ReentrantLock and others are built on.
Thread target = Thread.currentThread();
Thread signaler = new Thread(() -> {
// Do preparation work
LockSupport.unpark(target);
// Give a permit to the target thread,
// even if the target has not parked yet
});
signaler.start();
LockSupport.park();
// Consumes the permit if one is already available;
// otherwise, blocks until unpark() is called.
Unlike wait/notify, there's no race between "signal arrives first" and "wait starts first" — the permit is stored. Still subject to spurious wakeups per its javadoc, so re-check conditions after park() returns.
11. Specialized: Exchanger
A rendezvous point where exactly two threads swap objects.
Exchanger<Buffer> exchanger = new Exchanger<>();
// Thread A: Fills a buffer, then swaps it for an empty one
Buffer filled = fillBuffer(new Buffer());
Buffer empty = exchanger.exchange(filled);
// Waits for Thread B and exchanges the filled buffer
// for the buffer supplied by Thread B
// Thread B: Takes the filled buffer and hands back an empty one
Buffer received = exchanger.exchange(new Buffer());
// Waits for Thread A and exchanges buffers
12. Thread-Confinement Tools
ThreadLocal
Gives each thread its own independent copy of a variable — sidesteps coordination entirely by avoiding sharing.
private static final ThreadLocal<SimpleDateFormat> FORMATTER =
ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd")
);
String format(Date d) {
return FORMATTER.get().format(d);
// Each thread gets its own formatter instance,
// so no shared mutable state or locking is required.
}
ScopedValue (finalized in Java 25, JEP 506)
A safer, immutable alternative to ThreadLocal for passing context (like trace IDs) into child tasks — especially virtual threads spawned by structured concurrency — without the leak/inheritance pitfalls of InheritableThreadLocal.
static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance();
ScopedValue.where(TRACE_ID, "req-42").run(() -> {
// TRACE_ID.get() == "req-42" here
// The value is available to child tasks
// within the structured scope
processRequest();
});
13. Virtual Threads
Virtual threads make thread-per-task programming practical for large numbers of mostly blocking tasks.
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> callRemoteService());
executor.submit(() -> callDatabase());
}
They are especially useful for I/O-bound workloads. They do not provide more CPU capacity for CPU-bound work.
A good rule:
I/O-bound, many concurrent tasks -> virtual threads can scale very well
CPU-bound -> parallelism is still limited by available CPU
13. Structured Concurrency: StructuredTaskScope
As of late 2026, StructuredTaskScope (java.util.concurrent) is still a preview API — it has re-previewed in every JDK release since 19, most recently as JEP 525 in JDK 26, with JEP 533 targeting JDK 27; it requires --enable-preview and its surface may still change before finalization. It ties the lifetimes of a group of subtasks (run on virtual threads) to a single block, so cancellation and error propagation happen as one unit instead of being scattered across manually-tracked Futures.
try (var scope = StructuredTaskScope.open()) {
Subtask<User> userTask =
scope.fork(() -> fetchUser(id));
Subtask<Order> orderTask =
scope.fork(() -> fetchOrders(id));
scope.join();
// Wait for both subtasks to complete
// or fail according to the scope's shutdown policy
User user = userTask.get();
Order order = orderTask.get();
}
Because this is a moving target pre-finalization, check the current JEP before depending on exact method names in production code.
14. Supporting Concurrent Collections
Not coordination primitives per se, but purpose-built to avoid needing external synchronization for common shared data structures:
-
ConcurrentHashMap— thread-safe map with fine-grained internal locking/CAS;computeIfAbsent,merge, etc. are atomic per-key. -
CopyOnWriteArrayList/CopyOnWriteArraySet— every mutation copies the underlying array; ideal for read-heavy, rarely-mutated lists (e.g., listener lists) since reads never block. -
ConcurrentLinkedQueue— lock-free unbounded FIFO queue.
ConcurrentHashMap<String, Integer> counts =
new ConcurrentHashMap<>();
counts.merge("key", 1, Integer::sum);
// Performs an atomic read-modify-write operation
// without requiring an external lock.
15. VarHandle (Java 9+)
Low-level API for fine-grained atomic and volatile access to individual fields or array elements — what libraries use to implement things like custom lock-free data structures, below even AtomicInteger in the stack.
private static final VarHandle COUNT;
static {
try {
COUNT = MethodHandles.lookup()
.findVarHandle(MyClass.class, "count", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
private volatile int count;
void increment() {
COUNT.getAndAdd(this, 1);
// Atomic increment without requiring an AtomicInteger field
}
Rarely needed in application code — mostly relevant when writing high-performance libraries.
14. Happens-Before: The Memory Visibility Layer
Coordination is not only about blocking. Java also defines happens-before relationships that determine when one thread is guaranteed to observe another thread's actions.
Important examples:
Thread.start()
actions before start()
↓
actions in started thread
Thread.join()
actions in terminated thread
↓
actions after successful join()
monitor unlock
↓
matching monitor lock
volatile write
↓
subsequent read of same volatile variable
Higher-level concurrency utilities also define memory-consistency effects. This is why synchronization APIs provide more than "making a thread wait".
Atomicity vs visibility
Keep these concepts separate:
volatile
-> visibility + ordering
AtomicInteger
-> atomic operations + visibility guarantees
synchronized
-> mutual exclusion + visibility + ordering
A variable can be visible to every thread and still be updated incorrectly if the operation itself is not atomic.
Common Mistakes
1. Using sleep() as synchronization
Thread.sleep(1000);
assumeOtherThreadFinished();
This is timing-based and unreliable. Prefer join(), a latch, a future, or another condition mechanism.
2. Using if instead of while around wait()
while (!ready) {
wait();
}
The condition must always be re-checked.
3. Forgetting unlock() on exceptional paths
lock.lock();
try {
update();
} finally {
lock.unlock();
}
4. Assuming volatile makes compound operations atomic
volatile int count;
count++; // still a race
5. Swallowing interruption
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
unless the method has a deliberate policy for handling the cancellation request.
6. Choosing low-level primitives too early
Prefer:
BlockingQueue
CountDownLatch
Semaphore
ExecutorService
CompletableFuture
when they directly express the problem.
Choosing the Right Tool
| Need | Reach for |
|---|---|
| Wait for a thread to finish | join() |
| Visible flag across threads, no atomicity needed | volatile |
| Simple mutual exclusion | synchronized |
| Mutual exclusion + timeouts/interruptibility/fairness | ReentrantLock |
| Multiple distinct wait conditions on one lock | Condition |
| Many readers, few writers |
ReentrantReadWriteLock / StampedLock
|
| Lock-free counters/flags/state transitions | Atomic classes / LongAdder
|
| One-time "wait for N events" gate | CountDownLatch |
| Reusable "wait for all threads at a checkpoint" | CyclicBarrier |
| Barrier with a dynamic, changing number of threads | Phaser |
| Limit concurrent access to a resource pool | Semaphore |
| Producer/consumer handoff | BlockingQueue |
| Managed thread pools + async task results |
ExecutorService / Future
|
| React to results as they finish, not in order | CompletionService |
| Delayed / recurring tasks | ScheduledExecutorService |
| Composable async pipelines | CompletableFuture |
| Recursive divide-and-conquer parallelism |
ForkJoinPool / RecursiveTask
|
| Building a custom synchronizer from scratch | LockSupport |
| One-to-one data swap between two threads | Exchanger |
| Per-thread state, no sharing at all |
ThreadLocal / ScopedValue
|
| Grouping subtasks as one cancellable unit |
StructuredTaskScope (preview) |
| Thread-safe shared map/list without manual locking |
ConcurrentHashMap / CopyOnWriteArrayList
|
Top comments (0)