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.
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");
}
}
}running on main
running on worker-1
a thread can be started only oncejoin() 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 |
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");
}
}blocked NEW
blocked BLOCKED
blocked TERMINATED
waiting WAITING
sleeping TIMED_WAITING
all doneThe 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.
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();
}
}flag after catch: false
flag after restore: true
spinner saw the flag and stoppedIf 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.
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");
}
}heartbeat daemon: true
main daemon: false
main ends, JVM exitsWithout 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).
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);
}
}unsafe <= 200000: true
safe = 200000unsafeCount 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.
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));
}
}holds lock: true
balance 10100
holds lock after: falseWithout 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.
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);
}
}took 1
took 2
took 3
took 4
took 5
sum 15Only 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 |
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);
}
}unordered: deadlocked threads = 2
ordered: balances 95 105In 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; callingrun()executes on the caller. A thread can be started once.- Six states:
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED. Blocking IO shows asRUNNABLE. - Interruption is cooperative; restore the flag or rethrow
InterruptedException, never swallow it. synchronizedgives mutual exclusion and visibility; intrinsic locks are reentrant.waitmust be in awhileloop because of spurious wakeups and competing threads; call it only while holding the monitor.- Prefer
notifyAllunless 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
joinwaits for termination;Thread.Statereports the current state.interruptsets a flag; blocking methods throwInterruptedExceptionand clear it.- Daemon threads do not keep the JVM alive and may stop at any point.
count++is not atomic;synchronizedon a shared lock fixes it.wait/notifyAllimplement condition waiting on an intrinsic lock.ThreadMXBean.findDeadlockedThreadsandjstackdetect deadlocks.
