Guide contents

Lesson 24 of 71

Threads, synchronized, wait and notify

A thread is an independent path of execution inside one process; all threads of a JVM share the heap. This lesson covers the low-level tools that every higher-level Java concurrency utility builds on: Thread, synchronized, and wait/notify. Later lessons cover locks and synchronizers and executors.

Topic API
Creating and running new Thread(Runnable, name), start, join
States Thread.State, getState
Cancellation interrupt, isInterrupted, InterruptedException
Mutual exclusion synchronized methods and blocks
Coordination Object.wait, notify, notifyAll
Diagnostics ThreadMXBean.findDeadlockedThreads

Example 1: start versus run

start() creates a new thread, which then calls run(). Calling run() directly is an ordinary method call on the current thread.

Java
public class StartVsRun {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> System.out.println("running on " + Thread.currentThread().getName());
        Thread worker = new Thread(task, "worker-1");

        worker.run();      // no new thread
        worker.start();    // new thread
        worker.join();     // wait for it to finish

        try {
            worker.start();
        } catch (IllegalThreadStateException e) {
            System.out.println("a thread can be started only once");
        }
    }
}
Output
running on main
running on worker-1
a thread can be started only once

join() blocks the caller until the target thread terminates, so the second line prints before the third. A Thread object cannot be restarted; a new task needs a new Thread or an executor.

Example 2: thread states

Thread.State has six values. A thread is in exactly one at any time.

State Meaning
NEW created, start not called
RUNNABLE running or ready to run (includes blocking IO in native code)
BLOCKED waiting to enter a synchronized block held by another thread
WAITING in wait(), join(), or LockSupport.park() without timeout
TIMED_WAITING in sleep, wait(ms), join(ms), or timed park
TERMINATED run has returned or thrown
Java
public class ThreadStates {
    static void awaitState(Thread t, Thread.State state) {
        while (t.getState() != state) {
            Thread.onSpinWait();
        }
        System.out.println(t.getName() + " " + state);
    }

    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();

        Thread blocked = new Thread(() -> { synchronized (lock) { } }, "blocked");
        System.out.println(blocked.getName() + " " + blocked.getState());
        synchronized (lock) {
            blocked.start();
            awaitState(blocked, Thread.State.BLOCKED);
        }
        blocked.join();
        System.out.println(blocked.getName() + " " + blocked.getState());

        Thread waiting = new Thread(() -> {
            synchronized (lock) {
                try { lock.wait(); } catch (InterruptedException e) { }
            }
        }, "waiting");
        waiting.start();
        awaitState(waiting, Thread.State.WAITING);

        Thread sleeping = new Thread(() -> {
            try { Thread.sleep(60_000); } catch (InterruptedException e) { }
        }, "sleeping");
        sleeping.start();
        awaitState(sleeping, Thread.State.TIMED_WAITING);

        synchronized (lock) { lock.notifyAll(); }
        sleeping.interrupt();
        waiting.join();
        sleeping.join();
        System.out.println("all done");
    }
}
Output
blocked NEW
blocked BLOCKED
blocked TERMINATED
waiting WAITING
sleeping TIMED_WAITING
all done

The main thread holds lock while it starts blocked, so that thread cannot enter its synchronized block and moves to BLOCKED. The spin loop waits until each state is reached, so the output does not depend on timing. A thread that blocks on a socket read shows RUNNABLE, because the JVM does not track waits inside native code.

Example 3: interruption

Interruption is a cooperative request to stop. t.interrupt() sets a flag on t. Blocking methods such as sleep, wait, and join respond by throwing InterruptedException and clearing the flag. Code that is not blocked must check the flag itself.

Java
public class Interruption {
    public static void main(String[] args) throws InterruptedException {
        Thread sleeper = new Thread(() -> {
            try {
                Thread.sleep(60_000);
            } catch (InterruptedException e) {
                System.out.println("flag after catch: " + Thread.currentThread().isInterrupted());
                Thread.currentThread().interrupt();     // restore the flag
                System.out.println("flag after restore: " + Thread.currentThread().isInterrupted());
            }
        });
        sleeper.start();
        sleeper.interrupt();
        sleeper.join();

        Thread spinner = new Thread(() -> {
            long iterations = 0;
            while (!Thread.currentThread().isInterrupted()) {
                iterations++;
            }
            System.out.println("spinner saw the flag and stopped");
        });
        spinner.start();
        spinner.interrupt();
        spinner.join();
    }
}
Output
flag after catch: false
flag after restore: true
spinner saw the flag and stopped

If interrupt() runs before the sleeper reaches sleep, sleep throws at once because the flag is already set, so the output is the same either way. Catching InterruptedException and ignoring it hides the stop request from callers, such as an executor shutting down. The two correct responses are to rethrow it or to restore the flag with Thread.currentThread().interrupt(). The static Thread.interrupted() reads and clears the flag; isInterrupted() only reads it.

Example 4: daemon threads

A daemon thread does not keep the JVM alive. The JVM exits when the last non-daemon thread ends, and daemon threads are stopped without running finally blocks.

Java
public class DaemonThreads {
    public static void main(String[] args) {
        Thread heartbeat = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(1_000);
                } catch (InterruptedException e) {
                    return;
                } finally {
                    // not guaranteed to run when the JVM exits
                }
            }
        }, "heartbeat");
        heartbeat.setDaemon(true);    // must be called before start()
        heartbeat.start();

        System.out.println("heartbeat daemon: " + heartbeat.isDaemon());
        System.out.println("main daemon: " + Thread.currentThread().isDaemon());
        System.out.println("main ends, JVM exits");
    }
}
Output
heartbeat daemon: true
main daemon: false
main ends, JVM exits

Without setDaemon(true) the program would never exit. A new thread inherits the daemon status of the thread that creates it. Daemon threads fit background housekeeping such as metrics flushing; they do not fit work that must complete, such as writing to a file, because the JVM can stop them mid-write.

Example 5: race conditions and synchronized

A race condition is a bug where the result depends on the timing of threads. count++ is three steps (read, add, write), so two threads can read the same value and one update is lost. The keyword synchronized makes a block mutually exclusive: only one thread at a time can hold the object's intrinsic lock (also called its monitor).

Java
public class RaceCondition {
    static int unsafeCount = 0;
    static int safeCount = 0;
    static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) {
                unsafeCount++;
                synchronized (LOCK) {
                    safeCount++;
                }
            }
        };
        Thread a = new Thread(work);
        Thread b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();

        System.out.println("unsafe <= 200000: " + (unsafeCount <= 200_000));
        System.out.println("safe = " + safeCount);
    }
}
Output
unsafe <= 200000: true
safe = 200000

unsafeCount usually ends below 200,000, but the exact value changes between runs, so the program prints only a fact that always holds. synchronized also gives visibility: changes made before releasing a lock are seen by the next thread that acquires it. join gives the same guarantee for the main thread reading the counters. Why that holds is the subject of Happens-Before and volatile.

Form Lock used
synchronized instance method this
static synchronized method the Class object
synchronized (obj) { } block obj

Example 6: reentrancy and lock biasing

Intrinsic locks are reentrant: a thread that holds a lock can acquire it again without blocking. The JVM counts acquisitions and releases the lock when the count returns to zero.

Java
public class Reentrancy {
    static class Account {
        private long balanceCents;

        synchronized void deposit(long cents) {
            balanceCents += cents;
        }

        synchronized void depositWithBonus(long cents) {
            deposit(cents);                    // re-acquires the lock on this
            deposit(cents / 100);
            System.out.println("holds lock: " + Thread.holdsLock(this));
        }

        synchronized long balance() {
            return balanceCents;
        }
    }

    public static void main(String[] args) {
        Account account = new Account();
        account.depositWithBonus(10_000);
        System.out.println("balance " + account.balance());
        System.out.println("holds lock after: " + Thread.holdsLock(account));
    }
}
Output
holds lock: true
balance 10100
holds lock after: false

Without reentrancy, depositWithBonus would deadlock on itself when it calls deposit. Reentrancy also lets a synchronized method in a subclass call super on a synchronized method.

HotSpot once used biased locking: a lock "biased" toward the first thread that took it could be re-acquired by that thread without an atomic instruction. Revoking the bias when another thread arrived required a safepoint and was expensive. JEP 374 disabled it by default and deprecated it in Java 15, and the implementation was removed in Java 18. On modern JVMs an uncontended synchronized uses a compare-and-swap on the object header (lightweight locking), and a contended one inflates into a heavyweight monitor that parks waiting threads.

Example 7: wait, notifyAll, and spurious wakeups

wait() releases the monitor and suspends the thread until another thread calls notify() or notifyAll() on the same object. Both must be called while holding that object's monitor, otherwise they throw IllegalMonitorStateException. A spurious wakeup is a return from wait() with no notify; the Java documentation permits it. For both reasons, wait() is always called in a loop that rechecks the condition.

Java
import java.util.ArrayDeque;
import java.util.Queue;

public class BoundedBuffer {
    static class Buffer<T> {
        private final Queue<T> items = new ArrayDeque<>();
        private final int capacity;

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

        synchronized void put(T item) throws InterruptedException {
            while (items.size() == capacity) {   // loop, not if
                wait();
            }
            items.add(item);
            notifyAll();
        }

        synchronized T take() throws InterruptedException {
            while (items.isEmpty()) {
                wait();
            }
            T item = items.remove();
            notifyAll();
            return item;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Buffer<Integer> buffer = new Buffer<>(2);

        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) buffer.put(i);
                buffer.put(-1);                    // end marker
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        int sum = 0;
        for (int v = buffer.take(); v != -1; v = buffer.take()) {
            sum += v;
            System.out.println("took " + v);
        }
        producer.join();
        System.out.println("sum " + sum);
    }
}
Output
took 1
took 2
took 3
took 4
took 5
sum 15

