[THREADING 101] 02 – Race Conditions and Data Races

[

Overview

When multiple threads access shared mutable state without proper coordination, two fundamental categories of bugs can occur: race conditions and data races. They are related but distinct problems.

Race Condition

A race condition occurs when the correctness of a program depends on the relative timing of thread execution. Multiple threads compete to read and write the same resource, and the outcome varies depending on which thread gets there first.

Classic Example: Lost Update

public class Counter {
    private int count = 0;

    public void increment() {
        count++; // Not atomic: read → modify → write
    }

    public int getCount() {
        return count;
    }
}

When two threads call increment() simultaneously:

Both threads read the same value, both compute the same result, and one update is lost. After two increments, the counter shows 1 instead of 2.

Check-Then-Act Race

Another common pattern where the check and the action are not atomic:

public class LazyInitializer {
    private Object instance;

    public Object getInstance() {
        if (instance == null) {          // Check
            instance = new Object();      // Act
        }
        return instance;
    }
}

Two threads can both see instance == null, both create an object, and the singleton guarantee is broken.

Read-Modify-Write Race

Any operation that reads a value, computes something, and writes back is vulnerable:

// All of these are race-prone without synchronization:
balance = balance - withdrawal;  // Bank account
list.add(item);                  // Adding to shared list
map.put(key, map.get(key) + 1); // Updating map counter

Data Race

A data race is a lower-level problem related to memory visibility and instruction reordering. It occurs when:

  1. Two threads access the same memory location
  2. At least one access is a write
  3. There is no synchronization ordering the accesses

CPU Reordering

Modern CPUs and compilers reorder instructions for performance. If two instructions don’t have a visible dependency from the CPU’s perspective, their order may be swapped:

// Original code
int a = 1;        // (1)
boolean ready = true;  // (2)

// CPU might execute as:
boolean ready = true;  // (2) — moved first!
int a = 1;        // (1)

On a single thread this is invisible — the final state is the same. But if another thread reads ready and then a, it might see ready == true but a == 0:

// Thread 1                    // Thread 2
a = 42;                        while (!ready) { /* spin */ }
ready = true;                  System.out.println(a); // might print 0!

How Data Races Differ from Race Conditions

AspectRace ConditionData Race
LevelLogical/algorithmicMemory model / hardware
CauseUnsynchronized read-write sequencesMissing happens-before relationship
SymptomWrong result, lost updateStale/invisible values, reordered effects
FixSynchronize the critical sectionUse volatile, synchronized, or atomics
Can exist without the other?YesYes

You can have a race condition without a data race (if operations are individually atomic but the sequence is wrong). You can have a data race without a race condition (if reordering causes visibility issues even in a single-writer scenario).

The volatile Keyword as a Memory Barrier

volatile serves two purposes:

  1. Visibility: Every read of a volatile variable goes to main memory. Every write flushes to main memory immediately.
  2. Ordering barrier: Instructions before a volatile write cannot be reordered past it. Instructions after a volatile read cannot be reordered before it.
// volatile acts as a "fence" — no reordering crosses it
private int a = 0;
private volatile boolean ready = false;

// Thread 1 (writer)
a = 42;                // Guaranteed to happen BEFORE the volatile write
ready = true;          // Volatile write — flushes all previous writes

// Thread 2 (reader)
if (ready) {           // Volatile read — refreshes all subsequent reads
    System.out.println(a); // Guaranteed to see 42
}

What volatile Does NOT Do

  • It does not make compound operations atomic (volatile int count; count++ is still not atomic)
  • It does not replace synchronization for read-modify-write patterns
  • It does not provide mutual exclusion

Common Race Condition Patterns

1. Compound Check-Then-Act

// BROKEN: gap between check and act
if (!map.containsKey(key)) {
    map.put(key, value);
}

// FIX: use atomic operation
map.putIfAbsent(key, value);

2. Lazy Initialization (Double-Checked Locking)

// BROKEN without volatile
private static Instance instance;

public static Instance getInstance() {
    if (instance == null) {
        synchronized (Instance.class) {
            if (instance == null) {
                instance = new Instance(); // Can be seen partially constructed!
            }
        }
    }
    return instance;
}

// FIX: add volatile
private static volatile Instance instance;

3. Iterator Invalidation

// BROKEN: another thread modifies the list while iterating
for (String item : sharedList) {
    if (item.startsWith("X")) {
        sharedList.remove(item); // ConcurrentModificationException
    }
}

// FIX: use ConcurrentHashMap, CopyOnWriteArrayList, or external synchronization

4. Time-of-Check to Time-of-Use (TOCTOU)

// BROKEN: file might be deleted between check and read
if (file.exists()) {
    // Another thread/process deletes the file here
    readFile(file); // FileNotFoundException
}

// FIX: just try the operation and handle the exception
try {
    readFile(file);
} catch (FileNotFoundException e) {
    // Handle missing file
}

Detecting Race Conditions

ToolWhat It Catches
Thread sanitizers (TSan)Data races at runtime
Static analysis (SpotBugs, Error Prone)Common concurrency antipatterns
Stress testing (JCStress)Race conditions under heavy contention
Code reviewLogical race conditions in check-then-act patterns

Summary

  • Race condition: the logic is correct only if threads happen to execute in a specific order. Fix by making critical sections atomic.
  • Data race: memory operations have no ordering guarantee. Fix with volatile, synchronized, or atomic classes.
  • volatile provides visibility and prevents reordering but does not make compound operations atomic.
  • Most real-world bugs combine both: a data race causes a stale read, which leads to a race condition in the logic.

References

About the author

Maksim

I build AI-powered products and lead engineering teams. I've launched platforms from zero to millions of users and learned most lessons the hard way. I write about the gap between engineering theory and practice, what actually matters when building products, and the decisions that shape teams and systems.

Add Comment

By Maksim

Maksim

Get in touch

Reach out if you want to discuss engineering leadership, collaborate on something interesting, or suggest topics you'd like me to write about.