Command Palette

Search for a command to run...

[Advanced Java] Thread Lifecycle, synchronized and Race Conditions

Earlier articles in this series described Vector, Hashtable and Collections.synchronizedList as documented contracts and said outright that nothing had been race-tested, because a race does not reproduce on demand. That was honest, and it left a hole. This article fills it: two threads, one int field, and a total that comes out wrong.

The subject is not an abstraction. count++ is three bytecode instructions, and the JVM is free to run another thread between any two of them. Everything else here — the intrinsic lock, volatile, AtomicInteger, wait/notify — exists to constrain that freedom, and each one constrains a different part of it.

Thread A and Thread B both running count++, expected 2,000,000 against an actual 1,708,088

Every listing below was compiled and run on OpenJDK 21.0.6 (arm64) on a machine reporting ten available processors. Where a number changes between runs, several runs are quoted and the variation is stated. You will not reproduce those numbers exactly, and that is the point of them.

The six values of Thread.State, and one program that visits all of them

Thread.getState() returns a Thread.State, an enum with exactly six constants. Printing them costs nothing:

Java
System.out.println(Arrays.toString(Thread.State.values()));
Text
[NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED]

A textbook state chart is easy to draw and easy to misremember. It is more useful to write a program that deliberately drives one thread through every state, and to have the main thread watch and print what it sees.

Java
public class ThreadStates {
    private static final Object lock = new Object();
    private static volatile boolean spin = true;
 
    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            while (spin) { }
            synchronized (lock) {
                try { lock.wait(); }
                catch (InterruptedException e) { return; }
            }
            try { Thread.sleep(300); }
            catch (InterruptedException e) { }
        }, "worker");
 
        print("before start()", worker);
        worker.start();
        Thread.sleep(100);
        print("in the spin loop", worker);
 
        synchronized (lock) {
            spin = false;
            Thread.sleep(200);
            print("main holds the monitor", worker);
        }
        Thread.sleep(200);
        print("inside lock.wait()", worker);
 
        synchronized (lock) { lock.notify(); }
        Thread.sleep(200);
        print("inside Thread.sleep(300)", worker);
 
        worker.join();
        print("after run() returned", worker);
    }
 
    private static void print(String when, Thread t) {
        System.out.printf("%-24s %s%n", when, t.getState());
    }
}

The main thread grabs lock before clearing spin, so the worker leaves its loop and runs straight into a monitor it cannot have. That is the only way to observe BLOCKED on purpose:

Text
before start()           NEW
in the spin loop         RUNNABLE
main holds the monitor   BLOCKED
inside lock.wait()       WAITING
inside Thread.sleep(300) TIMED_WAITING
after run() returned     TERMINATED

Three consecutive runs printed exactly those six lines. The transitions are forced by the structure of the program rather than left to chance, which is why this particular output is stable when almost nothing else in this article is.

The six Thread.State values with the call that enters and leaves each one

The THE MONITOR MEANWHILE row in that diagram is the part people get wrong most often. wait() releases the monitor before it parks — that is what makes the handoff possible at all. Thread.sleep() releases nothing: a sleeping thread that holds a lock keeps holding it for the whole nap.

Why RUNNABLE tells you less than you think

The Javadoc for Thread.State.RUNNABLE says it outright: "A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor." That single sentence covers two different situations — actually executing on a CPU right now, and sitting in the operating system's run queue waiting for a core — and nothing in the API distinguishes them, because the JVM does not schedule threads. The OS does, and the JVM does not ask.

Twenty threads spinning on a ten-core machine make the point:

Java
int n = 20;
Thread[] ts = new Thread[n];
long until = System.nanoTime() + 400_000_000L;
for (int i = 0; i < n; i++) {
    ts[i] = new Thread(() -> { while (System.nanoTime() < until) { } });
    ts[i].start();
}
Thread.sleep(150);
long runnable = Arrays.stream(ts).filter(t -> t.getState() == Thread.State.RUNNABLE).count();
Text
availableProcessors   = 10
threads started       = 20
reported RUNNABLE     = 20

Twenty RUNNABLE threads on ten cores means at least ten of them are not running. Three runs reported 20 every time. getState() is a diagnostic hint, never a synchronisation primitive: by the time the value reaches your if, it may already be stale.

Why count++ is not one step

Here is the smallest possible shared object.

Java
public class Counter {
    private int count = 0;
 
    public void increment() {
        count++;
    }
 
