🧵 Section 9 · Question #20

What is race condition

A Race Condition is a critical concurrency defect where the correctness, state consistency, or computation result of a program becomes dependent on the uncontrollable, non-deter...


🟢 Junior Level

30-Second Summary

A Race Condition is a critical concurrency defect where the correctness, state consistency, or computation result of a program becomes dependent on the uncontrollable, non-deterministic order of execution and thread interleaving managed by the operating system CPU scheduler.

Unlike a Deadlock, which halts thread execution entirely (0% CPU), a program affected by a Race Condition continues executing outwardly normally, but silently corrupts internal state or produces incorrect numerical results (Silent Data Corruption).

Race Conditions typically fall into three archetypes:

  1. Read-Modify-Write: Reading a shared variable, modifying its local copy, and writing it back (e.g., count++).
  2. Check-Then-Act: Evaluating a condition and performing an action based on stale state that was mutated by another thread immediately after the check (if (condition) act();).
  3. Unsafe Publication: Exposing an object reference to another thread before its constructor finishes memory initialization.

Why count++ is Not Thread-Safe

In Java syntax, count++ looks like an atomic primitive operation, but at the bytecode and CPU assembler level, it compiles into three distinct instructions:

  1. read: Read current variable value from memory into a CPU register.
  2. modify: Increment the register value by 1.
  3. write: Write the register value back to main memory.
Thread 1:  [ Reads 0 ] ───> [ Increments to 1 ] ───────────────────> [ Writes 1 ]
Thread 2:       [ Reads 0 ] ───────────────────> [ Increments to 1 ] ───> [ Writes 1 (OVERWRITE!) ]

After two concurrent increment operations, the counter value is 1 instead of 2. An update was permanently lost (Lost Update).


Idiomatic Java 21 Demonstration

public class RaceConditionDemo {
    private int counter = 0;

    // HAZARDOUS: Read-Modify-Write without synchronization
    public void increment() {
        counter++;
    }

    public int getCounter() {
        return counter;
    }

    public static void main(String[] args) throws InterruptedException {
        RaceConditionDemo demo = new RaceConditionDemo();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 10_000; i++) demo.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 10_000; i++) demo.increment(); });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        // Expected 20,000, but will output an arbitrary corrupted value (e.g., 14,352)
        System.out.println("Final counter value: " + demo.getCounter());
    }
}

🟡 Middle Level

The Check-Then-Act Concurrency Trap

The second most prevalent concurrency pitfall involves making decisions on state that becomes stale in the very next CPU cycle:

// CLASSIC ANTI-PATTERN: Check-Then-Act
public class UnsafeAccountService {
    private int balance = 100;

    public void withdraw(int amount) {
        // 1. CHECK: verify balance
        if (balance >= amount) {
            // At this precise moment, thread context may switch!
            // Another thread executes withdraw() and empties the account!
            
            // 2. ACT: execute deduction based on expired check
            balance -= amount; // Negative balance invariant violation!
        }
    }
}

Check-Then-Act in Concurrent Collections

Wrapping a standard map with ConcurrentHashMap does not protect compound business logic:

// ERROR: Race Condition remains present even with ConcurrentHashMap!
if (!concurrentMap.containsKey(key)) { // CHECK
    concurrentMap.put(key, new ExpensiveService()); // ACT: Instantiates service twice!
}

// CORRECT: Leverage atomic compound methods
concurrentMap.computeIfAbsent(key, k -> new ExpensiveService());

Why volatile Does NOT Prevent Race Conditions

Junior developers frequently attempt to fix shared counters by adding volatile:

private volatile int count = 0;
public void increment() { count++; } // STILL SUFFERS FROM A RACE CONDITION!
  • The volatile modifier addresses memory visibility (ensuring reads bypass CPU L1/L2 caches to main memory) and prevents instruction reordering.
  • It provides zero mutual exclusion or atomicity for compound operations. count++ still compiles into separate getfield, iadd, and putfield bytecodes. Another thread can interleave between the read and write stages.

Heisenbugs: The Diagnostic Challenge

Race conditions are quintessential Heisenbugs (named after the Heisenberg uncertainty principle):

  • They cannot be reproduced inside an interactive debugger, because breakpoints and thread stepping artificially serialize timing windows.
  • They may remain undetected for months on a developer’s 4-core workstation, yet trigger immediate catastrophic data corruption when deployed to 64-core enterprise production servers under high load.

🔴 Senior Level

Data Race vs. Race Condition: The Theoretical Distinction

On Senior and Principal engineering interviews, interviewers expect precision when distinguishing Data Race from Race Condition:

+--------------------------------------------------------------------------+
| Data Race (JMM Memory Model Violation)                                   |
| Low-level memory access defect:                                          |
| At least two concurrent threads access the exact same memory location,   |
| at least one access is a write, and there is no Happens-Before edge      |
| ordering the accesses.                                                   |
+--------------------------------------------------------------------------+
                                     vs
+--------------------------------------------------------------------------+
| Race Condition (Business Invariant Violation)                            |
| High-level algorithmic flaw:                                             |
| Non-deterministic program outcome caused by unpredictable thread         |
| execution timing (can manifest even when zero low-level Data Races       |
| exist!).                                                                 |
+--------------------------------------------------------------------------+

Example: Race Condition WITHOUT a Data Race

