☕ Java

Inter-thread Communication

Inter-thread communication is the mechanism by which Java threads coordinate their activities — not just prevent interference, but actively signal each other about state changes. Mutual exclusion via synchronized prevents concurrent corruption, but many concurrent algorithms require that one thread pause until another has completed some work or produced a value. Java's built-in inter-thread communication is built on the monitor's wait/notify mechanism: a thread waiting for a condition calls wait() to release its lock and suspend; another thread that produces the awaited condition calls notify() or notifyAll() to wake the waiter. This entry covers the producer-consumer pattern as the canonical use case, the why and how of releasing the lock during the wait, the notify-then-proceed contract, spurious wakeups and why the while-loop guard is mandatory, the limitations of the built-in mechanism, and when to use higher-level alternatives (BlockingQueue, Condition, CountDownLatch, Semaphore).

The Producer-Consumer Problem and Why Polling Fails

The producer-consumer problem is the canonical use case for inter-thread communication: one thread (the producer) generates data items and places them in a shared buffer; another thread (the consumer) takes items from the buffer and processes them. The producer must wait when the buffer is full; the consumer must wait when the buffer is empty. Neither thread should spin-wait — repeatedly checking the condition in a tight loop — because that wastes CPU resources and may prevent the other thread from running. Spin-waiting (busy-waiting) is the naive approach: while (buffer.isEmpty()) { /* do nothing */ }. It has two problems. First, it consumes 100% of a CPU core doing nothing useful. Second, on a single-core machine or a heavily loaded system, it may prevent the producer from running at all, creating a livelock where the consumer loops forever and the producer never gets CPU time to fill the buffer. Even on multi-core machines, spin-waiting is wasteful and runs hot. The correct approach requires a way for a thread to suspend itself — relinquish the CPU and block — until a specific condition becomes true, and for another thread to wake it up when that condition changes. In Java, this is achieved through the wait/notify mechanism built into every object's monitor. The waiting thread calls wait(), which atomically releases the monitor lock and suspends the thread. The notifying thread calls notify() or notifyAll() after making the condition true, which wakes the waiting thread(s). The woken thread then reacquires the monitor and re-checks the condition. The combination of synchronized blocks, wait(), and notify() is Java's built-in condition variable system. It is more primitive than the java.util.concurrent.locks.Condition interface (which allows multiple distinct condition variables per lock and has other advantages), but it is fundamental to understanding how the higher-level abstractions work, and it is the mechanism underlying Object.wait/notify throughout the JDK.
Java
// ── Spin-wait: WRONG — burns CPU, may starve producer ─────────────────
public class SpinningConsumer {
    private final Queue<String> buffer;

    public SpinningConsumer(Queue<String> buffer) { this.buffer = buffer; }

    public String consume() {
        while (buffer.isEmpty()) {
            // Tight loop: 100% CPU usage, no progress — BAD
        }
        return buffer.poll();
    }
}

// ── Yielding: slightly better but still wrong ─────────────────────────
public String consumeWithYield() {
    while (buffer.isEmpty()) {
        Thread.yield();    // hints to OS to schedule other threads — not guaranteed
    }
    return buffer.poll();
}

// ── Sleeping: better but still wrong — arbitrary latency ─────────────
public String consumeWithSleep() throws InterruptedException {
    while (buffer.isEmpty()) {
        Thread.sleep(1);   // releases CPU but introduces up to 1ms latency
    }
    return buffer.poll();
}

// ── The correct solution: wait/notify ─────────────────────────────────
public class WaitNotifyBuffer<T> {
    private final Queue<T> queue = new LinkedList<>();
    private final int capacity;

    public WaitNotifyBuffer(int capacity) { this.capacity = capacity; }

    public synchronized void produce(T item) throws InterruptedException {
        while (queue.size() == capacity) {
            wait();               // buffer full: release lock, suspend thread
        }
        queue.add(item);
        notifyAll();             // wake any waiting consumers
    }

    public synchronized T consume() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();              // buffer empty: release lock, suspend thread
        }
        T item = queue.poll();
        notifyAll();            // wake any waiting producers
        return item;
    }
}

WaitNotifyBuffer<Integer> buf = new WaitNotifyBuffer<>(5);

