AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

17 Concurrency / Lesson 2 of 6

Shared state, races and synchronized

15 min · +10 XP · 2 exercises · 6 questions

Read the text15 min
Exercises0/2
Quiz6 q

Quick answer

What does synchronized do in Java?

The synchronized keyword in Java lets only one thread at a time run code guarded by the same object's intrinsic lock, or monitor. It gives atomicity — the block runs as one indivisible step relative to other blocks on that lock — and visibility, because everything written before the lock is released is seen by the next thread to acquire it. The lock is reentrant.

Two threads and one variable is all it takes. count++ looks atomic and is three operations — read, add, write — so two threads incrementing a shared counter a million times each end up with something less than two million, a different something each run. That is a race condition, and everything in this module is a way to avoid one. The oldest and still most common tool is synchronized: a block that only one thread at a time may execute, guarded by a lock that every object carries. This lesson is what a race is, what synchronized guarantees, how to use it without deadlocking, and the wait/notify protocol that lives on the same locks.

The anatomy of a race

class Counter { int count; void increment() { count++; } }
// two threads, 100 000 increments each → 200 000 expected; observed: 137 412, 158 990, …

Thread A reads count (5), thread B reads count (5), A writes 6, B writes 6: one increment is lost. The window is nanoseconds wide, which is why the bug appears only under load, only in production, only on Tuesdays. Any check-then-act (if (!map.containsKey(k)) map.put(k, v)) or read-modify-write (count++, balance -= amount, list.add on an ArrayList) on shared mutable state without synchronisation is a race. The fix is always one of: do not share, do not mutate, or coordinate access — and synchronized is coordination.

synchronized: mutual exclusion on an intrinsic lock

Every Java object has an intrinsic lock (monitor). A synchronized block acquires the lock of the object named, runs, and releases it — even if the body throws. While one thread holds it, any other thread reaching a block synchronised on the same object blocks.

class Counter {
    private int count;
    public synchronized void increment() { count++; }          // locks `this`
    public synchronized int get() { return count; }             // also locks `this`: reads need the lock too
}

class Registry {
    private final Object lock = new Object();                    // a private lock object
    private final Map<String, Integer> map = new HashMap<>();
    public void put(String k, int v) {
        synchronized (lock) { map.put(k, v); }
    }
}

A synchronized instance method locks this; a synchronized static method locks the Class object; a synchronized (obj) block locks whatever you name. Both writers and readers must synchronise on the same lock: a get() without the lock may see a stale or half-written value (lesson 3), so "only the writes need synchronising" is a bug.

Two guarantees come with the lock: atomicity (the block runs as one indivisible step relative to other blocks on the same lock) and visibility (everything written before a release is visible to whoever acquires the lock next). The lock is reentrant: a thread that holds it may acquire it again (a synchronised method calling another synchronised method on the same object) without deadlocking itself.

Choosing the lock

Prefer a private final lock object over this for classes others can see: anyone with a reference to your object can synchronized (yourObject) and interfere with, or deadlock, your internal locking. Never lock on a String literal (interned, shared with the whole JVM), a boxed primitive (cached), or any value-based class — you would be sharing a lock with strangers. Lock on one object per independent piece of state; lock on the same object for state that must change together.

Granularity: how much to lock

// too coarse: the network call holds the lock for milliseconds
synchronized (lock) { data = fetchFromNetwork(); cache.put(k, data); }
// right: compute outside, publish inside
Data d = fetchFromNetwork();
synchronized (lock) { cache.put(k, d); }

Hold locks for as short a time as possible and never while doing I/O, sleeping, or calling out to code you do not control (a listener might try to acquire another lock). A lock held for a long time serialises the whole program onto one thread; a lock held for a few instructions costs almost nothing when uncontended — modern JVMs take an uncontended synchronized in a few nanoseconds.

Deadlock, and the one rule that prevents it

// thread 1: synchronized (a) { synchronized (b) { … } }
// thread 2: synchronized (b) { synchronized (a) { … } }   → each holds one, waits for the other, forever

Deadlock needs two or more locks acquired in different orders. The rule: acquire locks in a global, consistent order — sort the objects by some key (account id, System.identityHashCode as a last resort) and always lock the smaller first. Or use one lock for both. Or use tryLock with a timeout (lesson 5). A thread dump shows a deadlock as two threads BLOCKED, each "waiting to lock" what the other "locked"; jstack even prints "Found one Java-level deadlock".

