You are currently viewing Thread Communication in Java

Thread Communication in Java

This entry is part 4 of 4 in the series Concurrency (Threads)

Introduction

Once you have created multiple threads in your Java application, they need ways to talk to each other. A background thread loading data should be able to tell the main thread when it is done. A consumer thread should be able to sleep until a producer has something to offer. Threads that are spinning waiting for a condition should yield CPU time rather than wasting it. This coordination between threads is what thread communication in Java is all about.

Java provides several built-in mechanisms for this coordination. The simpler ones — Thread.sleep(), Thread.yield(), and Thread.join() — are methods on the Thread class itself. The more powerful one — the wait(), notify(), and notifyAll() trio — is built into every Java object and enables condition-based signalling between threads.

This article covers all of them in order of complexity, starting with sleep() and finishing with best practices for wait()/notify(). It builds on the article on thread synchronization in Java, which covers the synchronized keyword — a prerequisite for the wait()/notify() sections.

1. Thread.sleep(): Pausing for a Fixed Duration

Thread.sleep(long millis) suspends the current thread for at least the specified number of milliseconds. It is the simplest form of inter-thread timing: you use it whenever a thread needs to wait for a fixed period before doing its next unit of work — polling for a resource, pacing a producer, or simulating elapsed time in a test.

Two things are important to know about sleep():

  • It does not release any lock the thread currently holds. This is the key difference from wait(), which releases the monitor lock before suspending.
  • It throws InterruptedException if another thread interrupts the sleeping thread. Always handle this exception; never swallow it silently.
// Thread.sleep(): pause for a fixed duration without releasing any held lock
Thread worker = new Thread(() -> {
    System.out.println("Worker: starting task");
    try {
        Thread.sleep(200); // pause for 200 ms
    } catch (InterruptedException e) {
        // Restore interrupt status; do not swallow this exception
        Thread.currentThread().interrupt();
        System.out.println("Worker: sleep was interrupted");
        return;
    }
    System.out.println("Worker: task complete");
}, "sleeping-worker");

worker.start();
worker.join(); // wait for the worker to finish before continuing

Additionally, Thread.sleep(0) is a valid call that tells the JVM “I am willing to yield my current time slice.” In practice, however, Thread.yield() is the more explicit API for that purpose.

2. Thread.yield(): A Hint to the Scheduler

Thread.yield() is a hint — and nothing more — to the JVM thread scheduler that the current thread is willing to give up its CPU time slice so that other threads of equal or higher priority can run. Crucially, the JVM is free to ignore this hint entirely.

Unlike sleep(), yield() does not suspend the thread for any guaranteed duration. The thread remains RUNNABLE immediately after the call; it simply moves to the back of the scheduler’s ready queue.

// Thread.yield(): hint to the scheduler that this thread is willing to pause
Runnable task = () -> {
    for (int i = 1; i <= 3; i++) {
        System.out.println(Thread.currentThread().getName() + " - step " + i);
        Thread.yield(); // suggest that another thread may run now
    }
};

Thread t1 = new Thread(task, "thread-A");
Thread t2 = new Thread(task, "thread-B");

t1.start();
t2.start();
t1.join();
t2.join();

Because yield() is advisory, the interleaving of thread-A and thread-B output varies between runs. On a multi-core machine, both threads may execute entirely in parallel and yield() may have no visible effect at all.

As a result, yield() has very limited practical uses. It occasionally appears in tight CPU-bound loops where being a “good scheduling citizen” matters, and it appears in the implementation of spinlocks and LockSupport. For most application code, however, it is rarely the right tool. If you need to pause a thread, use sleep(); if you need to wait for a condition, use wait() or a BlockingQueue.

3. Thread.join(): Waiting for Another Thread to Finish

Thread.join() is one of the most commonly used inter-thread coordination tools. When thread A calls threadB.join(), thread A blocks — entering the WAITING state — until thread B reaches TERMINATED. (See Thread Lifecycle in Java for the full state diagram.) This is the correct way to wait for a background task to complete before using its results.

