Threads, races, synchronized, and volatile
Why count++ loses updates, what synchronized guarantees beyond mutual exclusion, what volatile does and does not fix, and the check-then-act race.
After this lesson you can
- Start a thread correctly and wait for it with join
- Explain a lost update and a check-then-act race in terms of interleaving
- Say what synchronized guarantees (atomicity and visibility) and what volatile guarantees (visibility only)
- Name the happens-before edges that make one thread's writes visible to another
A Thread runs a Runnable concurrently with the thread that started
it. start() creates the new thread; calling run() directly is just
an ordinary method call on the current thread — a classic interview
trap. A thread can be started only once, and join() waits for it to
finish.
import java.util.*; public class Solution { public static List<String> describe() throws InterruptedException { List<String> seen = Collections.synchronizedList(new ArrayList<>()); Thread worker = new Thread( () -> seen.add("ran on " + Thread.currentThread().getName()), "worker"); worker.run(); // plain call, on the current thread worker.start(); // a real new thread worker.join(); // wait for it to finish try { worker.start(); } catch (IllegalThreadStateException e) { seen.add("second start(): IllegalThreadStateException"); } return seen; }}Races: when the interleaving decides the answer
count++ looks like one step but is three: read count, add one,
write it back. If two threads both read before either writes, both
write the same value and one increment is lost. Real races depend on
timing, so the example below forces the bad interleaving with a
CyclicBarrier, making the lost update happen every time:
import java.util.*;import java.util.concurrent.*;import java.util.concurrent.atomic.AtomicInteger; public class Solution { static final class Counter { int value; } static void await(CyclicBarrier barrier) { try { barrier.await(); } catch (Exception e) { throw new RuntimeException(e); } } static void runTwice(Runnable task) throws InterruptedException { Thread a = new Thread(task), b = new Thread(task); a.start(); b.start(); a.join(); b.join(); } public static String describe() throws InterruptedException { Counter counter = new Counter(); CyclicBarrier bothHaveRead = new CyclicBarrier(2); runTwice(() -> { int read = counter.value; // 1. read await(bothHaveRead); // both threads have now read 0 counter.value = read + 1; // 2. write }); AtomicInteger atomic = new AtomicInteger(); CyclicBarrier together = new CyclicBarrier(2); runTwice(() -> { await(together); atomic.incrementAndGet(); // read-add-write as one step }); return "plain counter after 2 increments=" + counter.value + ", AtomicInteger after 2 increments=" + atomic.get(); }}The same shape appears whenever code checks a condition and then
acts on it — if (balance >= amount) balance -= amount;,
if (!map.containsKey(k)) map.put(k, v);. Between the check and the
act another thread can invalidate what was checked. The exercise below
is exactly this race.
synchronized
A synchronized method or block acquires an object's intrinsic lock
(the monitor); only one thread can hold it at a time, so the whole block
runs as one indivisible step with respect to other code locking the
same object. Locks are reentrant: a thread that holds a lock can
enter another block guarded by it.
It also guarantees visibility. The Java Memory Model only promises
that a write by one thread is seen by another when there is a
happens-before edge between them. Releasing a lock happens-before
every later acquisition of the same lock, so everything written inside
one synchronized block is visible to the next thread that enters a
block on that lock. Other edges: Thread.start() happens-before
everything the new thread does; everything a thread does happens-before
another thread's successful join() on it; a write to a volatile
field happens-before every later read of it.
volatile is visibility, not atomicity
volatile guarantees that a read of the field sees the most recent
write to it, and that everything the writing thread did before that
write is visible to a thread that reads the new value — the right tool
for a stop flag read in a loop by another thread. Without it, that loop may never see
the flag change at all. It does not make count++ atomic: the read
and the write are each visible, but another thread can still slip in
between them. For a single counter use /
(lock-free compare-and-set); for an invariant spanning several fields
or a check-then-act, use a lock.
Exercise · Pro
Close the check-then-act race in withdraw
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade