☕ Java

Race Condition

A race condition is a defect in concurrent code where the correctness of a computation depends on the relative timing or interleaving of operations in multiple threads. When threads access shared mutable state without adequate synchronization, the outcome depends on which thread executes which operation first — and that ordering is nondeterministic, controlled by the OS scheduler, CPU speed, cache state, and load. Race conditions produce results that are occasionally correct (making them hard to reproduce), occasionally wrong (making them hard to ignore), and can manifest as corrupted data structures, wrong numeric results, infinite loops, NullPointerExceptions, or silent data loss. Java provides several mechanisms to eliminate race conditions: synchronized blocks and methods, volatile fields, atomic classes (AtomicInteger, AtomicReference, etc.), and the higher-level concurrency utilities in java.util.concurrent. This entry covers the three classes of race conditions (check-then-act, read-modify-write, put-if-absent), compound operations and why they are not atomic, the Java Memory Model's role in visibility races, diagnosing races with tools, and systematic patterns for eliminating each class.

The Three Classes of Race Conditions

Race conditions fall into three canonical classes based on the structure of the interleaving that causes incorrect behavior. Read-modify-write is the most common. An operation reads a value, computes a new value based on it, and writes the result back. count++ is the classic example: it reads count, increments it, and stores it. If two threads execute count++ simultaneously, both may read the same value (say, 5), both compute 6, and both write 6 — the counter increments once instead of twice. The root cause is that the three-step operation is not atomic: the JVM does not guarantee that no other thread can interleave between the read and the write. This class also includes compound assignments (x += delta), increment and decrement, list size updates, and any computation that feeds back into the shared variable. Check-then-act is the second class. A thread checks a condition, and then based on the outcome of that check, performs an action that assumes the condition still holds. Lazy initialization is the canonical example: if (instance == null) { instance = new Singleton(); }. Two threads may both check, both find null, and both create a new Singleton, violating the singleton guarantee. Between the check and the act, another thread may have changed the state that the check was verifying. This class includes getInstance() patterns, buffer-full/empty checks, file-existence checks before creation, and any conditional mutation. Put-if-absent (a specialization of check-then-act) occurs with maps: if (!map.containsKey(key)) { map.put(key, computeValue(key)); }. Two threads may both find the key absent, both compute the value, and both put it — one overwriting the other. ConcurrentHashMap.computeIfAbsent() is the atomic solution for this exact pattern. All three classes share the same root cause: a logically atomic compound operation is implemented as multiple non-atomic steps with no synchronization protecting the sequence.
Java
// ── Read-modify-write race: the lost increment ───────────────────────
public class RaceCounter {
    private int count = 0;                     // shared mutable state

    public void increment() { count++; }       // NOT atomic — race condition
    public int get()        { return count; }
}

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

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join();  t2.join();
System.out.println(rc.get());
// Expected: 200,000 — Actual: anywhere from ~100,001 to 200,000 (nondeterministic)

// ── What count++ looks like at the bytecode level ─────────────────────
// GETFIELD  count     ← Thread A reads count = 5
//                     ← Thread B reads count = 5  (before A writes)
// ICONST_1            ← Thread A computes 6
// IADD                ← Thread A computes 6Thread B computes 6
// PUTFIELD  count     ← Thread A writes 6
//                     ← Thread B writes 6 (overwrites A's result — lost update)
// Result: count = 6 instead of 7

// ── Check-then-act race: lazy singleton ──────────────────────────────
public class BrokenSingleton {
    private static BrokenSingleton instance;

    public static BrokenSingleton getInstance() {
        if (instance == null) {           // Thread A: reads null
                                          // Thread B: reads null (before A writes)
            instance = new BrokenSingleton(); // Thread A: creates instance
                                          // Thread B: also creates instance — two instances!
        }
        return instance;
    }
}

// ── Check-then-act race: file creation ───────────────────────────────
public class RacyFileCreator {
    public void createIfAbsent(Path path) throws IOException {
        if (!Files.exists(path)) {             // Thread A: file does not exist
                                               // Thread B: file does not exist
            Files.createFile(path);            // Thread A: creates file
                                               // Thread B: FileAlreadyExistsException!
        }
    }
}

// ── Put-if-absent race: map population ───────────────────────────────
public class RacyCache {
    private final Map<String, Data> cache = new HashMap<>();

    public Data getOrCompute(String key) {
        if (!cache.containsKey(key)) {          // both threads see key absent
            Data value = expensiveCompute(key); // both compute (wasteful or wrong)
            cache.put(key, value);              // one overwrites the other silently
        }
        return cache.get(key);
    }