Only the consumer prints, and a single-producer queue keeps FIFO order, so the output is fixed. notifyAll wakes producers and consumers that wait on the same monitor; notify wakes one arbitrary thread, which may be a thread of the wrong kind, so the right thread stays asleep. notify is safe only when all waiters wait for the same condition and any one of them can proceed. A Condition per predicate, covered in Locks and Synchronizers, removes that problem.

Example 8: deadlock and lock ordering

A deadlock is a state where threads wait for each other forever. It needs all four Coffman conditions at once:

Condition Meaning How to break it
Mutual exclusion a resource is held by one thread use lock-free or immutable data
Hold and wait a thread holds one lock while waiting for another acquire all locks at once
No preemption locks cannot be taken away use timed tryLock and back off
Circular wait T1 waits for T2, T2 waits for T1 acquire locks in one global order
Java
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class Deadlock {
    static class Account {
        final int id;
        long balance;
        Account(int id, long balance) { this.id = id; this.balance = balance; }
    }

    static void transferUnsafe(Account from, Account to, long amount) {
        synchronized (from) {
            bothHoldFirstLock();
            synchronized (to) { from.balance -= amount; to.balance += amount; }
        }
    }

    static void transferOrdered(Account from, Account to, long amount) {
        Account first = from.id < to.id ? from : to;
        Account second = from.id < to.id ? to : from;
        synchronized (first) {
            synchronized (second) { from.balance -= amount; to.balance += amount; }
        }
    }

    static final Object gate = new Object();
    static int arrived = 0;

    // Waits until both threads hold their first lock, so the deadlock always happens.
    static void bothHoldFirstLock() {
        synchronized (gate) {
            arrived++;
            gate.notifyAll();
            try {
                while (arrived < 2) gate.wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    static Thread[] run(boolean ordered, Account a, Account b) {
        Thread t1 = new Thread(() -> { if (ordered) transferOrdered(a, b, 10); else transferUnsafe(a, b, 10); });
        Thread t2 = new Thread(() -> { if (ordered) transferOrdered(b, a, 5); else transferUnsafe(b, a, 5); });
        t1.setDaemon(true);
        t2.setDaemon(true);
        t1.start();
        t2.start();
        return new Thread[] {t1, t2};
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadMXBean mx = ManagementFactory.getThreadMXBean();

        Account a = new Account(1, 100), b = new Account(2, 100);
        run(false, a, b);
        long[] stuck;
        while ((stuck = mx.findDeadlockedThreads()) == null) Thread.sleep(50);
        System.out.println("unordered: deadlocked threads = " + stuck.length);

        Account c = new Account(3, 100), d = new Account(4, 100);
        for (Thread t : run(true, c, d)) t.join();
        System.out.println("ordered: balances " + c.balance + " " + d.balance);
    }
}
Output
unordered: deadlocked threads = 2
ordered: balances 95 105

In the unordered run, t1 locks a then waits for b, while t2 locks b then waits for a: a circular wait. bothHoldFirstLock is a small barrier built with wait/notifyAll: neither thread asks for its second lock until both hold their first one, so the deadlock happens on every run. The loop polls until the JVM reports it. The deadlocked threads are daemons, so the JVM still exits. The ordered version always locks the lower id first, which removes the circular wait. main joins both threads, so it prints the balances only after both transfers finish. jstack <pid> and jcmd <pid> Thread.print print a "Found one Java-level deadlock" section for the same situation.

Interview points

  • start() creates a thread; calling run() executes on the caller. A thread can be started once.
  • Six states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. Blocking IO shows as RUNNABLE.
  • Interruption is cooperative; restore the flag or rethrow InterruptedException, never swallow it.
  • synchronized gives mutual exclusion and visibility; intrinsic locks are reentrant.
  • wait must be in a while loop because of spurious wakeups and competing threads; call it only while holding the monitor.
  • Prefer notifyAll unless every waiter waits for the same condition.
  • Biased locking was disabled and deprecated in Java 15 (JEP 374) and removed in Java 18.
  • Deadlock needs mutual exclusion, hold and wait, no preemption, and circular wait; global lock ordering breaks the last one.

Summary

  • join waits for termination; Thread.State reports the current state.
  • interrupt sets a flag; blocking methods throw InterruptedException and clear it.
  • Daemon threads do not keep the JVM alive and may stop at any point.
  • count++ is not atomic; synchronized on a shared lock fixes it.
  • wait/notifyAll implement condition waiting on an intrinsic lock.
  • ThreadMXBean.findDeadlockedThreads and jstack detect deadlocks.