Back to Race Conditions
Q1Question 1 of 3

What is a race condition?

Key Concepts
  • A race condition occurs when program correctness depends on the timing of thread execution.
  • Check-then-act: the condition is checked in one step and acted on in another, with a gap where another thread can change state.
  • Read-modify-write: a value is read, modified, and written back as three separate operations, allowing interleaving.
  • counter++ is not atomic: it is three operations (read, increment, write) and is a classic read-modify-write race.
  • Fixes: synchronized block around the compound operation, AtomicInteger for single-variable atomicity, or ConcurrentHashMap.computeIfAbsent for collections.
  • Race conditions are non-deterministic: they may work 99% of the time and fail only under load, making them the hardest bugs to find.

Definition

A race condition occurs when correctness depends on thread timing, typically in check-then-act or read-modify-write patterns where a gap between operations allows another thread to interleave.
  • Race condition – a bug where the correctness of a program depends on the relative timing of thread execution. Two or more threads access shared mutable state, at least one writes, and there is no synchronization.
  • Check-then-act – if (balance >= amount) balance -= amount has a gap between the check and the act. Another thread can change the balance in that gap, causing an overdraft.
  • Read-modify-write – counter++ reads the current value, increments it, and writes it back. Two threads can read the same value, both increment to the same result, and one increment is lost.
  • Non-deterministic – race conditions may work correctly most of the time and fail only under specific timing, making them extremely difficult to reproduce and diagnose.

Teller-1 checks Rajesh's balance: Rs.100,000. Teller-2 also checks the balance: Rs.100,000. Both see enough for Rs.60,000. Teller-1 withdraws, leaving Rs.40,000. Teller-2 also withdraws, leaving -Rs.20,000. The check and the act were not atomic: another teller raced in between. The fix is to lock the account so the check and the withdrawal happen as one indivisible operation.

Understanding the Concepts

What is a race condition?

A race condition occurs when the outcome of a program depends on which thread executes first. The result is non-deterministic and may produce different (often wrong) values on different runs.

What is check-then-act?

The check-then-act pattern reads a condition and acts on it in separate steps. Another thread can change the condition between the check and the act.

JAVA
package com.bank.core;

import java.math.BigDecimal;

public class CheckThenActRace {
    private BigDecimal balance = new BigDecimal("100000.00");

    // WRONG: check and act are not atomic
    public void unsafeWithdraw(BigDecimal amount) {
        if (balance.compareTo(amount) >= 0) {     // CHECK
            // Another thread can change balance HERE!
            balance = balance.subtract(amount);     // ACT
        }
    }

    // CORRECT: check and act inside one synchronized block
    private final Object lock = new Object();
    public void safeWithdraw(BigDecimal amount) {
        synchronized (lock) {
            if (balance.compareTo(amount) >= 0) {  // CHECK
                balance = balance.subtract(amount); // ACT
            }                                       // Both atomic
        }
    }
}

What is read-modify-write?

counter++ looks like one operation but compiles to three: read the value, increment it, write it back.

JAVA
package com.bank.core;

import java.util.concurrent.atomic.AtomicInteger;

public class ReadModifyWriteRace {
    private int unsafeCounter = 0;
    private final AtomicInteger safeCounter = new AtomicInteger(0);

    // WRONG: counter++ is three operations
    public void unsafeIncrement() {
        unsafeCounter++; // read(0) -> increment -> write(1)
        // Another thread reads(0) in the gap -> writes(1) -> lost update!
    }

    // CORRECT: AtomicInteger uses CAS for atomic increment
    public void safeIncrement() {
        safeCounter.incrementAndGet(); // single CAS operation
    }

    public static void main(String[] args) throws InterruptedException {
        ReadModifyWriteRace demo = new ReadModifyWriteRace();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) demo.unsafeIncrement();
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) demo.unsafeIncrement();
        });
        t1.start(); t2.start();
        t1.join();  t2.join();
        System.out.println("Unsafe counter: " + demo.unsafeCounter);
        // Expected: 200000. Actual: less (e.g., 187432)
    }
}
TEXT
Unsafe counter: 187432

What about compound operations on collections?

The put-if-absent pattern on a HashMap is a check-then-act race.

JAVA
package com.bank.core;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class CollectionRace {
    // WRONG: check and put are separate operations
    public void unsafePutIfAbsent(Map<String, List<String>> map, String key) {
        if (!map.containsKey(key)) {
            // Another thread can put the key HERE!
            map.put(key, new ArrayList<>());
        }
    }

    // CORRECT: atomic compound operation
    public void safePutIfAbsent(ConcurrentHashMap<String, List<String>> map,
                                String key) {
        map.computeIfAbsent(key, k -> new ArrayList<>());
    }
}

How do you visualize thread interleaving?

TEXT
Thread-1              Thread-2             balance
---------             ---------            -------
read balance=100000                        100000
                      read balance=100000  100000
subtract 60000                             100000
write 40000                                40000
                      subtract 60000       40000
                      write -20000         -20000  OVERDRAFT!

What are the fixes?

| Pattern | Fix | When to use |
|---|---|---|
| Check-then-act | synchronized block | Multi-step compound operations |
| Read-modify-write | AtomicInteger.incrementAndGet() | Single-variable counters |
| Put-if-absent on map | ConcurrentHashMap.computeIfAbsent() | Collection operations |
| General compound | ReentrantLock with try-finally | When you need tryLock or fairness |

What should a senior engineer know?

  • Race conditions are non-deterministic: they may pass unit tests 100 times and fail in production under load. Use stress testing and tools like jcstress to detect them.
  • Happens-before: the Java Memory Model defines happens-before relationships that determine when one thread's writes are visible to another. Without a happens-before edge (synchronized, volatile, or thread join), there is no visibility guarantee.
  • Data races vs race conditions: a data race is a specific JMM term (unsynchronized access to a shared variable where at least one is a write). A race condition is a higher-level correctness problem. Not all race conditions involve data races (e.g., check-then-act with volatile still races).

Visual Overview

DIAGRAM
  Check-Then-Act Race:

  Thread-1              Thread-2           balance = 100,000
  ─────────             ─────────
  if (bal >= 60K) true
                        if (bal >= 60K) true   (stale!)
  bal -= 60K
  write 40,000                                40,000
                        bal -= 60K
                        write -20,000         -20,000 OVERDRAFT!

  Fix: synchronized(lock) { if (bal >= 60K) bal -= 60K; }
  Thread-2 sees 40,000, rejects withdrawal.

Key Takeaway

A race condition occurs when program correctness depends on thread timing. The two classic patterns are check-then-act (the condition changes between the check and the act) and read-modify-write (the value changes between the read and the write-back). Both produce non-deterministic bugs that may work correctly 99% of the time and fail only under load.

The fixes map to the patterns: synchronized for multi-step compound operations, AtomicInteger for single-variable counters, and ConcurrentHashMap.computeIfAbsent() for collection operations. The key insight is that any compound operation on shared mutable state must be made atomic.

Remember one sentence: a race condition is a gap between operations on shared state where another thread can interleave, and the fix is to make the compound operation atomic.

---

Share
Use ← → arrow keys to navigate