    private Data expensiveCompute(String key) { return new Data(key); }
}

// ── Atomically correct alternatives for each class ────────────────────
// Read-modify-write → AtomicInteger:
AtomicInteger atomicCount = new AtomicInteger(0);
atomicCount.incrementAndGet();   // single atomic CAS — no lost updates

// Check-then-act singleton → initialization-on-demand holder:
public class CorrectSingleton {
    private CorrectSingleton() {}
    private static class Holder {
        static final CorrectSingleton INSTANCE = new CorrectSingleton();
    }
    public static CorrectSingleton getInstance() { return Holder.INSTANCE; }
}

// Put-if-absent → ConcurrentHashMap.computeIfAbsent():
ConcurrentHashMap<String, Data> safeCache = new ConcurrentHashMap<>();
Data result = safeCache.computeIfAbsent(key, k -> expensiveCompute(k)); // atomic

Visibility Races and the Java Memory Model

A visibility race is a subtler category than the atomicity-based races above. Even if a write and a read are each individually atomic, the write may not be visible to the reading thread. The Java Memory Model (JMM) permits the JVM and CPU to reorder instructions, keep values in registers or CPU caches, and delay writing to main memory — all in the name of performance. Without a synchronization action that establishes a happens-before relationship between a write and a read, the reading thread may see a stale value indefinitely. The JMM defines happens-before as the only mechanism that guarantees visibility. A write to a volatile field happens-before every subsequent read of that field. An unlock of monitor M happens-before every subsequent lock of M. Thread.start() happens-before any action in the started thread. Thread.join() returning happens-before the joined thread's final actions. Without one of these relationships, there is no visibility guarantee, even if the writes are correct in isolation. A common visibility race: a flag field used to signal a thread to stop. If running is a plain boolean field (not volatile, not synchronized), the thread that reads running may never observe the change made by another thread. The JIT compiler may hoist the read of running out of the loop body (since no volatile or synchronization barrier is present), turning while (running) {} into if (running) { while (true) {} } — an infinite loop that ignores subsequent writes. The 64-bit non-atomicity race: writes to long and double fields are not guaranteed to be atomic on 32-bit JVMs. A write to a long is implemented as two 32-bit writes. A reading thread may observe a partial write — the upper 32 bits from one write and the lower 32 bits from another — producing a value that was never written by any thread. Declaring the field volatile eliminates this: volatile long guarantees both atomicity of the write and visibility.
Java
// ── Visibility race: thread may loop forever ─────────────────────────
public class VisibilityRace {
    private static boolean running = true;   // not volatile — no visibility guarantee

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long iterations = 0;
            while (running) {           // JIT may cache 'running' in register
                iterations++;
            }
            System.out.println("Stopped after: " + iterations);
        });
        worker.start();
        Thread.sleep(1000);
        running = false;               // write may NEVER be seen by worker
        System.out.println("Set running = false");
        worker.join(2000);
        if (worker.isAlive()) System.out.println("Worker is STILL RUNNING — visibility race");
    }
}

// ── Fix: volatile establishes happens-before ─────────────────────────
public class VisibilityFixed {
    private static volatile boolean running = true;   // happens-before guarantee

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long iterations = 0;
            while (running) iterations++;  // always reads fresh value from main memory
            System.out.println("Stopped after: " + iterations);
        });
        worker.start();
        Thread.sleep(1000);
        running = false;   // write guaranteed visible to worker immediately
        worker.join();
    }
}

// ── 64-bit long visibility and atomicity race ─────────────────────────
public class LongRace {
    private static long value = 0;   // not volatile

    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            while (true) {
                value = 0x0000_0000_0000_0000L;
                value = 0xFFFF_FFFF_FFFF_FFFFL;
            }
        });
        Thread reader = new Thread(() -> {
            long seen;
            while ((seen = value) == 0x0000_0000_0000_0000L
                || seen == 0xFFFF_FFFF_FFFF_FFFFL) {}
            System.out.printf("Saw torn value: 0x%016X%n", seen);
            // On 32-bit JVM: may print 0xFFFF_FFFF_0000_0000 or 0x0000_0000_FFFF_FFFF
        });
        writer.setDaemon(true);
        writer.start();
        reader.start();
        reader.join(5000);
    }
}

// Fix: volatile long guarantees atomic 64-bit writes and visibility:
private static volatile long value = 0;

// ── Publication race: safely publishing an object to another thread ───
public class UnsafePublication {
    public int x, y;
    public UnsafePublication() { x = 1; y = 2; }  // constructor sets fields
}

public class Publisher {
    private static UnsafePublication obj;  // not volatile

