Command Palette

Search for a command to run...

PHASE 13Advanced ~19 min· topic 3 of 11

Topic 13.3

Race Conditions and synchronized

In one line

When two threads update shared data at the same time without coordination, updates get lost: a race condition. synchronized makes a block or method run by one thread at a time (mutual exclusion) and makes its changes visible to the next thread that takes the same lock.

Think of it like this

Two shopkeepers share one notebook that says how many mangoes are left. Both read "10", both sell one, both write "9". Two mangoes are gone but the notebook says one. That's a race condition. The fix is a rule: only the person holding the notebook's pen may read-and-write, and everyone else waits for the pen. The pen is the lock.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Race condition
A bug where the result depends on the unlucky timing of two or more threads.
Critical section
Code that touches shared data and must not run in two threads at once.
Atomic
Happening as one indivisible step that other threads can't see half-done.
Lock (monitor)
A token only one thread can hold at a time. Every Java object has one.
Mutual exclusion
Making sure only one thread at a time runs a piece of code.
Reentrant
A thread already holding a lock can take it again without blocking itself.
Check-then-act
Testing a condition and then acting on it. Unsafe if another thread can change things in between.

Step by step

01count++ is three steps

In bytecode, count++ on a field is getfield, iconst_1, iadd, putfield. Between the read and the write another thread can do the same, so both write the same new value.

With two threads each adding 100,000, the unprotected total is usually less than 200,000, and different every run.

count++ is three stepsdiagram
Rendering diagram…

02Guard it with a lock

Wrap the read-modify-write in synchronized (lock) { count++; }. Now thread B can't start its read until A has written and released the lock.

The lock must be the same object for every thread. Locking on new Object() created inside the method gives each call its own lock and protects nothing.

Counter.javawhole filejava
class Counter {
    private final Object lock = new Object();
    private int count;

    void increment() {
        synchronized (lock) {
            count++;
        }
    }

    int get() {
        synchronized (lock) {      // reads need the lock too, for visibility
            return count;
        }
    }
}

03Check-then-act needs one lock around both steps

if (balance >= amount) balance -= amount; with two threads withdrawing 80 from 100: both check (100 >= 80), both subtract, balance becomes -60. Making each line synchronized separately doesn't help; the check and the act must be inside one critical section.

This is why thread-safe classes offer compound operations like ConcurrentHashMap.putIfAbsent and merge: the check and the update happen atomically inside.

04synchronized methods and static methods

synchronized void deposit(int x) is shorthand for void deposit(int x) { synchronized (this) { ... } }. Simple, but anyone who has a reference to the object can also lock on it, and might do so for unrelated reasons. A private lock object avoids that.

static synchronized methods lock on Counter.class, a different lock from instance methods, so a static and an instance synchronized method don't exclude each other.

05What the JVM does with a lock

synchronized blocks compile to monitorenter and monitorexit bytecodes (with an extra monitorexit on the exception path, so the lock is always released). The object header holds the lock state.

Uncontended locks are cheap (a compare-and-swap); contended locks park waiting threads in the OS, which costs microseconds. That's why short critical sections and less sharing matter more than avoiding synchronized itself.

06Avoid sharing when you can