// Thread.join(): block until the target thread finishes
Thread dataLoader = new Thread(() -> {
    try {
        System.out.println("Loader: fetching data...");
        Thread.sleep(150); // simulate I/O or a slow computation
        System.out.println("Loader: data ready");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "data-loader");

dataLoader.start();
System.out.println("Main:   waiting for data loader...");
dataLoader.join(); // main thread blocks here until dataLoader reaches TERMINATED
System.out.println("Main:   loader finished - proceeding with loaded data");

The output always shows the loader’s messages before “loader finished”, regardless of how fast the scheduler runs — join() enforces that ordering guarantee.

3.1 Joining with a Timeout

Thread.join(long millis) adds a timeout: if the target thread has not finished within the given number of milliseconds, join() returns anyway. After the call, check thread.isAlive() to find out whether the timeout expired or the thread actually finished.

// Thread.join(timeout): wait at most 300 ms for the task to complete
Thread slowTask = new Thread(() -> {
    try {
        Thread.sleep(2000); // deliberately slow task
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "slow-task");

slowTask.start();
slowTask.join(300); // returns after 300 ms even if slowTask is still running

if (slowTask.isAlive()) {
    System.out.println("Main: slow task still running - state: " + slowTask.getState());
    slowTask.interrupt(); // cancel it so the JVM can exit cleanly
}
slowTask.join(); // wait for the interrupted thread to terminate

Using join(timeout) is the right pattern whenever you want to place an upper bound on how long you are willing to wait — for example, when implementing a graceful shutdown with a deadline.

4. Why Thread Communication Needs wait() and notify()

sleep() and join() cover many coordination needs, but neither lets a thread efficiently wait for an arbitrary condition — one that another thread will make true at some unknown point in the future. The alternatives are unattractive:

  • Busy-waiting (looping on a condition check) burns CPU and starves other threads.
  • Polling with sleep() wastes time when the condition becomes true between polls.

What you really want is for a thread to release its lock, sleep until the condition changes, and wake up exactly when signalled. That is precisely what wait() does — and why it exists as a distinct API from sleep().

5. The wait(), notify(), and notifyAll() Methods

These three methods are defined on java.lang.Object, which means every Java object can act as a monitor — combining a lock with a wait set. Their semantics are:

  • wait() — the calling thread releases the monitor lock, enters WAITING (or TIMED_WAITING with a timeout argument), and is added to the object’s wait set. It stays there until another thread calls notify() or notifyAll() on the same object.
  • notify() — moves one arbitrarily chosen thread from the wait set back to BLOCKED, where it competes to re-acquire the lock.
  • notifyAll() — moves every thread in the wait set to BLOCKED. Each competes for the lock; only one proceeds at a time.

There is one absolute rule: all three methods must be called from within a synchronized block on the same object. Calling them outside throws IllegalMonitorStateException at runtime.

The following snippet shows the simplest possible use: one thread waits for data, and another sets it and signals:

// Basic wait() / notify() handshake
class DataHolder {
    private String  data  = null;
    private boolean ready = false;

    public synchronized void setData(String value) {
        data  = value;
        ready = true;
        notify(); // wake the thread waiting on this object's monitor
    }

    public synchronized String waitForData() throws InterruptedException {
        while (!ready) { // re-check the condition after every wakeup
            wait();      // atomically releases the lock and suspends
        }
        return data;
    }
}

// Usage
DataHolder holder = new DataHolder();

Thread consumer = new Thread(() -> {
    try {
        String value = holder.waitForData();
        System.out.println("Received: " + value);
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "consumer-thread");

Thread producer = new Thread(() -> {
    try {
        Thread.sleep(50); // simulate preparation work
        holder.setData("Hello from producer");
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "producer-thread");

consumer.start(); // consumer starts first so it is already waiting
producer.start();
consumer.join();
producer.join();

Notice that wait() sits inside a while loop. This is not stylistic — Section 7 explains exactly why it is mandatory.

6. The Classic Example: A Bounded Producer-Consumer Queue

The producer-consumer pattern is the canonical application of wait()/notify(). A producer thread generates items and adds them to a shared queue; a consumer thread removes and processes them. A bounded queue adds the constraint that the producer must pause when the queue is full, preventing unbounded memory growth.

Both produce() and consume() are synchronized on the same object, so they share one monitor. Each calls notifyAll() after mutating the queue to wake any thread that may be blocked waiting for a change:

// Bounded producer-consumer queue using wait() and notifyAll()
class BoundedQueue {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity;

    BoundedQueue(int capacity) { this.capacity = capacity; }

    public synchronized void produce(int item) throws InterruptedException {
        while (queue.size() == capacity) {
            wait(); // full — release the lock and wait for a consumer
        }
        queue.add(item);
        System.out.printf("[%s] Produced: %d  (size=%d)%n",
                Thread.currentThread().getName(), item, queue.size());
        notifyAll(); // wake waiting consumers (and other waiting producers)
    }

    public synchronized int consume() throws InterruptedException {
        while (queue.isEmpty()) {
            wait(); // empty — release the lock and wait for a producer
        }
        int item = queue.poll();
        System.out.printf("[%s] Consumed: %d  (size=%d)%n",
                Thread.currentThread().getName(), item, queue.size());
        notifyAll(); // wake waiting producers (and other waiting consumers)
        return item;
    }
}

// Usage — slower consumer forces the producer to block when the queue fills up
BoundedQueue queue = new BoundedQueue(3);

Thread producer = new Thread(() -> {
    try { for (int i = 1; i <= 6; i++) { queue.produce(i); Thread.sleep(20); } }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "producer");

Thread consumer = new Thread(() -> {
    try { for (int i = 0; i < 6; i++) { queue.consume(); Thread.sleep(50); } }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "consumer");

producer.start(); consumer.start();
producer.join();  consumer.join();

Running this code, you will observe the producer filling the queue to capacity and then blocking — with the size hovering at 3 — until the slower consumer creates space. The backpressure works entirely through wait() and notifyAll(), with no polling.

7. Spurious Wakeups and the while Rule

Every example in this article wraps wait() in a while loop rather than an if statement. This section explains precisely why that is mandatory.

There are two reasons a waiting thread can return from wait() even though its condition is not yet true:

Spurious wakeups. The JVM specification explicitly permits a thread to return from wait() without having received a notify(). These are rare in practice, but the specification allows them, so every correct program must handle them.

Competing threads. When notifyAll() is used, every thread in the wait set wakes up. They compete for the lock and proceed one at a time. Each thread that acquires the lock must re-check the condition, because the thread that ran before it may have already consumed the resource they were both waiting for.

The MessageBox below makes this concrete. Two receivers both wait for a message. When the first message arrives, notifyAll() wakes both. One takes the message; the other, finding message == null, must loop back to wait(). Without the while, it would proceed with a null value — a silent, non-deterministic bug:

// MessageBox: single-slot store that demonstrates the while rule
class MessageBox {
    private String message = null;

    public synchronized void post(String msg) throws InterruptedException {
        while (message != null) { // occupied — wait until the current message is taken
            wait();
        }
        message = msg;
        System.out.println("Posted:   " + msg);
        notifyAll(); // wake all waiting receivers
    }

    public synchronized String take() throws InterruptedException {
        while (message == null) { // CORRECT: re-checks after every wakeup
            wait();               // with 'if' instead: a competing receiver proceeds
        }                         // with message == null after notifyAll() fires
        String taken = message;
        message = null;
        System.out.println("Taken:    " + taken);
        notifyAll();
        return taken;
    }
}

// Two receivers, two messages — each gets exactly one
// notifyAll() after "Message A" wakes both; one takes it; the other loops back
MessageBox box = new MessageBox();

Thread receiver1 = new Thread(() -> {
    try { System.out.println("[receiver-1] Got: " + box.take()); }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "receiver-1");

Thread receiver2 = new Thread(() -> {
    try { System.out.println("[receiver-2] Got: " + box.take()); }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "receiver-2");

Thread sender = new Thread(() -> {
    try {
        Thread.sleep(30);
        box.post("Message A"); // notifyAll() wakes both; only one takes it
        box.post("Message B"); // the other loops back in while() and takes this one
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "sender");

receiver1.start(); receiver2.start(); sender.start();
receiver1.join();  receiver2.join();  sender.join();

8. notify() vs notifyAll()

notify() wakes exactly one thread from the wait set, chosen by the JVM. This is efficient when every waiting thread is waiting for the same condition and any one of them can handle the signal. However, if threads waiting for different conditions share the same monitor, notify() may wake the wrong one. That thread re-checks its condition, finds it false, and goes back to sleep. Meanwhile, the thread that could have acted on the signal stays sleeping — and the system stalls indefinitely.

notifyAll() wakes every waiting thread. Each re-acquires the lock in turn, re-checks its condition, and either proceeds or returns to wait(). This causes more wakeups than strictly necessary, but it is always correct.

The BoundedQueue from Section 6 illustrates why notifyAll() is necessary here. Producers waiting for space and consumers waiting for items both sleep on the same monitor. If a consumer finishes and calls notify(), it might accidentally wake another consumer — which finds the queue empty and goes straight back to sleep — instead of a waiting producer. With two producers and two consumers, that failure mode is easy to trigger:

// 2 producers + 2 consumers on the same BoundedQueue (capacity 2)
// notifyAll() ensures that after each mutation, the right type of thread wakes up
BoundedQueue queue = new BoundedQueue(2);

Thread p1 = new Thread(() -> {
    try { for (int i = 1; i <= 3; i++) { queue.produce(i); Thread.sleep(10); } }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "producer-1");

Thread p2 = new Thread(() -> {
    try { for (int i = 4; i <= 6; i++) { queue.produce(i); Thread.sleep(10); } }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "producer-2");

Thread c1 = new Thread(() -> {
    try { for (int i = 0; i < 3; i++) { queue.consume(); Thread.sleep(40); } }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "consumer-1");

Thread c2 = new Thread(() -> {
    try { for (int i = 0; i < 3; i++) { queue.consume(); Thread.sleep(40); } }
    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "consumer-2");

p1.start(); p2.start(); c1.start(); c2.start();
p1.join();  p2.join();  c1.join();  c2.join();

The practical rule is simple: use notifyAll() by default. Switch to notify() only when you can prove that all waiting threads are identical — waiting for the same condition, and any one of them can respond to it.

9. Best Practices

Having covered all the mechanisms, here are the most important rules to follow when writing inter-thread communication code.

9.1 Always Call wait() in a while Loop

This is the single most important rule. Spurious wakeups and competing threads both require the condition to be re-checked after every return from wait(). Using if is always incorrect.

9.2 Prefer notifyAll() over notify()

Unless you can definitively prove that all waiting threads share the same condition and any one of them can handle the signal, use notifyAll(). The extra wakeups cost little; a stall is catastrophic and nearly impossible to reproduce in testing.

9.3 Use a Dedicated Private Lock Object

Synchronizing on this exposes your monitor to external code. External code that accidentally calls notify() on the same object will silently corrupt your wait logic. Use a private final Object lock = new Object() that no outside code can reach.

9.4 Handle InterruptedException Correctly

sleep(), join(), and wait() are all interruptible. Always restore the interrupt flag with Thread.currentThread().interrupt() if you catch the exception and cannot re-throw it. Swallowing it permanently disables cooperative cancellation for that thread.

9.5 Prefer java.util.concurrent for Production Code

wait()/notify() is low-level and requires getting every rule in this article right. For most production use cases, higher-level tools are safer and more expressive: BlockingQueue replaces the BoundedQueue pattern entirely; java.util.concurrent.locks.Condition supports multiple condition queues on one lock; CountDownLatch and CyclicBarrier handle common coordination patterns.

The following snippet combines practices 9.1, 9.2, and 9.3 in a minimal channel that sends messages one at a time:

// SafeChannel: private lock + notifyAll() + while loops on both sides
class SafeChannel {
    private final Object lock = new Object(); // private — no external code can interfere
    private String  message   = null;
    private boolean available = false;

    public void send(String msg) throws InterruptedException {
        synchronized (lock) {
            while (available) {
                lock.wait(); // wait until the previous message is consumed
            }
            message   = msg;
            available = true;
            System.out.println("Sent:     " + msg);
            lock.notifyAll();
        }
    }

    public String receive() throws InterruptedException {
        synchronized (lock) {
            while (!available) {
                lock.wait(); // wait until a message is available
            }
            String received = message;
            message   = null;
            available = false;
            lock.notifyAll();
            return received;
        }
    }
}

// Usage
SafeChannel channel = new SafeChannel();

Thread sender = new Thread(() -> {
    try {
        channel.send("Message 1");
        channel.send("Message 2");
        channel.send("Message 3");
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "sender");

Thread receiver = new Thread(() -> {
    try {
        for (int i = 0; i < 3; i++) {
            System.out.println("Received: " + channel.receive());
            Thread.sleep(30);
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "receiver");

receiver.start(); sender.start();
receiver.join();  sender.join();

Conclusion

Java gives you several tools for thread communication, each suited to a different situation. Thread.sleep() pauses a thread for a fixed duration without releasing any lock. Thread.yield() offers a non-binding hint to the scheduler that the current thread is willing to step aside. Thread.join() — with or without a timeout — lets one thread wait cleanly for another to finish.

For condition-based coordination, wait(), notify(), and notifyAll() provide the foundation. They let a thread release its lock and sleep until another thread changes a shared condition, eliminating busy-waiting. Three rules govern their correct use: always call wait() in a while loop, prefer notifyAll() over notify(), and always synchronize on a private lock object.

You can find the complete code of this article on GitHub

Concurrency (Threads)

Thread Synchronization in Java

Noel Kamphoa

Senior Software Engineer and Tech Lead with 14+ years of professional experience in backend development, system design, and enterprise software. Passionate about clean architecture, scalable Java applications, and helping developers grow through structured learning and real-world practice.