wait / notify: waiting for a condition

Mutual exclusion says "not at the same time"; sometimes a thread needs "not until something is true" — a consumer waiting for a non-empty queue. Object.wait() releases the lock and parks the thread; notify()/notifyAll() wakes waiters, who re-acquire the lock and continue. All three must be called while holding the monitor, or you get IllegalMonitorStateException.

synchronized (queue) {
    while (queue.isEmpty()) queue.wait();     // ALWAYS in a loop: spurious wake-ups and other consumers
    item = queue.remove();
}
synchronized (queue) { queue.add(x); queue.notifyAll(); }

Always while, never if: a thread can wake spuriously, or another consumer may have taken the item first. Prefer notifyAll unless you can prove one waiter suffices. And in practice prefer java.util.concurrent — a BlockingQueue, a CountDownLatch, a Condition — over hand-written wait/notify; the library versions are the same protocol without the mistakes.

What synchronized does not do

It does not make a sequence of synchronised calls atomic: if (map.get(k) == null) map.put(k, v) on a Collections.synchronizedMap is still a check-then-act race across two atomic calls — you need one block around both, or putIfAbsent. It does not help if some access bypasses it. It does not scale to many cores contending on one hot lock — for a counter, AtomicInteger or LongAdder (lesson 5) is faster. And it cannot be interrupted or time out — a thread waiting for a monitor waits until it gets it.

Interview angle

  • "What is a race condition?" Unsynchronised access to shared mutable state where the result depends on timing — count++ from two threads.
  • "What does synchronized guarantee?" Mutual exclusion on the monitor and visibility of writes at release/acquire; it is reentrant.
  • "Synchronised method versus block?" Method locks this (or the class for static); a block names the lock and can be narrower.
  • "How do you avoid deadlock?" Consistent lock ordering, one lock, or tryLock with timeout; never hold a lock while calling unknown code.
  • "Why while around wait()?" Spurious wake-ups and competing waiters; the condition must be rechecked.

Key takeaways

  • count++ is three steps; every read-modify-write or check-then-act on shared state is a race.
  • synchronized = one thread at a time on a monitor + visibility on release/acquire; readers lock too; reentrant.
  • Lock a private object, briefly, never around I/O or foreign calls; one lock per unit of related state.
  • Deadlock = inconsistent lock order; fix with ordering, a single lock, or tryLock.
  • wait in a while, notifyAll unless proven otherwise — and reach for java.util.concurrent first.

Common questions

What is a race condition in Java?

A race condition is unsynchronised access to shared mutable state where the result depends on thread timing. count++ is a read, an add and a write, so two threads incrementing one counter lose updates; every read-modify-write or check-then-act on shared state without coordination is a race.

What is the difference between a synchronized method and a synchronized block?

A synchronized instance method locks this, and a synchronized static method locks the Class object. A synchronized (obj) block names its lock and can cover only the lines that need it, which allows a private lock object and a shorter critical section.

How do you prevent deadlock in Java?

Acquire locks in one global, consistent order — for example, always the account with the smaller id first — or use a single lock for both pieces of state, or tryLock with a timeout. Never hold a lock while doing I/O or calling code you do not control.

Why must wait() be called in a while loop?

A waiting thread can wake spuriously, or another consumer may take the item first, so the condition must be checked again after every wake-up. wait, notify and notifyAll must also be called while holding the monitor, or they throw IllegalMonitorStateException.

Do getters need synchronized in Java?

Yes, when the field is shared and mutable: a read without the lock may see a stale value. Writers and readers must synchronise on the same lock for the visibility guarantee to hold, so locking only the writes is a bug.

Mark as readSign in to keep your place.

Java synchronized Explained: Race Conditions and Deadlock

Why count++ from two Java threads loses updates, how synchronized locks an object's monitor, how to avoid deadlock with lock ordering, and wait and notify.

  • Course: Java study plan
  • Module: Concurrency
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: OpenJDK 18

What does synchronized do in Java?

The synchronized keyword in Java lets only one thread at a time run code guarded by the same object's intrinsic lock, or monitor. It gives atomicity — the block runs as one indivisible step relative to other blocks on that lock — and visibility, because everything written before the lock is released is seen by the next thread to acquire it. The lock is reentrant.

