Race conditions
Why two threads touching the same field can lose updates, sell the last seat twice or spin forever: shared mutable state, the two race shapes, data races vs race conditions, and the toolbox that fixes them.
On this page
- Picture it first: two cashiers, one ledger
- What can race, and what can't
- Under the hood: count++ is three steps
- The two shapes every race takes
- "But I used a thread-safe collection"
- Atomicity and critical sections, in plain words
- Race condition vs data race
- Why races hide on your laptop
- A related teaser: visibility
- Real world
- Advanced: tearing, the toolbox, and hunting races
- How to say it in the interview
🏁 What is a race condition?
A race condition is a bug where the answer depends on which thread gets there first, and nobody planned the order. Picture two cashiers sharing one paper ledger: both read "100", both add their sale, both write their number back. One sale just vanished. In Java, the classic version is two threads running count++ on the same field and ending up with fewer counts than they made.
For this lesson, we'll start from zero. You'll see which data can race and which can't, watch count++ lose real updates on a real laptop, and learn the two shapes nearly every race takes. Then we'll fix them. Interviewers love this topic because it's the doorway to all of concurrency: a candidate who can explain why count++ breaks usually understands locks, atomics and the memory model too. A candidate who can't, usually doesn't.
Below the line (out of scope)
- How
synchronizedworks inside, monitors and all: synchronized and monitors - Why a thread can take the same lock twice: Reentrancy
- Where threads and their stacks come from: Processes vs threads and Stack vs heap
- Visibility gets its own lessons later ("volatile", "Happens-before", "Visibility and reordering"); here we only meet it
The 30-second answer
A race condition happens when two or more threads touch shared mutable state and the result depends on the timing of their steps. The classic example is count++, which is really three steps: read the field, add one, write it back. If two threads interleave those steps, one increment is lost. The other common shape is check-then-act: if (!map.containsKey(k)) map.put(k, v), where both threads pass the check before either acts. The fix is to make the compound action atomic: don't share the data, make it immutable, or use one atomic operation (AtomicInteger, ConcurrentHashMap.merge) or a lock. A data race is a narrower, formal term from the Java Memory Model: two conflicting accesses not ordered by happens-before.
What you’ll learn
- Explain why only shared mutable state can race, and why local variables never do
- Break
count++into read, add, write usingjavap(getfield,iadd,putfield) and draw the interleaving that loses an update - Spot the two race shapes: read-modify-write and check-then-act (containsKey then put, lazy init, balance check then withdraw)
- Show why a sequence of calls on a
ConcurrentHashMapis still racy, and fix it withmerge,putIfAbsentandcomputeIfAbsent - Tell a race condition (a timing-dependent wrong result) from a data race (JLS: conflicting accesses not ordered by happens-before)
- Describe atomicity and critical sections in plain words, and why races hide on a laptop and appear under load
- Choose between **confinement, immutability, atomics, concurrent-collection methods and
synchronized**, with a sensible default - Find races with stress tests, jcstress and code-review smells, and know about
long/doubletearing and sharedSimpleDateFormat
Interview radar
Asked very often: What is a race condition? Why is count++ not thread-safe?
Core follow-up: I swapped HashMap for ConcurrentHashMap. Is my code safe now?
Depth signal: What's the difference between a race condition and a data race?
01Picture it first: two cashiers, one ledger
Let's go back to the coffee shop. The till's total lives in one paper ledger on the counter, and it says 100. Cashier A sells a coffee for 10. Cashier B, at the same moment, sells a muffin for 5.
Both glance at the ledger and see 100. A works out 110 and writes it. B works out 105 and writes it, right over A's number. Here's how that looks:
Two cashiers, one paper ledger
Notice there's no crash and no error message. The ledger says 105 when the truth is 115. That's what makes races nasty: the program keeps running and quietly produces the wrong answer.
02What can race, and what can't
Here's the thing that calms most beginners down: most of your variables can never race. A race needs three ingredients at once. The data must be shared (two threads can reach it), mutable (someone writes it), and accessed without coordination.
Local variables fail the first test. Every thread gets its own stack, and locals live there, so no other thread can even see them. Fields, static variables, and any object two threads both hold a reference to live on the heap, which every thread shares.
import java.util.ArrayList;
import java.util.List;
public class WhatRaces {
static int hits = 0; // static: one copy on the heap, shared by all threads
private final List<String> log = new ArrayList<>(); // field: shared if `this` is shared
void handle(String user) {
int attempts = 0; // local: lives on this thread's stack, can't race
attempts++; // always safe
hits++; // can race
log.add(user); // can race (ArrayList isn't thread-safe)
}
}
One subtlety: a local variable that points to a shared object is safe, but the object it points to isn't. The reference is yours; the thing on the heap might not be. If you want the full picture of what lives where, Stack vs heap walks through it.
03Under the hood: count++ is three steps
Try it yourself first
Before reading on, guess: four threads each run count++ one million times on a shared int. What does count say at the end? Run it three times. Same answer each time?
count++ looks like one action. It isn't. Let's ask the compiler what it really produced. Here's a tiny class:
public class Counter {
private int count;
public void increment() {
count++;
}
}
Compile it and disassemble the bytecode with javac Counter.java && javap -c Counter:
public void increment();
Code:
0: aload_0
1: dup
2: getfield #7 // Field count:I
5: iconst_1
6: iadd
7: putfield #7 // Field count:I
10: return
Three separate instructions do the real work. getfield reads count from the heap onto this thread's private operand stack. iadd adds 1 there, in private. putfield writes the result back to the heap. Between any two of them, the operating system can pause this thread and let another one run.
Now let's slow two threads down and watch them interleave those three steps:
count++ is three steps, and two threads can interleave them
If this feels too unlucky to happen in practice, you're not alone thinking that. Let's measure it.
public class LostUpdate {
static int count = 0; // one field on the heap, shared by every thread
static volatile boolean tick; // read in the loop so the JIT can't merge the increments
public static void main(String[] args) throws InterruptedException {
int threads = 4, perThread = 1_000_000;
for (int run = 1; run <= 5; run++) {
count = 0;
Thread[] workers = new Thread[threads];
for (int i = 0; i < threads; i++) {
workers[i] = new Thread(() -> {
for (int j = 0; j < perThread; j++) {
if (tick) return;
count++; // read, add 1, write back
}
});
workers[i].start();
}
for (Thread t : workers) t.join();
int expected = threads * perThread;
System.out.printf("run %d: expected %,d, got %,d, lost %,d%n",
run, expected, count, expected - count);
}
}
}
Here's a real run of java LostUpdate.java on a 14-core laptop (JDK 26, same behaviour on 21). Your numbers will differ, and they'll differ every run. That's the point.
run 1: expected 4,000,000, got 1,298,814, lost 2,701,186
run 2: expected 4,000,000, got 1,328,889, lost 2,671,111
run 3: expected 4,000,000, got 1,194,214, lost 2,805,786
run 4: expected 4,000,000, got 1,124,559, lost 2,875,441
run 5: expected 4,000,000, got 1,177,014, lost 2,822,986
Notice three things:
- We lost more than half the increments. Not a rounding error: 2.7 million updates disappeared on every single run.
- The answer changes every run. Same code, same input, different output. That's the signature of a race.
- That odd
tickline is a guard for the demo, not part of the bug. Without it, the JIT compiler is allowed to fuse the loop into something like one bigcount += 1_000_000, which makes the race window tiny. We'll see in a minute why that makes races so good at hiding.
04The two shapes every race takes
Once you've seen a few, races start to look alike. Nearly all of them are one of two shapes. Learn these two names; they're how experienced engineers talk about it.
Shape 1: read-modify-write. You read a value, compute a new one from it, and write it back. count++ is the poster child. So is balance = balance + deposit, and so is map.put(k, map.get(k) + 1). The race lives in the gap between the read and the write.
Shape 2: check-then-act. You look at the world, decide something, then act on that decision. But the world can change between your look and your act. Here are three you'll meet in real code:
// 1. "put if missing": two threads both see "missing", both put
if (!cache.containsKey(key)) {
cache.put(key, load(key));
}
// 2. lazy singleton: two threads both see null, both construct
if (instance == null) {
instance = new ExpensiveService();
}
// 3. withdraw: two threads both see enough money, both withdraw
if (balance >= amount) {
balance -= amount;
}
The third one is the scary one. It's the same bug as selling the last concert seat to two people. Here's how it plays out:
Check-then-act: both threads pass the check before either acts
Let's make that real. Two threads wait at a starting gate (a CountDownLatch), then both try to withdraw 100 from an account holding 100. We repeat that 10,000 times:
import java.util.concurrent.CountDownLatch;
public class DoubleSpend {
static class Account {
private int balance = 100;
boolean withdraw(int amount) {
if (balance >= amount) { // check
balance -= amount; // act
return true;
}
return false;
}
}
public static void main(String[] args) throws InterruptedException {
int trials = 10_000, doubleSpends = 0;
for (int i = 0; i < trials; i++) {
Account account = new Account();
CountDownLatch go = new CountDownLatch(1);
boolean[] ok = new boolean[2];
Thread[] people = new Thread[2];
for (int p = 0; p < 2; p++) {
int me = p;
people[p] = new Thread(() -> {
try { go.await(); ok[me] = account.withdraw(100); } catch (InterruptedException e) { }
});
people[p].start();
}
go.countDown(); // both press "withdraw" at the same moment
for (Thread t : people) t.join();
if (ok[0] && ok[1] && doubleSpends++ == 0)
System.out.println("first double spend: balance is now " + account.balance);
}
System.out.printf("%,d trials, %d double spends%n", trials, doubleSpends);
}
}
Three real runs printed 7, 8 and 10 double spends. Here's one:
first double spend: balance is now 0
10,000 trials, 7 double spends
Read that first line again. Both people got their 100, so the bank paid out 200 from an account holding 100. And the balance says 0, not -100, because balance -= amount is itself a read-modify-write that lost one of the two updates. Two races stacked on top of each other, and the books look perfectly tidy. Seven in 10,000 sounds rare until you remember a payments service does 10,000 withdrawals before lunch.
05"But I used a thread-safe collection"
This is the bit that trips people up, including experienced engineers. ConcurrentHashMap guarantees that each single call is safe. It does not, and cannot, make a sequence of your calls safe.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class SafeMapStillRacy {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> racy = new ConcurrentHashMap<>();
Map<String, Integer> atomic = new ConcurrentHashMap<>();
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 250_000; j++) {
// racy: every call is thread-safe, the sequence is not
Integer old = racy.get("clicks");
racy.put("clicks", old == null ? 1 : old + 1);
// atomic: one call does the whole read-modify-write
atomic.merge("clicks", 1, Integer::sum);
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("get + put: " + racy.get("clicks"));
System.out.println("merge: " + atomic.get("clicks"));
}
}
Real output (the first number changes every run):
get + put: 909891
merge: 1000000
Ninety thousand clicks gone, on a "thread-safe" map. The get was safe and the put was safe, but another thread slipped in between them. The fix is to hand the whole compound action to the map in one call, so the map does it under its own lock:
| You wrote (racy) | Use instead (one atomic call) |
|---|---|
get, add, put |
map.merge(key, 1, Integer::sum) |
if (!containsKey) put |
map.putIfAbsent(key, value) |
if (!containsKey) put(key, expensiveLoad()) |
map.computeIfAbsent(key, k -> expensiveLoad()) |
get, change, put (any rule) |
map.compute(key, (k, old) -> ...) |
Keep the function you pass to compute and computeIfAbsent short. The map holds a lock on that bucket while it runs, and the Javadoc warns it must not update other mappings of the same map.
06Atomicity and critical sections, in plain words
Two words do most of the work when we fix races, so let's pin them down.
An action is atomic when other threads can only ever see it as "not started" or "finished", never half-done. A light switch is atomic; nobody sees it halfway. count++ isn't, because another thread can see the moment after the read and before the write.
A critical section is a stretch of code that must run as one atomic block: only one thread inside at a time. Back in the coffee shop, imagine the ledger moves into a small room with one key on a hook. You take the key, go in, read, add, write, come out, and hang the key back. Anyone else waits at the door.
The fix: one key to the ledger room
In Java, the key is a lock and the room is a synchronized block:
private final Object lock = new Object();
private int count;
void increment() {
synchronized (lock) { // take the key (wait at the door if someone has it)
count++; // read, add, write: nobody else can be in here
} // hang the key back, even if an exception is thrown
}
Every thread that touches count must use the same lock. A lock only one side respects is a door with no wall around it. We'll open up how synchronized works in synchronized and monitors.
07Race condition vs data race
You'll hear both terms, often mixed up. They overlap, but they're not the same thing, and interviewers who know the difference will ask.
A race condition is about correctness: the result depends on timing, and some timings give a wrong answer. It's a logic bug, and you can have one in any language, even across machines.
A data race is a precise Java Memory Model term. The JLS (§17.4.5) says two accesses to the same variable conflict if at least one is a write. A program has a data race when two conflicting accesses aren't ordered by happens-before, the JMM's rule for "this write is guaranteed visible to that read". In plain words: two threads touch the same field, one writes, and there's no lock, volatile or other synchronization between them.
Here's the surprise: either one can happen without the other.
- Data race, no race condition.
String.hashCode()caches its result in a plain field with no lock. Two threads may both compute it and both write it. That's a data race, but each computes the same number, so the result is always right. The JDK does this on purpose; you almost never should. - Race condition, no data race. The
ConcurrentHashMapgetthenputabove. Every access goes through the map's own synchronization, so there's no data race at all. The clicks still vanished. Same forif (atomic.get() < 10) atomic.incrementAndGet();. - Both. Our plain
count++. Unsynchronized conflicting writes, and a wrong total.
A data race is about the memory model; a race condition is about your logic. Fixing all data races (say, by making everything volatile) won't fix a check-then-act, and a check-then-act can live entirely inside thread-safe calls.
08Why races hide on your laptop
Races are timing bugs, so anything that changes timing hides or reveals them. Here's the same count++ race with only 2 threads, run 1,000 times at three sizes:
1,000 increments per thread: 0 of 1,000 runs were wrong
10,000 increments per thread: 4 of 1,000 runs were wrong
100,000 increments per thread: 3 of 1,000 runs were wrong
With 1,000 increments, the first thread usually finishes before the second even starts. Zero failures in 1,000 runs. You'd ship that. And the JIT makes it worse: my first version of LostUpdate (without the tick guard) lost 2 million updates in run 1, then exactly zero in runs 3, 4 and 5, once the JIT had compiled the loop into a fast blob that finished before the next thread got going.
In production, everything that hides a race goes away. More cores run threads truly in parallel. Higher load means threads are always overlapping. A garbage-collection pause or a slow network call stretches the gap between check and act from nanoseconds to milliseconds. That's why "it works on my machine" means very little for concurrent code.
09A related teaser: visibility
There's a second, sneakier problem hiding in shared state. It's not about two writes colliding; it's about a write never being seen.
public class StopFlag {
static boolean stop = false; // not volatile: that's the bug
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (!stop) { } // may never see main's write
});
worker.setDaemon(true); // so the JVM can still exit
worker.start();
Thread.sleep(1_000);
stop = true;
System.out.println("main set stop = true");
worker.join(3_000);
System.out.println("worker still running 3 s later? " + worker.isAlive());
}
}
main set stop = true
worker still running 3 s later? true
The worker never stopped. With no synchronization, the JIT is allowed to read stop once and turn the loop into while (true). Only one thread writes, so nothing collides, yet it's still a data race and still broken. The one-word fix is volatile boolean stop, and the "why" is a whole topic: we'll cover it properly in the "volatile" and "Happens-before" lessons.
10Real world
The Java HashMap that spun forever. Before Java 8, if two threads put into a plain HashMap while it was resizing, the bucket's linked list could end up pointing back at itself. Every later get() on that bucket looped forever at 100% CPU. Paul Tyma's post "A Beautiful Race Condition" walks through the exact interleaving, and OpenJDK bug JDK-6423457 ("High cpu usage in HashMap.get()") was closed as not a defect: HashMap was never thread-safe. Java 8 rewrote the resize to keep list order, which removed that particular loop, but a shared HashMap can still lose entries or corrupt itself. The fix was always ConcurrentHashMap.
Therac-25. Between 1985 and 1987 a radiation therapy machine gave at least six patients massive overdoses. Leveson and Turner's investigation found race conditions in the software: if the operator edited the treatment settings quickly, within about 8 seconds, the setup task could miss the change while the screen showed the corrected values. Fast, experienced operators triggered it; slow testers never did.
Unlimited Starbucks credit. In 2015 Egor Homakov found that transferring money between Starbucks gift cards was check-then-act: check the source balance, then move the money. He bought three $5 cards and fired the same $5 transfer from two browser sessions at once. On the sixth try, both transfers passed the balance check: the cards now held $15 and $5, so $15 had become $20. It's our DoubleSpend demo, over HTTP. His advice was the same as ours: make the check and the update one atomic step (in SQL, a row lock with SELECT ... FOR UPDATE).
11Advanced: tearing, the toolbox, and hunting races
Many candidates fix count++ by marking the field volatile. That's a mistake. volatile makes each read and write visible to other threads, but count++ is still a read, an add and a write, and two threads can still interleave them. Use AtomicInteger.incrementAndGet() or a lock. The other classic slip is "I made every method synchronized, so callers are safe". Each call is safe; a caller's if (!list.contains(x)) list.add(x) is still a race.
Word tearing on long and double. JLS §17.7 lets the JVM split a write to a non-volatile long or double into two 32-bit halves. Another thread could read the top half of the new value and the bottom half of the old one, a number nobody ever wrote. Declaring the field volatile makes its reads and writes atomic. On today's 64-bit HotSpot you won't see tearing in practice, but the spec still allows it, so portable code shouldn't rely on that.
The toolbox, in order of preference. The cheapest race is the one you design away.
| Tool | How it removes the race | Use it when |
|---|---|---|
| Confinement | Only one thread ever touches the data (a local, a per-request object, ThreadLocal) |
You can avoid sharing at all. Best option. |
| Immutability | Nobody can write it (final fields, records, List.of) |
Shared config, value objects, messages between threads |
| Concurrent collection atomic methods | The map does the compound action in one call | Counters and caches keyed by something: merge, computeIfAbsent |
AtomicInteger / AtomicLong |
One hardware compare-and-swap per update | A single counter or flag, moderate contention |
LongAdder |
Spreads increments over per-thread cells, sums on read | Hot counters (metrics) written far more than read |
synchronized / a lock |
One thread at a time in the critical section | Several fields must change together (balance plus history) |
My default: don't share; if you must share, share immutable data; if it must change, reach for one atomic call; use synchronized when the invariant spans more than one variable. The "Atomic classes" and "Compare-and-swap and the ABA problem" lessons go deeper on the middle rows.
Here's one run of four threads doing 1,000,000 increments each on the 14-core laptop: plain count++ finished in 2 ms with a wrong answer (1,044,759). synchronized took 236 ms, AtomicInteger 56 ms and LongAdder 4 ms, all correct at 4,000,000. That's a quick demo, not a benchmark (no JMH, numbers shift run to run), but the order is typical under heavy contention: the LongAdder Javadoc says it trades space for higher throughput than AtomicLong when many threads update. Correct and slower beats fast and wrong every time.
Hunting races. You can't prove absence by running code once, but you can raise the odds:
- Stress tests. Many threads, a starting gate (
CountDownLatch) so they collide, thousands of repetitions, and an assertion on the invariant. OurDoubleSpenddemo is exactly that. - OpenJDK jcstress. The harness the JDK team uses to test the memory model itself. You write a tiny test with two or more actor methods, it runs them billions of times across thread pairings, and reports every outcome it saw, labelled acceptable, interesting or forbidden.
- Code-review smells. A
getfollowed by aput. Anifon shared state followed by a write.++or+=on a field. Astaticmutable field, or astaticSimpleDateFormat. A "thread-safe" class whose callers glue two calls together.
SimpleDateFormat, a race in the JDK's own API. It keeps parse state in instance fields, and its Javadoc says plainly that date formats are not synchronized. Sharing one static final instance is a classic bug. I ran 4 threads parsing two fixed dates 10,000 times each through one shared instance:
40,000 parses: 27125 wrong dates, 368 exceptions
Two thirds came back wrong, silently, and a few hundred threw. Use java.time.format.DateTimeFormatter, which is immutable and safe to share.
12How to say it in the interview
When I ask this, I'm listening for three things. Do you name the cause (shared mutable state, non-atomic compound actions) rather than "threads are random"? Can you break count++ into its three steps and draw the bad interleaving? And do you know that thread-safe building blocks don't make your sequence of calls safe? Here's how I'd handle the classic four:
- "What is a race condition?" A bug where the result depends on the timing of threads touching shared mutable state, with an example: two threads incrementing one counter and losing updates.
- "Why is
count++not thread-safe?" It compiles togetfield,iadd,putfield. Two threads can both read the old value, so one write overwrites the other. Fix withAtomicIntegeror a lock, notvolatile. - "Race condition vs data race?" A data race is the JMM term: conflicting accesses not ordered by happens-before. A race condition is a timing-dependent wrong result.
ConcurrentHashMapgetthenputis a race condition with no data race. - "Is
ConcurrentHashMapenough?" For single calls, yes. For check-then-act or read-modify-write across calls, no: usemerge,putIfAbsent,computeIfAbsentorcompute.
In the interview
"A race condition is when the correctness of your result depends on how threads happen to interleave on shared mutable state. The textbook case is count++. It looks like one step, but it's a read, an add and a write, so two threads can both read 5 and both write 6. I've seen it lose over half the increments with four threads. There are really two shapes: read-modify-write like that, and check-then-act, like 'if the key's missing, put it' or 'if the balance is enough, withdraw'. The fix is to make the compound action atomic. First I'd avoid sharing or make the data immutable. If it has to be shared and mutable, I'd use a single atomic operation, AtomicInteger or ConcurrentHashMap.merge, and a lock when several fields have to change together. One thing I'd call out: a thread-safe collection doesn't make two calls in a row safe."
On the whiteboard
- Race = result depends on timing; needs shared + mutable + uncoordinated
- Locals never race; heap state (fields, statics) can
count++=getfield/iadd/putfield: interleave, lose updates- Shapes: read-modify-write, check-then-act
- Thread-safe map ≠ safe sequence:
merge,computeIfAbsent - Data race (JMM, no happens-before) ≠ race condition (logic)
- Fix order: confine, immutable, atomic call, lock
- Hide on laptops, appear under load: stress test, jcstress
Here's that whole lesson as the board I'd leave behind after the interview:
Race conditions on one whiteboard
Further reading (14)
- Java Language Specification (Java SE 21) §17.4.5 Happens-before Order: conflicting accesses and the formal definition of a data race; correctly synchronized programs
- Java Language Specification (Java SE 21) §17.7 Non-Atomic Treatment of double and long: a non-volatile 64-bit write may be split into two 32-bit writes; volatile long and double are atomic
- Java Virtual Machine Specification (Java SE 21) Chapter 6: getfield, iadd, putfield: the three separate instructions behind count++
- ConcurrentHashMap (Java SE 21 API): computeIfAbsent and compute are performed atomically; aggregate status methods are only estimates
- AtomicInteger (Java SE 21 API): incrementAndGet, compareAndSet
- LongAdder (Java SE 21 API): higher throughput than AtomicLong under high contention, at the cost of more space
- SimpleDateFormat (Java SE 21 API): date formats are not synchronized; create separate instances per thread; use DateTimeFormatter
- OpenJDK Code Tools: jcstress: the Java Concurrency Stress harness used to test the JVM memory model and concurrent code
- Java Concurrency in Practice (Goetz et al., 2006), chapters 2 and 3: race conditions, compound actions, check-then-act, read-modify-write, thread confinement and immutability
- Paul Tyma: A Beautiful Race Condition: how a concurrent HashMap resize created a cycle in a bucket list and sent get() into an infinite loop
- JDK-6423457: (coll) High cpu usage in HashMap.get(): the classic report of HashMap.get spinning forever under concurrent use; closed as not a defect because HashMap is not thread-safe
- Leveson and Turner: An Investigation of the Therac-25 Accidents (IEEE Computer, 1993): race conditions in the treatment-setup software contributed to massive radiation overdoses between 1985 and 1987
- Egor Homakov: Hacking Starbucks for unlimited coffee: a gift-card transfer race (check balance, then move money) let parallel requests create money
- Aleksey Shipilev: Close Encounters of The Java Memory Model Kind: data races, benign races like String.hashCode, and jcstress examples