    public int get() {
        return count;
    }
}

count++ looks atomic because it is one token in the source. It is not one operation in the class file. javap -c Counter.class shows what the compiler actually emitted:

Text
  public void increment();
    Code:
       0: aload_0
       1: dup
       2: getfield      #7                  // Field count:I
       5: iconst_1
       6: iadd
       7: putfield      #7                  // Field count:I
      10: return

Read the field, add one on the operand stack, write the field back. Three separate steps against shared memory, and the thread scheduler may preempt between any two of them.

Two threads interleaving getfield, iadd and putfield so that one increment is lost

A race condition is exactly this: the result depends on the order in which threads reach shared state, and nothing in the program constrains that order.

Running it: a different wrong number every time

Two threads, a million increments each, so the answer must be 2,000,000.

Java
public class Race {
    public static void main(String[] args) throws Exception {
        Counter counter = new Counter();
        int rounds = 1_000_000;
 
        Thread a = new Thread(() -> { for (int i = 0; i < rounds; i++) counter.increment(); });
        Thread b = new Thread(() -> { for (int i = 0; i < rounds; i++) counter.increment(); });
 
        a.start();
        b.start();
        a.join();
        b.join();
 
        int expected = rounds * 2;
        System.out.println("expected = " + expected);
        System.out.println("actual   = " + counter.get());
        System.out.println("lost     = " + (expected - counter.get()));
    }
}

Seven consecutive runs of that exact program, with nothing changed between them:

Text
run 1   actual = 1708088   lost = 291912
run 2   actual = 1658395   lost = 341605
run 3   actual = 1669182   lost = 330818
run 4   actual = 1628574   lost = 371426
run 5   actual = 1648923   lost = 351077
run 6   actual = 1690288   lost = 309712
run 7   actual = 1704241   lost = 295759

Seven runs, seven different answers, none of them right. Your machine will print seven different answers too, and they will not be these. That is what makes the figure evidence rather than a measurement: the number is meaningless on its own, and the fact that it is never 2,000,000 is the whole result. Between 15% and 19% of the increments vanished across these runs, but that rate is a property of this machine on this afternoon, not of Java.

Why a small loop is not a test

Turn the loop count down from a million to a thousand and run it eight times:

Text
rounds 1000 x2 -> expected 2000, actual 1873
rounds 1000 x2 -> expected 2000, actual 1366
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000

Six of those eight runs produced the correct answer from code that is definitively broken. The first thread often finishes before the second one has really started, so there is no overlap to race in. A unit test written this way passes, gets committed, and proves nothing whatsoever — which is why concurrency bugs are found in production and not in CI.

⚠️ A green test run on concurrent code is evidence that this interleaving was fine, not that every interleaving is. Correctness here has to come from reasoning about the code, not from observing one execution of it.

Now make the operation atomic and change nothing else. Two versions, five runs each:

Java
public class SyncCounter {
    private int count = 0;
 
    public synchronized void increment() {
        count++;
    }
 
    public synchronized int get() {
        return count;
    }
}
Text
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000

Nothing lost, ten runs out of ten. Unlike the broken version, this output is not a sample — it is what the memory model guarantees, so a differing result would be a JVM bug rather than bad luck.

synchronized: the intrinsic lock

Every Java object has a monitor attached to it — an intrinsic lock, one per object, that exactly one thread can own at a time. synchronized is the only way to take it, and the JVM always gives it back: the lock is released when the block exits, whether that is by falling off the end, by return, or by an exception on the way out.

The intrinsic lock with an owner, an entry set of BLOCKED threads and a wait set of WAITING ones

Three things live around one monitor: the owner (with a hold count), the entry set of threads stopped trying to acquire it, and the wait set of threads that called wait() and gave it up voluntarily. The two sets are different, and thread dumps report them differently.

What javap shows: monitorenter versus ACC_SYNCHRONIZED

A synchronized block and a synchronized method look alike in source and are compiled completely differently. This class has both:

Java
public class Locks {
    private final Object lock = new Object();
    private int n;
 
    public void withBlock() {
        synchronized (lock) {
            n++;
        }
    }
 
    public synchronized void withMethod() {
        n++;
    }
}

javap -c Locks.class on the block version:

Text
  public void withBlock();
    Code:
       0: aload_0
       1: getfield      #7                  // Field lock:Ljava/lang/Object;
       4: dup
       5: astore_1
       6: monitorenter
       7: aload_0
       8: dup
       9: getfield      #13                 // Field n:I
      12: iconst_1
      13: iadd
      14: putfield      #13                 // Field n:I
      17: aload_1
      18: monitorexit
      19: goto          27
      22: astore_2
      23: aload_1
      24: monitorexit
      25: aload_2
      26: athrow
      27: return
    Exception table:
       from    to  target type
           7    19    22   any
          22    25    22   any

One monitorenter and two monitorexit instructions. The second exit at 24 is the exception path, wired up by that Exception table entry covering "any" throwable: this is the compiler making sure the lock is released even when the body throws.

The method version, from the same javap -c output, has no monitor instruction at all:

Text
  public synchronized void withMethod();
    Code:
       0: aload_0
       1: dup
       2: getfield      #13                 // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #13                 // Field n:I
      10: return

The bytecode is byte-for-byte what an unsynchronized method would be. The locking lives in the access flags instead, which javap -v reveals:

Text
  public void withBlock();
    flags: (0x0001) ACC_PUBLIC
 
  public synchronized void withMethod();
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
 
  public static synchronized void staticMethod();
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED

The JVM checks ACC_SYNCHRONIZED when it invokes the method and acquires the monitor itself, before the first instruction runs. The practical consequence: a synchronized method cannot narrow its critical section, because the lock is taken by the invocation, and it cannot choose its lock object either. A block can do both.

The lock belongs to an object, not to a method

Two threads calling the same synchronized method contend only if they are calling it on the same instance. This program starts two threads on one object, then two threads on two different objects:

Java
static class Vault {
    private final String name;
    Vault(String name) { this.name = name; }
 
    synchronized void enter(String who) throws InterruptedException {
        System.out.println(who + " is inside " + name);
        Thread.sleep(600);
    }
}
Text
-- same object --
t1 is inside vault A
t2 state: BLOCKED
t2 is inside vault A
-- different objects --
t3 is inside vault A
t4 is inside vault B
t4 state: TIMED_WAITING

On one object, t2 is BLOCKED and has to wait out t1's sleep. On two objects, t4 is TIMED_WAITING, which means it is inside enter already, sleeping — it never contended for anything. Across three runs, the two "is inside" lines of the second pair printed in either order, because they genuinely run at the same time; the states were the same every time.

An instance method locks this. A static synchronized method locks the Class object — Vault.class — which is a completely different monitor. A thread in a static synchronized method and a thread in an instance synchronized method of the same class do not exclude each other at all.

Reentrancy: the same thread can take the lock again

The intrinsic lock counts, rather than merely being held or not. A thread that already owns a monitor can enter it again; each entry increments a hold count and each exit decrements it, and the lock is released when it reaches zero.

Java
public class Reentrant {
    public synchronized void outer() {
        System.out.println("outer() holds the monitor");
        inner();
        System.out.println("outer() still holds it");
    }
 
    public synchronized void inner() {
        System.out.println("inner() acquired the same monitor again");
    }
}
Text
outer() holds the monitor
inner() acquired the same monitor again
outer() still holds it

Without reentrancy that program would hang forever on its own lock, and so would every synchronized method that calls another one on the same object — which includes most real classes. Note that this only protects a thread from itself. Two threads taking the same two locks in opposite orders still deadlock; that failure mode, and livelock alongside it, is the subject of a later article in this series.

Visibility is a different problem from atomicity

Atomicity is about an operation being indivisible. Visibility is about whether a write by one thread is ever seen by another. They are independent, and a program can fail either way.

Here is a pure visibility failure — a single write, a single read, no read-modify-write anywhere:

Java
public class StopFlag {
    private static boolean running = true;
 
    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            long spins = 0;
            while (running) {
                spins++;
            }
            System.out.println("worker saw the flag after " + spins + " spins");
        });
        worker.start();
 
        Thread.sleep(1000);
        running = false;
        System.out.println("main set running = false");
 
        worker.join(3000);
        System.out.println("worker still alive? " + worker.isAlive());
        System.out.println("worker state      : " + worker.getState());
        System.exit(0);
    }
}

This one does reproduce on this JVM, on every attempt:

Text
main set running = false
worker still alive? true
worker state      : RUNNABLE

Three runs out of three, the worker never noticed and the System.exit(0) at the end is the only reason the program terminated at all. Nothing was ever printed by the worker. The JIT compiler is entitled to hoist a non-volatile read out of a loop that does not write it, turning while (running) into if (running) while (true), and there is no rule in the memory model that says it must ever re-read the field.