Lesson

Two threads and one variable is all it takes. count++ looks atomic and is three operations — read, add, write — so two threads incrementing a shared counter a million times each end up with something less than two million, a different something each run. That is a race condition, and everything in this module is a way to avoid one. The oldest and still most common tool is synchronized: a block that only one thread at a time may execute, guarded by a lock that every object carries. This lesson is what a race is, what synchronized guarantees, how to use it without deadlocking, and the wait/notify protocol that lives on the same locks.

The anatomy of a race

class Counter { int count; void increment() { count++; } }
// two threads, 100 000 increments each → 200 000 expected; observed: 137 412, 158 990, …

Thread A reads count (5), thread B reads count (5), A writes 6, B writes 6: one increment is lost. The window is nanoseconds wide, which is why the bug appears only under load, only in production, only on Tuesdays. Any check-then-act (if (!map.containsKey(k)) map.put(k, v)) or read-modify-write (count++, balance -= amount, list.add on an ArrayList) on shared mutable state without synchronisation is a race. The fix is always one of: do not share, do not mutate, or coordinate access — and synchronized is coordination.

synchronized: mutual exclusion on an intrinsic lock

Every Java object has an intrinsic lock (monitor). A synchronized block acquires the lock of the object named, runs, and releases it — even if the body throws. While one thread holds it, any other thread reaching a block synchronised on the same object blocks.

class Counter {
    private int count;
    public synchronized void increment() { count++; }          // locks `this`
    public synchronized int get() { return count; }             // also locks `this`: reads need the lock too
}

class Registry {
    private final Object lock = new Object();                    // a private lock object
    private final Map<String, Integer> map = new HashMap<>();
    public void put(String k, int v) {
        synchronized (lock) { map.put(k, v); }
    }
}

A synchronized instance method locks this; a synchronized static method locks the Class object; a synchronized (obj) block locks whatever you name. Both writers and readers must synchronise on the same lock: a get() without the lock may see a stale or half-written value (lesson 3), so "only the writes need synchronising" is a bug.

Two guarantees come with the lock: atomicity (the block runs as one indivisible step relative to other blocks on the same lock) and visibility (everything written before a release is visible to whoever acquires the lock next). The lock is reentrant: a thread that holds it may acquire it again (a synchronised method calling another synchronised method on the same object) without deadlocking itself.

Choosing the lock

Prefer a private final lock object over this for classes others can see: anyone with a reference to your object can synchronized (yourObject) and interfere with, or deadlock, your internal locking. Never lock on a String literal (interned, shared with the whole JVM), a boxed primitive (cached), or any value-based class — you would be sharing a lock with strangers. Lock on one object per independent piece of state; lock on the same object for state that must change together.

Granularity: how much to lock

// too coarse: the network call holds the lock for milliseconds
synchronized (lock) { data = fetchFromNetwork(); cache.put(k, data); }
// right: compute outside, publish inside
Data d = fetchFromNetwork();
synchronized (lock) { cache.put(k, d); }

Hold locks for as short a time as possible and never while doing I/O, sleeping, or calling out to code you do not control (a listener might try to acquire another lock). A lock held for a long time serialises the whole program onto one thread; a lock held for a few instructions costs almost nothing when uncontended — modern JVMs take an uncontended synchronized in a few nanoseconds.

Deadlock, and the one rule that prevents it

// thread 1: synchronized (a) { synchronized (b) { … } }
// thread 2: synchronized (b) { synchronized (a) { … } }   → each holds one, waits for the other, forever

Deadlock needs two or more locks acquired in different orders. The rule: acquire locks in a global, consistent order — sort the objects by some key (account id, System.identityHashCode as a last resort) and always lock the smaller first. Or use one lock for both. Or use tryLock with a timeout (lesson 5). A thread dump shows a deadlock as two threads BLOCKED, each "waiting to lock" what the other "locked"; jstack even prints "Found one Java-level deadlock".

wait / notify: waiting for a condition

Mutual exclusion says "not at the same time"; sometimes a thread needs "not until something is true" — a consumer waiting for a non-empty queue. Object.wait() releases the lock and parks the thread; notify()/notifyAll() wakes waiters, who re-acquire the lock and continue. All three must be called while holding the monitor, or you get IllegalMonitorStateException.