    public static void publish() {
        obj = new UnsafePublication();     // another thread may see obj != null
    }                                      // but x == 0, y == 0 (before constructor runs)
                                           // due to instruction reordering

    // Reader thread:
    public static int readY() {
        UnsafePublication o = obj;
        if (o != null) return o.y;  // may return 0 — constructor not yet visible!
        return -1;
    }
}

// Fix 1: volatile reference — publication write happens-before read:
private static volatile UnsafePublication obj;

// Fix 2: synchronize both publish and read on same lock:
private static final Object LOCK = new Object();
private static UnsafePublication obj2;

public static synchronized void publishSafe() { obj2 = new UnsafePublication(); }
public static synchronized int  readSafe()    { return obj2 != null ? obj2.y : -1; }

// Fix 3: make the object immutable — immutable objects are always safely published:
public final class SafePublication {
    public final int x, y;   // final fields: JMM guarantees safe publication
    public SafePublication() { x = 1; y = 2; }
}

Diagnosing and Systematically Eliminating Race Conditions

Race conditions are difficult to reproduce because they depend on precise timing between threads. A test may pass thousands of times and then fail under production load when CPU contention changes the interleaving. This makes systematic analysis — reasoning about which accesses to shared state are properly synchronized — more reliable than testing for races. Thread sanitizers and static analysis tools can detect races without requiring the race to manifest. Java tools include: FindBugs and SpotBugs (static analysis that identifies common concurrency patterns like unsynchronized field access), Error Prone (Google's compiler plugin with thread safety annotations), Java PathFinder (model checker that exhaustively explores thread interleavings), and ThreadSanitizer (available via JVM agents and native code). The @GuardedBy annotation from JCIP (Java Concurrency in Practice) documents which lock protects each field, enabling both human review and tool analysis. The systematic approach to eliminating races: identify all shared mutable state — fields that are written by one thread and read by another. For each field, determine whether it is adequately protected. Protection options in order of preference: make the state immutable (final fields, defensive copies, immutable value objects); confine the state to one thread (thread-local storage, actor model); use atomic operations (AtomicInteger, AtomicReference, AtomicLong, etc.) for single-variable state; use synchronized for compound multi-variable state; use volatile for single-variable state where only visibility (not atomicity) is needed. The two most common mistakes in eliminating races: under-synchronization (protecting some accesses to a field but not all — every access must be synchronized with the same lock for synchronization to work) and lock splitting (using different lock objects for different methods that access the same shared field, providing no mutual exclusion between them).
Java
// ── Under-synchronization: missing lock on some accesses ─────────────
public class PartialSync {
    private int count = 0;

    public synchronized void increment() { count++; }   // locked

    public int get() { return count; }   // NOT locked — race: may read stale value
    // Fix: public synchronized int get() { return count; }
}

// ── Wrong lock: different lock objects for the same field ─────────────
public class WrongLock {
    private int x = 0;

    public void setX(int value) {
        synchronized (new Object()) { x = value; }  // new object each time — no exclusion!
    }

    public int getX() {
        synchronized (new Object()) { return x; }   // different lock than setX
    }
    // Fix: use a shared final lock object for both methods
    private final Object LOCK = new Object();
    public void setXFixed(int v) { synchronized (LOCK) { x = v; } }
    public int  getXFixed()       { synchronized (LOCK) { return x; } }
}

// ── @GuardedBy annotation — documents and enables tool analysis ────────
import net.jcip.annotations.GuardedBy;

public class AnnotatedAccount {
    private final Object lock = new Object();

    @GuardedBy("lock") private double balance = 0;
    @GuardedBy("lock") private long   txCount = 0;

    public void deposit(double amount) {
        synchronized (lock) {
            balance += amount;   // @GuardedBy ensures tools flag unprotected access
            txCount++;
        }
    }

    public double getBalance() {
        synchronized (lock) { return balance; }
    }
}

// ── Atomic classes: lock-free single-variable race elimination ─────────
import java.util.concurrent.atomic.*;

public class AtomicStats {
    private final AtomicLong  hitCount    = new AtomicLong(0);
    private final AtomicLong  missCount   = new AtomicLong(0);
    private final AtomicReference<String> lastKey = new AtomicReference<>(null);

    public void recordHit(String key) {
        hitCount.incrementAndGet();          // atomic — no lost increments
        lastKey.set(key);                    // atomic reference write
    }

    public void recordMiss(String key) {
        missCount.incrementAndGet();
        lastKey.compareAndSet(null, key);    // CAS: set only if currently null
    }

    // AtomicLong.updateAndGet for computed updates:
    public void addHits(long n) {
        hitCount.addAndGet(n);               // atomic add — equivalent to hitCount += n
    }

    // LongAdder for high-contention counters (splits into per-thread cells):
    private final LongAdder highContentionCounter = new LongAdder();
    public void fastIncrement() {
        highContentionCounter.increment();   // virtually no contention overhead
    }
    public long totalFast() {
        return highContentionCounter.sum();  // may not reflect concurrent in-progress increments
    }
}

// ── Immutability: the strongest race elimination ──────────────────────
// An immutable object can be shared between threads with no synchronization:
public final class Money {
    private final long   cents;       // final: written once in constructor, never changed
    private final String currency;

    public Money(long cents, String currency) {
        this.cents    = cents;
        this.currency = currency;
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException();
        return new Money(cents + other.cents, currency);  // returns new object — no mutation
    }

    public long   cents()    { return cents; }
    public String currency() { return currency; }
}

Money a = new Money(100, "USD");
Money b = new Money(50,  "USD");
// No synchronization needed — Money is immutable and safely published:
Runnable r = () -> System.out.println(a.add(b).cents());
new Thread(r).start();
new Thread(r).start();   // both threads read a and b safely

Related Topics in Multithreading

Process vs Thread
A process is an independent program in execution with its own isolated memory space, file handles, and system resources, managed by the operating system and separated from all other processes by strict boundaries. A thread is a unit of execution that lives inside a process, sharing that process's memory, heap, and resources with every other thread in the same process. Java programs run inside a JVM process; the JVM itself creates and manages threads, and every Java application starts with at least one thread — the main thread — with additional threads created by the JVM for garbage collection, JIT compilation, signal handling, and other runtime tasks. Understanding the distinction between processes and threads is the foundation for all concurrent programming in Java: it determines what is shared and what is isolated, what is fast and what is expensive, what fails independently and what fails together. This entry covers the OS-level and JVM-level model of processes and threads, the memory model that follows from the shared-versus-isolated distinction, the cost model for creation and context switching, failure isolation and its consequences, inter-process and inter-thread communication mechanisms, and the practical decision of when to use multiple processes versus multiple threads.
Thread Basics
A Java thread is an instance of java.lang.Thread that represents an independent path of execution within the JVM process. Every thread has a lifecycle — from creation through runnable, running, blocked, waiting, timed-waiting, and terminated states — and a set of properties including its name, priority, daemon status, thread group, and uncaught exception handler. The Java memory model specifies what visibility guarantees exist between threads and when writes by one thread are guaranteed to be visible to another. Thread scheduling is controlled by the OS scheduler subject to hints from the JVM via thread priority; the JVM does not provide real-time scheduling guarantees. This entry covers the complete thread lifecycle and its state machine, thread properties and how they affect scheduling and JVM shutdown, the happens-before relationship and why it matters for visibility, daemon threads and their relationship to JVM shutdown, thread interruption as a cooperative cancellation mechanism, and the methods on Thread that every Java developer must understand.
Creating Threads
Java provides three primary abstractions for defining the work a thread will execute: the Thread class itself (subclassed to override run()), the Runnable interface (a task with no return value and no checked exception), and the Callable interface (a task with a return value and a declared checked exception). Each represents a different contract between the task and the infrastructure that runs it. Thread subclassing couples the task definition to the execution mechanism and is the oldest and least flexible approach. Runnable decouples the task from the thread, allowing the same Runnable to be submitted to thread pools, scheduled executors, or wrapped in Thread objects. Callable extends that decoupling to include a return value and exception propagation, returning a Future that allows the caller to retrieve the result or handle exceptions asynchronously. Understanding all three — their contracts, their limitations, and when to use each — is the foundation of concurrent programming in Java before reaching for higher-level constructs.
Thread Lifecycle
The Java thread lifecycle is the complete sequence of states a thread passes through from the moment a Thread object is constructed to the moment its execution ends. Java defines six states in the Thread.State enum — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — and the JVM transitions threads between these states in response to specific method calls, lock acquisitions, monitor notifications, timeouts, and exceptions. Each state has a precise meaning, a defined set of entry conditions, and a defined set of exit conditions. Understanding the lifecycle in full is prerequisite knowledge for diagnosing deadlocks, thread leaks, performance bottlenecks in thread dumps, and incorrect synchronization — all of which manifest as threads stuck in specific states. This entry covers every state in the lifecycle with its entry and exit conditions, all legal and illegal state transitions, how thread dumps represent each state, the interaction between lifecycle states and interruption, the effect of uncaught exceptions on lifecycle, and how to observe lifecycle transitions programmatically.