The fastest lock is no lock. Give each thread its own partial result and combine at the end (as in Topic 13.1's split sum), use immutable objects, or use AtomicInteger and concurrent collections for simple counters and maps (Topic 13.8).

Try it yourself

  1. 1

    Break the bank

    In "A thread-safe bank account", remove synchronized from withdraw and deposit and raise the loop to 100_000 with a starting balance of 1_000_000 (expected 600_000). On a multi-core JDK the balance comes out wrong and changes between runs.

  2. 2

    Lock the wrong object

    In "Lost updates without a lock", replace synchronized (lock) with synchronized (new Object()). Each call now has its own lock, so safe loses updates too (on a multi-core JDK).

Code & diagrams

Lost updates without a lock New tab

Print `unsafe` on a multi-core JDK and it's usually below 200000 and different each run. The program only prints facts that are always true, so the expected output is fixed.

Sign in to run this example in your browser.

Expected output

safe counter: 200000
unsafe counter is at most 200000: true
A thread-safe bank account New tab

400 rounds of withdraw 3, deposit 2: a net loss of 400, always, because every update is protected.

Sign in to run this example in your browser.

Expected output

balance: 600
Reentrancy: a synchronized method calling another New tab
Sign in to run this example in your browser.

Expected output

hello
hello

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Synchronize only half of a check-then-act

In withdraw, check balance < amount outside any lock and only put balance -= amount in a synchronized block.

terminal
$ run with many threads on a multi-core machine
── what you'll see ──
balance: -3 (sometimes; the bug appears only under load)

Myth vs fact

Myth

count++ is atomic.

Fact

It's a read, an add and a write. Use synchronized or AtomicInteger.

Myth

Only writes need the lock.

Fact

Reads need the same lock (or volatile) too, otherwise a thread can see stale values.

Myth

If the tests pass, there's no race.

Fact

Races depend on timing and core count. They often appear only under production load.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    HotSpot's biased locking was disabled by default in Java 15 and removed later (JEP 374); today an uncontended synchronized is a lightweight lock using a compare-and-swap, inflating to a full OS-backed monitor only under contention.

  • ▸

    Before Java 24, a virtual thread blocked inside synchronized pinned its carrier thread; JEP 491 (Java 24) removed most pinning, which makes synchronized fine again in virtual-thread code (Topic 13.10).

  • ▸

    Lock coarsening and lock elision (via escape analysis) let the JIT merge adjacent locks or drop locks on objects that never escape a thread, which is why StringBuffer in a local variable can cost no more than StringBuilder.

Remember this

  1. 1

    count++ looks like one step but is three: read count, add one, write it back. Two threads can interleave these steps (both read 10, both write 11), so increments are lost. Any check-then-act ("if balance >= amount then withdraw") or read-modify-write on shared data has the same problem.

  2. 2

    A race condition happens when the result depends on the timing of threads. It's nondeterministic: the program may pass every test and fail in production once a day. The cure is to make the critical steps atomic (indivisible) with respect to other threads.

  3. 3

    **synchronized (lock) { ... } lets only one thread at a time run any block guarded by the same lock object. A thread that arrives while another holds the lock waits in the BLOCKED state. Every object has a built-in lock (its monitor**), so any object can be used, but use a private final one: private final Object lock = new Object();.

  4. 4

    A **synchronized method** uses this as the lock (a static synchronized method uses the Class object). Locks are reentrant: a thread that already holds a lock can enter another block on the same lock (one synchronized method calling another doesn't deadlock itself).

  5. 5

    synchronized also guarantees visibility: releasing a lock happens-before the next acquire of the same lock, so the next thread sees everything written inside (Topic 13.4). Without it, a thread may keep reading a stale cached value.

  6. 6

    Lock only what must be atomic, and keep the critical section short. Holding a lock while doing slow I/O makes other threads queue. Every access to shared mutable state, reads included, must use the same lock. Alternatives: immutable data (nothing to race on), confinement (each thread has its own data, then combine), atomics and concurrent collections (Topic 13.8).

Explain it without notes

01

Why does count++ lose updates when two threads run it, and how do you fix it?

02

What two guarantees does synchronized give?

03

Why is a private final lock object better than synchronizing on this?

Practice

01

Make a thread-safe counter class with increment and get, and use four threads adding 1000 each.

02

Explain why if (!list.contains(x)) list.add(x); on a Collections.synchronizedList is still unsafe, and fix it.

Trade-offs

  • ↔

    synchronized is simple and always releases the lock, but it can't time out or be interrupted while waiting; ReentrantLock can (Topic 13.5).

  • ↔

    Coarse locks (one lock for everything) are easy to get right but limit parallelism; fine-grained locks scale better but risk deadlock (Topic 13.9).

Done when you can

  • Done when you can explain why count++ and check-then-act are unsafe across threads.

  • Done when you guard every access to shared mutable state with the same lock.

  • Done when you can explain mutual exclusion, visibility and reentrancy.

  • Done when you prefer private lock objects and short critical sections.