This is a case where the behaviour is genuinely JVM- and platform-dependent. Under the interpreter, or with a synchronized block or a System.out.println in the loop body, the same code may well terminate — not because it became correct, but because an unrelated barrier made the stale read impossible on that run.

volatile fixes visibility and nothing else

Add one keyword:

Java
private static volatile boolean running = true;
Text
main set running = false
worker saw the flag after 3227396683 spins
worker still alive? false
worker saw the flag after 3245484210 spins
worker still alive? false
worker saw the flag after 3229623815 spins
worker still alive? false

Three runs, three terminations. The spin counts differ every run and mean nothing — they are just how far the loop got in a second.

A synchronized accessor pair fixes the same bug, because entering a monitor also forces a fresh read:

Java
private static synchronized boolean isRunning() { return running; }
private static synchronized void stop() { running = false; }
Text
worker saw the flag after 391776802 spins
worker still alive? false

What volatile does not give you is atomicity. Take the original broken counter, make the field volatile, change nothing else:

Java
private static volatile int count = 0;
Text
volatile counter: expected 2000000, actual 1712636
volatile counter: expected 2000000, actual 1358314
volatile counter: expected 2000000, actual 1412960
volatile counter: expected 2000000, actual 1454897
volatile counter: expected 2000000, actual 1330949

Five runs, five wrong answers. volatile guarantees that every read sees the latest write; it does nothing about two threads reading the same latest write and then both writing back the same increment. getfield, iadd and putfield are still three instructions.

The happens-before rules, stated plainly

Two rules cover almost everything in this article.

The monitor rule. An unlock of a monitor happens-before every subsequent lock of that same monitor. So everything a thread wrote before leaving a synchronized block is visible to the next thread that enters a block on the same object — all of it, not just the fields the block mentions.

The volatile rule. A write to a volatile field happens-before every subsequent read of that same field. Everything the writing thread did before that write is visible to a reader that observes it.

Both rules are conditional on the two threads using the same lock or the same field. Two threads synchronising on different objects establish nothing between them, which is why the sentence "the method is synchronized" is not by itself an answer to "is this thread-safe".

constructmutual exclusionvisibility
synchronizedyesyes
volatilenoyes
AtomicIntegerper operationyes
plain fieldnono

AtomicInteger and compare-and-swap

For a single variable, a lock is heavier machinery than the job requires. java.util.concurrent.atomic offers AtomicInteger, AtomicLong, AtomicBoolean and AtomicReference, which get atomicity from a hardware compare-and-swap instruction instead of a monitor.

Java
public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger();
 
    public void increment() {
        count.incrementAndGet();
    }
 
    public int get() {
        return count.get();
    }
}

The mechanism is a retry loop rather than a queue: read the current value, compute the new one, and swap it in only if the variable still holds the value that was read. If another thread got there first, the swap fails and the whole thing is attempted again. No thread is suspended to do that, so a thread retrying a compare-and-swap stays RUNNABLE — a thread waiting for a contended synchronized block is BLOCKED, as the entry-set demonstration above showed. That is the structural difference between the two, and it is a more useful thing to know than any timing number, which would depend entirely on how many threads are hammering the variable and on what hardware.

Two atomic operations are not one atomic operation

This is where atomics stop being a drop-in replacement for a lock. Every method on AtomicInteger is atomic; two of them in a row are not.

Java
AtomicInteger balance = new AtomicInteger(1000);
 
Runnable withdraw = () -> {
    for (int i = 0; i < 500; i++) {
        if (balance.get() >= 1) {        // atomic read
            balance.decrementAndGet();   // atomic write
        }
    }
};

Eight threads, 500 attempts each, withdrawing from a balance of 1000. The guard says the balance can never go below zero:

Text
balance after 4000 withdrawals from 1000 = -2
balance after 4000 withdrawals from 1000 = -7
balance after 4000 withdrawals from 1000 = -7
balance after 4000 withdrawals from 1000 = -5
balance after 4000 withdrawals from 1000 = -3

Five runs, five negative balances, and the exact figure differs each time — the overdraft size is a function of how many threads happened to be inside the gap between the read and the write. Nothing here is a bug in AtomicInteger; the check and the act are two atomic steps, and the state can change between them.

The fix is to make the whole decision one atomic step, which is what updateAndGet is for — it retries the function until the compare-and-swap succeeds:

Java
balance.updateAndGet(v -> v >= 1 ? v - 1 : v);
Text
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0

Five runs, floor of zero, never breached. The function passed to updateAndGet must be side-effect free, because it can be called more than once per successful update. When the invariant spans two variables rather than one, no atomic can express it and you are back to a lock.

wait, notify and notifyAll

wait(), notify() and notifyAll() are methods on Object, not on Thread, because they operate on that object's monitor. All three require the calling thread to already own that monitor. Calling one without the lock is not a warning or a no-op:

Java
Object lock = new Object();
lock.wait();
Text
Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.wait0(Native Method)
	at java.base/java.lang.Object.wait(Object.java:366)
	at java.base/java.lang.Object.wait(Object.java:339)
	at BadWait.main(BadWait.java:4)

notifyAll() gives the same message from a different frame:

Text
Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.notifyAll(Native Method)
	at BadNotify.main(BadNotify.java:4)

Inside a synchronized block, wait() does three things in order: it releases the monitor, it parks the thread in the object's wait set with state WAITING, and — once notified — it reacquires the monitor before returning. That last step matters and is where most bugs live.

wait must always sit inside a loop

Between the moment a waiter is notified and the moment it actually gets the monitor back, other threads run. The condition it was waiting for can be false again by then. On top of that, the specification permits spurious wakeups: a wait() may return without any notify() at all.

Both problems have the same one-line fix — re-check the condition in a loop — and one program demonstrates why. Two consumers wait on an empty queue; one item is added and notifyAll() wakes both.

Java
synchronized String take(String who) throws InterruptedException {
    if (queue.isEmpty()) wait();          // wrong
    // while (queue.isEmpty()) wait();    // right
    String item = queue.poll();
    System.out.println("  " + who + " took " + item);
    return item;
}
Text
with if:
  consumer-1 took job-1
  consumer-2 took null
  consumer-2 state: TERMINATED
with while:
  consumer-1 took job-1
  consumer-2 state: WAITING
  consumer-2 interrupted, still waiting

Three runs produced that output identically. With if, the second consumer resumed, never re-checked, and pulled null out of an empty queue — in real code that is a NullPointerException several frames away from the cause. With while, it re-checked, found the queue empty again and went back to waiting, which is correct.

Which consumer wins the item is not fixed; consumer-1 won in all three runs here, but the code must be correct for either.

notifyAll is the safe default

notify() wakes one arbitrary thread from the wait set. You do not choose which one, and the JVM does not guarantee it is the one whose condition is now satisfiable. If several threads wait on the same monitor for different conditions, notify() can wake the wrong one; that thread re-checks, sees nothing to do, and waits again — and the notification is gone, because the thread that could have made progress was never woken.

notifyAll() wakes all of them. Every one re-checks its condition in its while loop, at most one proceeds, and the rest go back to waiting. It does more work and cannot lose a notification. Use notify() only when every waiter is genuinely interchangeable and waiting for exactly the same condition, and even then the saving is rarely worth the reasoning.

ReentrantLock, when synchronized is not enough

java.util.concurrent.locks.ReentrantLock is the explicit equivalent of the intrinsic lock: same reentrant semantics, same happens-before guarantees, but as an object you call methods on. The cost is that release is now your responsibility, so the unlock() belongs in a finally block — with synchronized the compiler wrote that exception path for you, as the javap output above showed.

What it buys is the things a monitor cannot express: acquiring with a timeout, giving up immediately if the lock is taken, being interruptible while waiting, and an optional fairness policy.

Java
System.out.println("tryLock()                    : " + lock.tryLock());
System.out.println("tryLock(300ms)               : " + lock.tryLock(300, TimeUnit.MILLISECONDS));
System.out.println("isLocked / heldByCurrent     : " + lock.isLocked() + " / " + lock.isHeldByCurrentThread());

With another thread holding the lock for 1.5 seconds:

Text
tryLock()                    : false
tryLock(300ms)               : false
isLocked / heldByCurrent     : true / false
after the holder finished    : true

tryLock() returning false instead of blocking is the whole point — there is no way to write that with synchronized, where the only options are wait indefinitely or do not enter. ReentrantLock also brings Condition objects, which are wait/notify split into as many separate queues as you need. Reach for it when you need one of those capabilities; otherwise synchronized is shorter, harder to leak, and just as correct.

What not to synchronize on

