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.
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: the Java Memory Model guarantees unpredictable behavior up to "the program does whatever it wants".
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.
class Counter {
private int value = 0;
public void increment() {
value++; // looks like one operation — actually three
}
public int get() {
return value;
}
}
The value++ statement hides three steps:
- Read the current value of
valuefrom memory into a register. - Add 1 to the value in the register.
- Write the result back to memory.
Two threads running simultaneously may execute these three steps interleaved:
Thread A: reads value = 0
Thread B: reads value = 0 ← both see 0
Thread A: computes 0 + 1 = 1
Thread B: computes 0 + 1 = 1
Thread A: writes 1
Thread B: writes 1 ← 1 instead of the expected 2
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 the same value. The result: two identical keys end up in the processing queue, the computation is duplicated or the data is overwritten.
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 = timestamp;
timestamp = System.currentTimeMillis(); // another thread could have read the old value too
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: run the suspicious code with numberOfThreads = 1 and compare with numberOfThreads = N. A discrepancy will almost certainly point to a race.
// Scaffold for reproduction: run N threads and check the total
ExecutorService pool = Executors.newFixedThreadPool(4);
Counter counter = new Counter();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
futures.add(pool.submit(counter::increment));
}
for (Future<?> f : futures) f.get(); // wait for all of them
pool.shutdown();
System.out.println("Expected: 1000, got: " + counter.get());
// Without synchronization the value is almost always less than 1000
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.AtomicIntegerand other classes fromjava.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.
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.
What to read next
- Java Memory Model — how the JVM manages visibility and ordering through happens-before.
- synchronized: monitors and mutual exclusion — the most straightforward way to eliminate a data race.
- Atomic variables — counters and flags without locks via CAS.