synchronized (queue) {
    while (queue.isEmpty()) queue.wait();     // ALWAYS in a loop: spurious wake-ups and other consumers
    item = queue.remove();
}
synchronized (queue) { queue.add(x); queue.notifyAll(); }

Always while, never if: a thread can wake spuriously, or another consumer may have taken the item first. Prefer notifyAll unless you can prove one waiter suffices. And in practice prefer java.util.concurrent — a BlockingQueue, a CountDownLatch, a Condition — over hand-written wait/notify; the library versions are the same protocol without the mistakes.

What synchronized does not do

It does not make a sequence of synchronised calls atomic: if (map.get(k) == null) map.put(k, v) on a Collections.synchronizedMap is still a check-then-act race across two atomic calls — you need one block around both, or putIfAbsent. It does not help if some access bypasses it. It does not scale to many cores contending on one hot lock — for a counter, AtomicInteger or LongAdder (lesson 5) is faster. And it cannot be interrupted or time out — a thread waiting for a monitor waits until it gets it.

Interview angle

  • "What is a race condition?" Unsynchronised access to shared mutable state where the result depends on timing — count++ from two threads.
  • "What does synchronized guarantee?" Mutual exclusion on the monitor and visibility of writes at release/acquire; it is reentrant.
  • "Synchronised method versus block?" Method locks this (or the class for static); a block names the lock and can be narrower.
  • "How do you avoid deadlock?" Consistent lock ordering, one lock, or tryLock with timeout; never hold a lock while calling unknown code.
  • "Why while around wait()?" Spurious wake-ups and competing waiters; the condition must be rechecked.

Key takeaways

  • count++ is three steps; every read-modify-write or check-then-act on shared state is a race.
  • synchronized = one thread at a time on a monitor + visibility on release/acquire; readers lock too; reentrant.
  • Lock a private object, briefly, never around I/O or foreign calls; one lock per unit of related state.
  • Deadlock = inconsistent lock order; fix with ordering, a single lock, or tryLock.
  • wait in a while, notifyAll unless proven otherwise — and reach for java.util.concurrent first.

Common questions

What is a race condition in Java?

A race condition is unsynchronised access to shared mutable state where the result depends on thread timing. count++ is a read, an add and a write, so two threads incrementing one counter lose updates; every read-modify-write or check-then-act on shared state without coordination is a race.

What is the difference between a synchronized method and a synchronized block?

A synchronized instance method locks this, and a synchronized static method locks the Class object. A synchronized (obj) block names its lock and can cover only the lines that need it, which allows a private lock object and a shorter critical section.

How do you prevent deadlock in Java?

Acquire locks in one global, consistent order — for example, always the account with the smaller id first — or use a single lock for both pieces of state, or tryLock with a timeout. Never hold a lock while doing I/O or calling code you do not control.

Why must wait() be called in a while loop?

A waiting thread can wake spuriously, or another consumer may take the item first, so the condition must be checked again after every wake-up. wait, notify and notifyAll must also be called while holding the monitor, or they throw IllegalMonitorStateException.

Do getters need synchronized in Java?

Yes, when the field is shared and mutable: a read without the lock may see a stale value. Writers and readers must synchronise on the same lock for the visibility guarantee to hold, so locking only the writes is a bug.

Exercises

The counter, fixed

Read k (threads) and m (increments per thread). Share one Counter whose increment() and get() are both synchronized. Start k threads that each call increment() m times, join them, and print expected=<k×m> actual=<counter> match=<expected == actual>. Without the synchronized, actual would fall short on most runs; with it the program is correct every time.

Example: 4 1000 →

expected=4000 actual=4000 match=true

Transfers under one lock

Read an integer n and n opening balances, then an integer t and t transfers from to amount (0-based account indexes; balances may go negative). Run each transfer on its own thread, guarding the balances array with a single lock (synchronized on the bank object). Join all threads, then print balances=<Arrays.toString>, total=<sum> and preserved=<total equals the opening total>.

Example: 3 then 100 50 0, 3 then 0 1 30, 1 2 60, 2 0 10 →

balances=[80, 20, 50]
total=150
preserved=true

In this module: Concurrency

← Threads and Runnables — starting, joining, interrupting · Visibility, volatile and the Java Memory Model →