DevNotes
Support
Multithreading · Chapter 04

Race Conditions & synchronized

Shared mutable state, why count++ isn't atomic, critical sections, synchronized methods and blocks, object vs class locks, reentrancy, and the hidden race in Spring singleton beans.

◷ 9 min read§ 15 sections? 7 interview questions{ } 10 code examples

#Introduction

When two threads read and write the same mutable data without coordination, the result depends on the exact timing of their steps — a race condition. This chapter shows how races happen, why even count++ is unsafe, and how the synchronized keyword fixes them.

#A Race Condition in Action

Java
class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(counter.getCount());
    }
}
Expected 200000 — but typically
137492

The result changes from run to run and is almost never 200,000.

#Why count++ Is Not Atomic

count++ looks like one operation but is really three:

Read count from memory
Add 1
Write count back

When two threads interleave these steps, one update is lost:

StepThread AThread Bcount in memory
1reads 55
2reads 55
3adds 1 → 65
4adds 1 → 65
5writes 66
6writes 66 — one increment lost

This pattern is called read-modify-write. The other classic race pattern is check-then-act:

Java
// Check-then-act race: two threads can both see "not present" and both insert
if (!cache.containsKey(key)) {
    cache.put(key, loadValue(key));
}

#Key Terms

TermMeaning
Shared mutable stateData that more than one thread can see and that can change
Race conditionCorrectness depends on the relative timing of threads
Critical sectionCode that accesses shared state and must not run in two threads at once
AtomicAn operation that happens completely or not at all, with no visible intermediate state
Thread-safeA class behaves correctly when used from multiple threads without extra synchronization by callers
Mutual exclusionOnly one thread at a time may execute a critical section

#The synchronized Keyword

Every Java object has an intrinsic lock (also called a monitor). synchronized makes a thread acquire that lock before entering a block of code, and release it when leaving — giving mutual exclusion and memory visibility.

synchronized Methods

Java
class Counter {
    private int count = 0;

    public synchronized void increment() {   // locks on 'this'
        count++;
    }

    public synchronized int getCount() {     // reads must be synchronized too
        return count;
    }
}

Now the program always prints 200000.

Thread A acquires the Counter's lock
Thread B tries to enter — BLOCKED
Thread A increments and releases the lock
Thread B acquires the lock and increments

synchronized Blocks

Lock only the part that needs it, which keeps the critical section short:

Java
public void placeOrder(Order order) {
    validate(order);                 // no shared state — runs concurrently

    synchronized (this) {            // critical section
        inventory.reserve(order);
        orderCount++;
    }

    sendConfirmationEmail(order);    // slow I/O outside the lock
}

Using a Private Lock Object

Java
class Inventory {
    private final Object lock = new Object();
    private int stock;

    public void reserve(int qty) {
        synchronized (lock) {
            if (stock < qty) throw new IllegalStateException("Out of stock");
            stock -= qty;
        }
    }
}

A private final lock can't be locked by outside code, so no other class can accidentally (or maliciously) hold your lock and stall you. synchronized (this) and synchronized methods use a lock that any caller holding a reference can also grab.

#Object Locks vs Class Locks

Instance (object) lockClass lock
Declared bysynchronized instance method or synchronized (this)static synchronized method or synchronized (MyClass.class)
Lock objectThat particular instanceThe Class object — one per class
ProtectsInstance fieldsStatic fields
Two threads on different instancesRun concurrentlyBlock each other
Java
class IdGenerator {
    private static long nextId = 1;

    public static synchronized long next() {   // locks IdGenerator.class
        return nextId++;
    }
}

#Rules of synchronized

  • Only one thread at a time can hold a given object's lock; others block (BLOCKED state).
  • Threads synchronizing on different objects don't block each other — every thread must lock the same object to protect the same data.
  • The lock is released when the block exits normally or by an exception.
  • You can't synchronize on a primitive, and synchronized (null) throws NullPointerException.
  • Constructors can't be synchronized (and don't need to be — the object isn't shared yet, unless this escapes).

Don't Synchronize on These

Java
synchronized ("LOCK") { }          // ❌ string literals are interned and shared JVM-wide
synchronized (Integer.valueOf(1))  // ❌ boxed values are cached and shared
synchronized (count) { count++; }  // ❌ reassigning the field changes the lock object

#Reentrancy

Intrinsic locks are reentrant — a thread that already holds a lock can acquire it again:

Java
class Account {
    public synchronized void transfer(Account to, int amount) {
        withdraw(amount);    // also synchronized on 'this' — no self-deadlock
        to.deposit(amount);
    }

    public synchronized void withdraw(int amount) { ... }
    public synchronized void deposit(int amount) { ... }
}

The JVM keeps a hold count per lock; the lock is released only when the count returns to zero. Without reentrancy, a synchronized method calling another synchronized method on the same object would deadlock itself. (transfer locking two accounts can still deadlock with another thread — see the deadlock chapter.)

#Compound Actions Need One Lock

Making each method of a class thread-safe doesn't make sequences of calls atomic:

Java
List<String> list = Collections.synchronizedList(new ArrayList<>());

// ❌ Each call is synchronized, but the check-then-act sequence is not
if (!list.contains("x")) {
    list.add("x");
}

// ✅ Hold the same lock for the whole compound action
synchronized (list) {
    if (!list.contains("x")) {
        list.add("x");
    }
}

Iterating a synchronized collection also requires holding its lock for the entire loop.

#Race Conditions in Spring Applications

Spring beans are singletons by default — one instance shared by every request thread:

Java
@Service
public class ReportService {

    private int processedCount;                         // ❌ shared by all requests
    private final SimpleDateFormat format =
            new SimpleDateFormat("yyyy-MM-dd");         // ❌ not thread-safe

    public String generate(Report r) {
        processedCount++;                               // lost updates under load
        return format.format(r.getDate());              // corrupted output
    }
}

Fixes:

  • Keep services stateless — use local variables and method parameters.
  • Use thread-safe types: AtomicInteger for counters, DateTimeFormatter (immutable) instead of SimpleDateFormat.
  • For data shared across instances of your service (several pods), an in-memory lock is useless — use the database (row locks, optimistic locking with @Version) or a distributed lock.

#Cost of synchronized

Uncontended synchronized is cheap on modern JVMs. The cost is contention: when many threads want the same lock, they queue up and the critical section becomes a bottleneck. Keep critical sections short, never do I/O or remote calls inside them, and consider finer-grained locks, ReentrantReadWriteLock, atomics or concurrent collections for hot paths.

#Interview Questions

Q1What is a race condition?

A bug where the program's result depends on the timing or interleaving of threads accessing shared mutable state — for example, two threads incrementing a counter and losing an update.

Q2Why is count++ not thread-safe?

It's a read-modify-write of three steps (read, add, write). Two threads can read the same value and both write back the same result, losing an increment.

Q3What does synchronized guarantee?

Mutual exclusion — only one thread holding a given lock runs the critical section — and visibility: changes made before releasing a lock are visible to the next thread that acquires the same lock.

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

A synchronized instance method locks this for the whole method (a static one locks the class). A block lets you choose the lock object and protect only the lines that need it, which reduces contention.

Q5What is the difference between an object lock and a class lock?

An object lock belongs to one instance and protects instance state; a class lock belongs to the Class object and protects static state. They're independent locks.

Q6Are intrinsic locks reentrant?

Yes. A thread holding a lock can re-enter any code synchronized on the same lock; the JVM tracks a hold count.

Q7Can two threads execute two different synchronized methods of the same object at the same time?

No — both methods lock the same object (this). They can if one is static synchronized (class lock) and the other is an instance method, or if they're on different objects.

#Common Mistakes

  • Synchronizing only the writer and leaving reads unsynchronized.
  • Locking on different objects for the same data — e.g. synchronized (new Object()), which locks a fresh object every time.
  • Synchronizing on string literals, boxed integers or a field that gets reassigned.
  • Doing slow I/O or remote calls inside a synchronized block.
  • Assuming a thread-safe collection makes compound actions atomic.
  • Mutable fields in Spring singleton beans.

#Best Practices

  • Minimize shared mutable state: prefer local variables, immutable objects and stateless services.
  • Guard each piece of shared state with one clearly documented lock.
  • Keep critical sections as short as possible.
  • Prefer a private final lock object over synchronized (this) in library-style code.
  • Use java.util.concurrent (atomics, concurrent collections) instead of hand-written locking where it fits.

#Key Takeaways

  • A race condition happens when threads interleave on shared mutable state — typically read-modify-write or check-then-act.
  • count++ is three steps and isn't atomic.
  • synchronized provides mutual exclusion and visibility using an object's intrinsic lock.
  • Instance methods lock this, static methods lock the Class object — they're different locks.
  • Intrinsic locks are reentrant.
  • Compound actions need one lock around the whole sequence.