← Back to the section

When several threads work with the same data at the same time, the result may depend on the order in which they happen to run. This is called a race — the threads "compete" for the data, and the winner is determined at random.

value thread A thread B no synchronizationreads 00 reads 00 0 + 1 = 10 0 + 1 = 10 writes 11 writes 11 two increments, value = 1: the second write erased the first one increment at a timereads 00 0 + 1 = 10 writes 11 waits its turn reads 11 1 + 1 = 21 writes 22 two increments, value = 2: both landed

The top row is the value in memory after each step; time runs left to right. Without synchronization both threads read zero, and the second write puts the same one on top of the first. When an increment runs whole, the second thread already reads the updated value.

Two different phenomena under one word

In everyday speech the word "race" is used for different things, and it is important to tell them apart.

Data race is a strictly technical concept: two threads access the same variable in memory at the same time, at least one of them writes, and there is no synchronization between them. This is a violation of the language rules, and the result stops being predictable: a thread may see the new value or the old one, and different threads may see different things. Java does not allow numbers out of thin air: you can only see what was written at some point. Even that is enough to make a counter lie and to produce a bug that shows up once a week under load.

Race condition is a logical problem: the correctness of the result depends on the order or timing of thread execution. A race condition can exist even with correct synchronization — if the algorithm is built from the start so that simultaneous actions produce a wrong answer.

A short formula: a data race is always a violation of the specification; a race condition is a bug in the logic.

Why i++ is not a single operation

Let's take the most common example — a shared counter. Two threads add 1 to the same field 100000 times each; we expect 200000.

live example

public class RaceDemo {
    private static int value = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                value++;
            }
        };
        Thread a = new Thread(task);
        Thread b = new Thread(task);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("two tasks of 100000: expected 200000, got " + value);
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Run it a few times: the number differs every run, almost always too low. The value++ statement hides three steps:

  1. Read the current value of value from memory into a register.
  2. Add 1 to the value in the register.
  3. Write the result back to memory.

Two threads running simultaneously may execute these three steps interleaved:

StepThread AThread Bvalue in memory
1reads value = 0—0
2—reads value = 00
3computes 0 + 1 = 1—0
4—computes 0 + 1 = 10
5writes 1—1
6—writes 11

Both threads added one, but the counter grew only by 1. An operation that looks atomic is in fact not atomic: between its steps another thread can slip in.

This is both a data race (no synchronization) and a race condition (the result is wrong).

Classic race patterns

Most races fall into two typical patterns.

Check-then-act

A thread checks a condition and performs an action, assuming the condition won't change between the two steps.

// Looks safe — but it isn't
if (!map.containsKey(key)) {
    map.put(key, computeValue(key)); // another thread may have already inserted
}

Between containsKey and put another thread manages to insert its own value. The result: computeValue does the same work twice, the second put erases the first, and everyone works with whatever value arrived last.

Read-modify-write

A thread reads a value, changes it, and writes it back. The problem is the same as with i++: another thread slips in between the read and the write.

// Not atomic without synchronization
long current = balance;
balance = current - amount; // another thread read the old balance too

Two withdrawals from the same balance leave the account as if one happened.

Three properties you need to keep under control

To understand why races happen at all, you need to grasp three independent properties of correct multithreaded code.

Atomicity — the operation runs as a single indivisible whole. No other thread sees an intermediate state. i++ is not atomic; AtomicInteger.incrementAndGet() is atomic.

Visibility — a change made by one thread becomes visible to other threads. Without special mechanisms the JVM is allowed to cache variable values in processor registers: thread A updated a variable, while thread B keeps seeing the old value from its cache for a long time.

Ordering — instructions execute in a predictable order. The compiler and the processor reorder instructions for optimization — in a way that keeps a single-threaded program correct. But in multithreaded code the reordering can break the assumptions of another thread.

These three properties are governed by the Java Memory Model through the happens-before relationship: if operation A happens-before operation B, then B is guaranteed to see all changes from A. For more on this, see the article on the memory model.

How to spot a race

Races are treacherous precisely because they don't reproduce reliably. A few signs worth watching for:

  • The program works correctly during testing, but occasionally gives a wrong result under load.
  • The behavior changes depending on the number of CPU cores or the speed of the machine.
  • Adding debug output (System.out.println) "cures" the problem — logging adds synchronization that masks the race.
  • Counters or aggregates diverge when run in parallel and match when run sequentially.
  • A test passes with one thread but fails with several.

The last point is the first step when debugging. Drop thread b from the counter example above: one thread always gives exactly 100000, two almost always give less than 200000. A total that depends on the number of threads is almost certainly a race.

What to do about it

The right answer depends on the nature of the data and the frequency of access.

  • synchronized — the simplest way to guarantee atomicity and visibility for a block of code. For the details, see the article on synchronized.
  • AtomicInteger and other classes from java.util.concurrent.atomic — when you only need atomicity of a single variable without locking. Good for counters and flags. For more, see the article on atomics.
  • volatile — solves only the visibility problem, not atomicity. Enough for a "running/stopped" flag, not enough for a counter.
  • Immutable objects — if an object can't be changed after creation, there are no races by definition: there's nothing to share.

The same counter, with read, add and write fused into one step:

live example

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicDemo {
    private static final AtomicInteger value = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                value.incrementAndGet();
            }
        };
        Thread a = new Thread(task);
        Thread b = new Thread(task);
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("the same count with an atomic operation: " + value.get());
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Exactly 200000 on every run: there is no gap inside incrementAndGet to slip into.

A good design rule: minimize shared mutable state. The less data available to several threads at once, the fewer places where a race can occur.

In short

  • Data race — simultaneous access to a variable without synchronization; a violation of the language specification.
  • Race condition — a logical bug where correctness depends on the order of thread execution.
  • i++ is not an atomic operation: read, modify, write — three steps, between which another thread can slip in.
  • The three properties required for correctness: atomicity, visibility, ordering.
  • Races don't reproduce reliably; a typical sign is a discrepancy in results with a different number of threads.
  • The main defense tools: synchronized, AtomicInteger, volatile, immutable objects.