// Every individual method call is 100% thread-safe (Zero Data Races, perfect JMM compliance)
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();

public void buyProduct(String item) {
    // Two thread-safe operations invoked without global synchronization:
    if (stock.get(item) > 0) { // Perfectly valid read, zero Data Race!
        stock.compute(item, (k, v) -> v - 1); // Atomic mutation!
    }
    // YET A SEVERE RACE CONDITION OCCURS:
    // Two threads concurrently read remaining stock of 1, and both decrement it,
    // overselling the item into negative inventory!
}

Benign Data Races in the JDK: String.hashCode()

The Java Development Kit contains deliberate benign data races engineered for peak runtime efficiency.

The prime example is hash code caching in java.lang.String:

// Simplified source from java.lang.String:
private int hash; // Field is NOT volatile!

public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h; // Data Race! Multiple threads can write concurrently!
        }
    }
    return h;
}

Why is this Benign?

  1. Immutability: The underlying character array value is deeply immutable.
  2. Determinism: The hash algorithm is pure. Five threads computing h concurrently will generate the exact same integer value.
  3. Atomic 32-bit Integer Writes: In Java, reads and writes of 32-bit int fields are guaranteed atomic by the JVM specification (preventing Word Tearing).
  4. Performance: Omitting volatile on trillions of instantiated String objects saves CPU memory fence instructions (StoreLoad barriers).

Verifying Concurrency: JCStress (Java Concurrency Stress)

Standard unit tests cannot reliably catch race conditions. The OpenJDK project uses JCStress to rigorously validate concurrent code:

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = "1", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Lost update detected!")
@Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "Correct concurrent execution")
@State
public class CounterStressTest {
    int count = 0;

    @Actor
    public void actor1() {
        count++; // First concurrent worker
    }

    @Actor
    public void actor2() {
        count++; // Second concurrent worker
    }

    @Arbiter
    public void arbiter(I_Result r) {
        r.r1 = count; // Observes resulting state
    }
}

JCStress forces JIT C2 aggressive optimizations, spins hardware threads into lockstep phase alignment, and executes millions of trials to uncover rare CPU pipeline anomalies.


🎯 Interview Cheat Sheet

1. 30-Second Elevator Pitch

A Race Condition is a semantic concurrency bug where application output depends on the random timing and interleaving of parallel threads, manifesting as Read-Modify-Write (i++), Check-Then-Act (if-check-then-act), and Unsafe Publication. It differs fundamentally from a Data Race: a Data Race is a low-level JMM violation lacking a Happens-Before relationship, whereas a Race Condition is a high-level business logic failure that can occur even when using thread-safe structures like ConcurrentHashMap. Marking fields volatile does not fix compound mutations.


2. 4 Tricky Follow-up Questions with Deep Answers

Question 1: What is the formal difference between a “Data Race” and a “Race Condition”?

Answer:

  • Data Race: A low-level JMM memory model violation where two threads access the same memory location simultaneously without synchronization, and at least one access is a write.
  • Race Condition: A high-level algorithmic flaw where program correctness depends on relative thread timing.
    Key Insight: Code can have a Race Condition without any Data Race (e.g., checking balance on a thread-safe ConcurrentHashMap and withdrawing concurrently). Conversely, code can have a Data Race without a Race Condition (e.g., the benign data race in String.hashCode()).

Question 2: Why is the unsynchronized caching in java.lang.String.hashCode() considered benign?

Answer: Because String is immutable and its hash calculation is deterministic. If multiple threads calculate the hash simultaneously without synchronization, they arrive at identical 32-bit integers. Writing a 32-bit int is inherently atomic in the JVM, so no partial bits can be read. Overwriting the field with the same value causes no functional harm, while avoiding volatile memory barriers improves performance globally across the JVM.

Question 3: Why does if (!map.containsKey(k)) map.put(k, v); suffer from a race condition even when map is a ConcurrentHashMap?

Answer: Because containsKey() and put() are individual atomic actions, but their composition is non-atomic. Between the evaluation of containsKey() returning false and the subsequent execution of put(), another thread can insert a value for that key. The first thread then blindly overwrites the newly inserted value. The atomic resolution requires compound operations: map.putIfAbsent(k, v) or map.computeIfAbsent(k, key -> v).

Question 4: How does the JCStress framework uncover race conditions that pass standard JUnit tests?

Answer: Standard JUnit tests execute with loose thread starts and unoptimized interpreted bytecode, making the probability of hitting a 1–2 CPU cycle concurrency window minuscule. JCStress preheats the JIT C2 compiler, executes assembly-level spin loops to synchronize thread release down to the exact clock cycle, and distributes actors across distinct hardware CPU sockets to trigger real cache-coherency delays. It evaluates millions of runs to generate a statistical outcome matrix.


3. Red Flags & Common Gotchas

❌ “Adding volatile to count makes count++ thread-safe.”
Correction: volatile guarantees visibility, not atomicity. Read-modify-write remains three separate operations; AtomicInteger or explicit locks are required.

❌ “Using ConcurrentHashMap automatically makes any code block thread-safe.”
Correction: Only individual collection methods are atomic. Multi-step workflows require atomic methods like computeIfAbsent() or explicit locking.

❌ “Race conditions always throw runtime exceptions in Java.”
Correction: In the vast majority of cases, race conditions proceed silently, corrupting data without raising errors.