Thread producer = new Thread(() -> {
    try {
        for (int i = 0; i < 20; i++) {
            buf.produce(i);
            System.out.println("Produced: " + i);
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

Thread consumer = new Thread(() -> {
    try {
        for (int i = 0; i < 20; i++) {
            int item = buf.consume();
            System.out.println("Consumed: " + item);
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

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

Higher-Level Alternatives and the java.util.concurrent Package

The built-in wait/notify mechanism is correct but low-level. It requires careful attention to spurious wakeups (requiring while loops), manual lock management, and has the limitation that a single monitor can express only one condition variable (wait/notify semantics do not allow waiting for "buffer not full" separately from "buffer not empty" on the same object — both go to the same wait set, so notify() may wake the wrong waiter, necessitating notifyAll() which wakes everyone). java.util.concurrent.locks.Condition, obtained from a ReentrantLock via lock.newCondition(), provides multiple named condition variables per lock. A ReentrantLock can have one Condition for "not empty" and another for "not full," allowing precise signaling: producers signal notFull.signal() to wake only a waiting producer, and consumers signal notEmpty.signal() to wake only a waiting consumer. This eliminates the thundering herd problem of notifyAll() and makes the code's intent explicit. BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue) encapsulates the producer-consumer pattern entirely. The put() method blocks when full; take() blocks when empty; all waiting and notification is handled internally. Using BlockingQueue instead of a manual wait/notify implementation is the recommended approach for producer-consumer in production code — it is well-tested, handles spurious wakeups correctly internally, and exposes a clean API. Other java.util.concurrent coordination primitives: CountDownLatch allows threads to wait until a count reaches zero — useful for "wait until N tasks complete." CyclicBarrier allows threads to synchronize at a meeting point — all threads wait until all have arrived. Semaphore limits the number of threads that can access a resource simultaneously — useful for connection pools and rate limiting. Phaser is a generalization of CyclicBarrier supporting dynamic registration. These cover the vast majority of inter-thread coordination scenarios more safely and clearly than raw wait/notify.
Java
// ── java.util.concurrent.locks.Condition — multiple conditions per lock ─
import java.util.concurrent.locks.*;

public class BoundedBuffer<T> {
    private final Queue<T> queue = new LinkedList<>();
    private final int capacity;
    private final ReentrantLock lock  = new ReentrantLock();
    private final Condition notFull   = lock.newCondition();  // producers wait here
    private final Condition notEmpty  = lock.newCondition();  // consumers wait here

    public BoundedBuffer(int capacity) { this.capacity = capacity; }

    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) notFull.await();  // only producers wake here
            queue.add(item);
            notEmpty.signal();   // wake ONE consumer — precise, no thundering herd
        } finally { lock.unlock(); }
    }

    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) notEmpty.await();   // only consumers wake here
            T item = queue.poll();
            notFull.signal();   // wake ONE producer
            return item;
        } finally { lock.unlock(); }
    }
}

// ── BlockingQueue — the recommended production solution ───────────────
import java.util.concurrent.*;

BlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<>(5);

Thread producer = new Thread(() -> {
    try {
        for (int i = 0; i < 20; i++) {
            blockingQueue.put(i);    // blocks automatically if full — no manual sync
            System.out.println("Put: " + i);
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

Thread consumer = new Thread(() -> {
    try {
        for (int i = 0; i < 20; i++) {
            int item = blockingQueue.take();  // blocks automatically if empty
            System.out.println("Took: " + item);
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});

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

// ── CountDownLatch — wait until N tasks complete ──────────────────────
int TASK_COUNT = 5;
CountDownLatch latch = new CountDownLatch(TASK_COUNT);
ExecutorService executor = Executors.newFixedThreadPool(TASK_COUNT);

for (int i = 0; i < TASK_COUNT; i++) {
    final int taskId = i;
    executor.submit(() -> {
        try {
            Thread.sleep(100 + (long)(Math.random() * 400));
            System.out.println("Task " + taskId + " complete");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            latch.countDown();   // decrement count — thread-safe
        }
    });
}

latch.await();   // main thread blocks until count reaches zero
System.out.println("All tasks complete");
executor.shutdown();

// ── CyclicBarrier — synchronize threads at a meeting point ────────────
int THREAD_COUNT = 3;
CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT, () ->
    System.out.println("All threads reached barrier — proceeding to phase 2"));

for (int i = 0; i < THREAD_COUNT; i++) {
    final int id = i;
    new Thread(() -> {
        try {
            System.out.println("Thread " + id + " doing phase 1 work");
            Thread.sleep((long)(Math.random() * 500));
            barrier.await();         // wait until all THREAD_COUNT threads arrive
            System.out.println("Thread " + id + " doing phase 2 work");
        } catch (InterruptedException | BrokenBarrierException e) {
            Thread.currentThread().interrupt();
        }
    }).start();
}

// ── Semaphore — limit concurrent access ───────────────────────────────
Semaphore semaphore = new Semaphore(3);  // at most 3 threads at a time

Runnable limitedTask = () -> {
    try {
        semaphore.acquire();   // blocks if 3 threads already inside
        try {
            System.out.println(Thread.currentThread().getName() + " accessing resource");
            Thread.sleep(200);
        } finally {
            semaphore.release();  // always release — finally block
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};

for (int i = 0; i < 10; i++) new Thread(limitedTask).start();

Related Topics in Multithreading

Process vs Thread
A process is an independent program in execution with its own isolated memory space, file handles, and system resources, managed by the operating system and separated from all other processes by strict boundaries. A thread is a unit of execution that lives inside a process, sharing that process's memory, heap, and resources with every other thread in the same process. Java programs run inside a JVM process; the JVM itself creates and manages threads, and every Java application starts with at least one thread — the main thread — with additional threads created by the JVM for garbage collection, JIT compilation, signal handling, and other runtime tasks. Understanding the distinction between processes and threads is the foundation for all concurrent programming in Java: it determines what is shared and what is isolated, what is fast and what is expensive, what fails independently and what fails together. This entry covers the OS-level and JVM-level model of processes and threads, the memory model that follows from the shared-versus-isolated distinction, the cost model for creation and context switching, failure isolation and its consequences, inter-process and inter-thread communication mechanisms, and the practical decision of when to use multiple processes versus multiple threads.
Thread Basics
A Java thread is an instance of java.lang.Thread that represents an independent path of execution within the JVM process. Every thread has a lifecycle — from creation through runnable, running, blocked, waiting, timed-waiting, and terminated states — and a set of properties including its name, priority, daemon status, thread group, and uncaught exception handler. The Java memory model specifies what visibility guarantees exist between threads and when writes by one thread are guaranteed to be visible to another. Thread scheduling is controlled by the OS scheduler subject to hints from the JVM via thread priority; the JVM does not provide real-time scheduling guarantees. This entry covers the complete thread lifecycle and its state machine, thread properties and how they affect scheduling and JVM shutdown, the happens-before relationship and why it matters for visibility, daemon threads and their relationship to JVM shutdown, thread interruption as a cooperative cancellation mechanism, and the methods on Thread that every Java developer must understand.
Creating Threads
Java provides three primary abstractions for defining the work a thread will execute: the Thread class itself (subclassed to override run()), the Runnable interface (a task with no return value and no checked exception), and the Callable interface (a task with a return value and a declared checked exception). Each represents a different contract between the task and the infrastructure that runs it. Thread subclassing couples the task definition to the execution mechanism and is the oldest and least flexible approach. Runnable decouples the task from the thread, allowing the same Runnable to be submitted to thread pools, scheduled executors, or wrapped in Thread objects. Callable extends that decoupling to include a return value and exception propagation, returning a Future that allows the caller to retrieve the result or handle exceptions asynchronously. Understanding all three — their contracts, their limitations, and when to use each — is the foundation of concurrent programming in Java before reaching for higher-level constructs.
Thread Lifecycle
The Java thread lifecycle is the complete sequence of states a thread passes through from the moment a Thread object is constructed to the moment its execution ends. Java defines six states in the Thread.State enum — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — and the JVM transitions threads between these states in response to specific method calls, lock acquisitions, monitor notifications, timeouts, and exceptions. Each state has a precise meaning, a defined set of entry conditions, and a defined set of exit conditions. Understanding the lifecycle in full is prerequisite knowledge for diagnosing deadlocks, thread leaks, performance bottlenecks in thread dumps, and incorrect synchronization — all of which manifest as threads stuck in specific states. This entry covers every state in the lifecycle with its entry and exit conditions, all legal and illegal state transitions, how thread dumps represent each state, the interaction between lifecycle states and interruption, the effect of uncaught exceptions on lifecycle, and how to observe lifecycle transitions programmatically.