DEV Community

Cover image for Java Thread Coordination
AnkitDevCode
AnkitDevCode

Posted on Edited on

Java Thread Coordination

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 check isAlive() if that matters.
  • Implemented internally via wait(), so it responds to interruption with InterruptedException.
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.");
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Thread.interrupt()

Sets a thread's interrupt flag; does not forcibly stop it.

  • If blocked in wait/sleep/join, the target wakes immediately with InterruptedException and 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
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Always guard wait() with a condition loop:

while (!condition) {
    wait();
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Conditionawait() / 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Future

Future<Integer> future = pool.submit(() -> compute(42));Integer result = future.get(5, TimeUnit.SECONDS); // blocks with timeout
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

CompletableFuture

Besides successful pipelines, learn its failure and cancellation operators:

future
    .exceptionally(ex -> fallbackValue())
    .whenComplete((value, ex) -> audit(value, ex));
Enter fullscreen mode Exit fullscreen mode

Use thenCompose when the next operation itself returns a CompletableFuture:

fetchUser()
    .thenCompose(user -> fetchOrders(user.id()));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

The condition must always be re-checked.

3. Forgetting unlock() on exceptional paths

lock.lock();
try {
    update();
} finally {
    lock.unlock();
}
Enter fullscreen mode Exit fullscreen mode

4. Assuming volatile makes compound operations atomic

volatile int count;
count++; // still a race
Enter fullscreen mode Exit fullscreen mode

5. Swallowing interruption

catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)