A lock only works if everyone agrees which object it is. These three mistakes all break that agreement, and none of them looks wrong in review.

A boxed Integer or an interned String

Integer.valueOf caches the range −128 to 127 by default — Integer.valueOf(127) == Integer.valueOf(127) is true and the same comparison at 128 is false — so Integer lock = 1; in two unrelated classes gives you the same object:

Java
static class Cart {
    void checkout() throws InterruptedException {
        Integer lock = 1;                 // Integer.valueOf(1), from the cache
        synchronized (lock) {
            System.out.println("Cart holds the monitor");
            Thread.sleep(1500);
            System.out.println("Cart releases");
        }
    }
}
 
static class Report {
    void render() throws InterruptedException {
        Integer lock = 1;                 // unrelated class, same object
        synchronized (lock) {
            System.out.println("Report holds the monitor");
        }
    }
}
Text
Integer.valueOf(1) == Integer.valueOf(1) : true
Cart holds the monitor
report thread state: BLOCKED
Cart releases
Report holds the monitor

Two classes that share no code and know nothing about each other now serialise against one another, because they picked the same integer. javac flags this without being asked:

Text
BoxLock.java:5: warning: [synchronization] attempt to synchronize on an instance of a value-based class
        synchronized (lock) {
        ^
1 warning

String literals are worse, because they are interned by the JVM and shared across the entire application including every library in it. There is no compiler warning for this one:

Java
synchronized ("config") { ... }
Text
"config" == "config".intern() : true
library A holds the monitor on "config"
library B state: BLOCKED
library B finally got in

The rule is to lock on a private object that exists for no other purpose: private final Object lock = new Object();. It has no equals semantics, no cache, no interning, and nothing outside your class can reach it.

this, once the reference has leaked

A synchronized method locks this, and this is whatever the caller was handed. Any code holding a reference to your object can take your lock and hold it:

Java
Thread outsider = new Thread(() -> {
    synchronized (registry) {          // legal: the reference is public
        System.out.println("  outside code grabbed the Registry monitor");
        Thread.sleep(1200);
    }
});
Text
  outside code grabbed the Registry monitor
  user state: BLOCKED
  Registry.add(a) ran

The Registry class does nothing wrong and is still stalled for 1.2 seconds by code it has never heard of. Worse, this is invisible: nothing in Registry mentions the outsider, so the stall shows up as an unexplained BLOCKED in a thread dump. Locking on a private field makes the problem impossible rather than unlikely.

Never hold a lock across I/O

A lock should cover the shared-state update and nothing else. Holding one across a network call, a disk write or a database round trip means every other thread queues behind an operation whose duration you do not control.

Java
void slowInsideLock() {
    synchronized (lock) {
        sleep(1000);              // stands in for a network call
    }
}
 
void slowOutsideLock() {
    sleep(1000);                  // the slow call runs unlocked
    synchronized (lock) {
        sleep(1);                 // only the shared-state update is locked
    }
}

Six threads, sampled 400 ms in:

Text
slow call inside the lock : 5 of 6 threads BLOCKED
slow call outside the lock: 0 of 6 threads BLOCKED

Five runs gave that same pair of lines. The second arrangement runs six network calls concurrently and serialises only the tiny update at the end; the first runs them strictly one after another. The shape of the fix is always the same — do the slow work outside the lock, take the lock, apply the result, release it — and it usually means the merge step has to handle the state having changed while you were away.

FAQ

Why did my race condition not reproduce in a test?

Because a race needs overlap, and a short test rarely produces any. In the runs above, two threads doing a thousand increments each gave the right answer six times out of eight, from code that is definitively broken. Threads take time to start, so with a small workload the first one is often finished before the second one begins. Raising the iteration count, adding threads and running on more cores all make the window wider, but none of them turns absence of failure into evidence of correctness. Reason about the code; use the run only to confirm the bug you already found.

Does Thread.sleep release the lock it holds?

No. Thread.sleep puts the thread into TIMED_WAITING and changes nothing about the monitors it owns, so a thread that sleeps inside a synchronized block keeps every other thread out for the entire duration. Object.wait is the opposite: it releases the monitor before parking and reacquires it before returning. That distinction is the reason wait is a method on Object and sleep is a static method on Thread — one is part of the locking protocol and the other is not.

Can volatile make count++ thread-safe?

No, and the test above shows it: with count declared volatile, five runs of two threads doing a million increments each returned 1712636, 1358314, 1412960, 1454897 and 1330949 instead of 2000000. volatile guarantees that every read sees the most recent write, which fixes the stale-flag problem completely. It does nothing about two threads reading the same recent value and both writing back the same increment, because count++ is still getfield, iadd, putfield. Use synchronized or AtomicInteger for a counter, and volatile for a flag that one thread writes and others only read.

What is the difference between BLOCKED and WAITING in a thread dump?

BLOCKED means the thread is trying to acquire a monitor that another thread owns — it is at monitorenter and has no lock. WAITING means it called wait() (or join(), or LockSupport.park()), which released the monitor voluntarily, and it will not run again until something notifies it. The practical reading is different too: a pile of BLOCKED threads points at a hot lock, often one held across slow work; a pile of WAITING threads usually points at a missing or lost notification. TIMED_WAITING is the same as WAITING but with a deadline, and it will resume by itself.

Why does wait() throw IllegalMonitorStateException?

Because the calling thread does not own the monitor of the object it called wait() on. The message is exactly current thread is not owner. It happens most often when wait() is called outside any synchronized block, and second most often when the block synchronises on one object and wait() is called on another — synchronized (lockA) { lockB.wait(); } compiles cleanly and throws at run time. notify() and notifyAll() throw the same exception under the same conditions.

Is synchronized on the setter enough, or does the getter need it too?

The getter needs it too. The happens-before edge is created between an unlock and a subsequent lock of the same monitor, so a reader that never locks never gets the edge, and there is no rule requiring it to see the writer's value at all. An unsynchronized getter can also return a half-updated object when the setter writes several fields. This is the same mistake as omitting volatile on the stop flag, and it fails the same way: silently, and only on some JVMs and some hardware.

Is AtomicInteger always a better choice than synchronized?

No — they answer different questions. AtomicInteger makes one variable's updates atomic without ever suspending a thread, which is why a thread retrying a compare-and-swap stays RUNNABLE instead of landing in an entry set. But it cannot make two operations into one, as the negative balances above demonstrated, and it cannot cover an invariant that spans several fields. synchronized can protect any amount of state and any sequence of steps, at the price of threads queueing in the entry set. Pick the atomic for a single counter or flag, and the lock when a group of fields must move together.

Conclusion

A race condition is not exotic. It is count++ on a shared field, three bytecode instructions with a scheduler free to interleave them, and the proof is that seven identical runs of the same eight-line program printed seven different wrong totals. Every tool here narrows that freedom differently: synchronized gives mutual exclusion and visibility through one object's monitor, volatile gives visibility only, AtomicInteger gives atomicity for one variable and composes with nothing, and wait/notifyAll in a while loop coordinates threads that need to hand work to each other. Lock on a private field, keep the critical section down to the state update, and never conclude from a passing test that the interleaving you did not see would have been fine.

The next article moves from raw threads to the thing production code actually uses: ExecutorService and thread pools — submitting tasks instead of creating threads, the pool types the JDK gives you, Future, and how a pool shuts down.

Related Posts

[Advanced Java] Java NIO: Path, Files and Channels

Java NIO in depth on OpenJDK 21: path algebra with resolve, relativize, normalize and toRealPath, directory traversal with walk, find and walkFileTree, file attributes and symbolic links, WatchService, and the FileChannel and ByteBuffer model with position, limit, capacity, direct buffers, transferTo and memory-mapped files.

[Advanced Java] Best Practices, Performance and Interview Preparation

Java best practices proved by running code on OpenJDK 21: six performance traps counted in allocation bytes, equals() calls and SQL statements instead of milliseconds, why a nanoTime loop lies, a code review checklist mapped to the defect each question catches, and interview answers backed by real transcripts.

[Advanced Java] Threads in Java: Thread, Runnable and Virtual Threads

Threads in Java on OpenJDK 21: what a thread is, its own stack against the shared heap, creating one with Thread, Runnable and a lambda, start versus run, join, daemon threads, names and priorities, non-deterministic output, virtual threads with Thread.ofVirtual, and cooperative interruption.

[Advanced Java] Deadlock and Livelock in Java: Diagnosing and Preventing Them

Deadlock and livelock in Java on OpenJDK 21: the four Coffman conditions, a reproducible two-lock deadlock, a real jstack thread dump and ThreadMXBean.findDeadlockedThreads, prevention through global lock ordering and ReentrantLock.tryLock with back-off, thread pool deadlock, livelock, starvation and the cost of fair locks.