> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Race Conditions in Java: Check-Then-Act Without a Data Race
- URL: https://www.ggorantala.dev/java-race-condition/
- Published: 2026-10-20T12:43:00.000Z
- Updated: 2026-10-30T13:44:20.000Z
- Description: A race condition in Java can survive a ConcurrentHashMap and synchronized on every call. Check-then-act, critical sections, and how to prove the bug is there.
- Author: Gopi Gorantala
- Tags: thread-safety, java-concurrency, java-memory-model

## Two buyers, one seat

```log
confirmed=200514 seats=200000 oversold=514 (map size 200000)
confirmed=200002 seats=200000 oversold=2   (map size 200000)
confirmed=200672 seats=200000 oversold=672 (map size 200000)

```

That's ten runs of a ticket-booking loop from my lab machine (OpenJDK 21.0.12.1, 2 vCPU Intel Xeon, x86\_64, four buyer threads), and every single run sold more seats than exist. Look at the map size though. 200,000 entries, exactly right. The map is fine. The *count of people told "booked"* is wrong, because two buyers each got a yes for the same seat and the second `put` quietly overwrote the first.

The map was a `ConcurrentHashMap`. Nothing in this program is a data race. And it still has a **race condition in Java** that sells seats twice. If you've ever said "it's a concurrent map, it's thread-safe", this one's for you.

I spent most of a day on this, and a good chunk of that went on a wrong turn I'll get to. The write-up exists so you don't repeat it.

## The short version

Two different things get called "race" and they're not the same thing.

A **data race** is a memory-model term. Two threads touch the same variable, at least one writes, and nothing orders them with a happens-before edge. JLS 17.4.5 defines it. The cure is synchronization: locks, `volatile`, atomics, safe publication. I covered the model in [the happens-before article](https://ggorantala.dev/java-memory-model-happens-before/?ref=ggorantala.dev).

A **race condition** is a correctness term. The program is only right if operations land in a lucky order. You can have a race condition with zero data races (my booking loop), and you can have a data race that never produces a wrong answer (a benign-looking racy `String` hash cache, which is exactly what `String.hashCode` does, on purpose, and safely, because of how it's built).

A race condition lives in a **compound action**: a sequence of steps that must behave as one. Check-then-act ("if the seat is free, take it") and read-modify-write ("read the balance, add, write it back") are the two shapes you'll meet nine times out of ten. Making each step thread-safe doesn't make the sequence atomic.

## Make the double-booking appear on demand

You need a JDK (I used OpenJDK 21.0.12.1; anything from 11 up runs this single-file program) and at least two cores, though the second experiment below shows even one core isn't safe.

```java
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class Booking {
    static final int SEATS = 200_000;

    public static void main(String[] args) throws Exception {
        String mode = args.length > 0 ? args[0] : "check-then-act";
        int threads = args.length > 1 ? Integer.parseInt(args[1]) : 4;
        ConcurrentHashMap<Integer, String> seats = new ConcurrentHashMap<>();
        AtomicLong confirmed = new AtomicLong();
        CountDownLatch gate = new CountDownLatch(1);
        Thread[] ts = new Thread[threads];
        for (int t = 0; t < threads; t++) {
            final String who = "buyer-" + t;
            ts[t] = new Thread(() -> {
                try { gate.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
                long mine = 0;
                for (int s = 0; s < SEATS; s++) {
                    if (mode.equals("check-then-act")) {
                        if (!seats.containsKey(s)) {   // look...
                            seats.put(s, who);         // ...then leap. Another thread can leap in between.
                            mine++;
                        }
                    } else {
                        if (seats.putIfAbsent(s, who) == null) mine++;  // one atomic step
                    }
                }
                confirmed.addAndGet(mine);
            }, who);
            ts[t].start();
        }
        gate.countDown();
        for (Thread t : ts) t.join();
        System.out.printf("%-14s confirmed=%d oversold=%d (map size %d)%n",
            mode, confirmed.get(), confirmed.get() - SEATS, seats.size());
    }
}

```

Step 1, run the broken version ten times:

```bash
for i in $(seq 10); do java Booking.java check-then-act 4; done

```

On my machine all ten runs oversold. The counts were 9, 63, 514, 2, 230, 271, 672, 35, 61 and 86\. Wildly uneven, which is the signature: the window is a few nanoseconds wide and the scheduler decides how often threads land in it.

Step 2, run the fixed version the same way, swapping in `put-if-absent`. Ten runs, ten times `oversold=0`.

A few levers I used and what they cost you. The `CountDownLatch` start gate makes all four threads begin the loop together, so they collide from the first iteration instead of drifting apart while they spin up. Two hundred thousand seats gives the window plenty of chances to be hit. Neither lever changes the logic. Production just has lower odds and a much longer wait.

What you'll see if you don't see it: with two threads I got oversold counts of 13, 6 and 2\. Fewer, but not zero. So run more iterations before concluding anything. **A run that prints `oversold=0` doesn't mean the code is safe.** It means that run got lucky.

And one more: I pinned the JVM to a single core with `taskset -c 0 java Booking.java check-then-act 4`. Oversold counts of 7, 11 and 9\. The OS can preempt a thread between the `containsKey` and the `put` on one core just as easily. Single-core doesn't save you. It only makes the window rarer, so your laptop test passes and your 64-core box doesn't.

Cleanup is nothing: no files, no background JVMs.

## What's actually happening

Here's one losing interleaving, thread by thread, for seat 7:

```log
buyer-1: containsKey(7)  -> false
buyer-2: containsKey(7)  -> false      <- both looked before either leapt
buyer-1: put(7, "buyer-1")             mine++   (buyer-1 thinks it owns seat 7)
buyer-2: put(7, "buyer-2")             mine++   (overwrites; buyer-2 thinks it owns seat 7)

```

Every individual call here is atomic and properly ordered. `ConcurrentHashMap` guarantees that. What it can't guarantee is that nothing happens *between* two calls, because it has no idea they belong together.

The thing worth saying out loud is where the **critical section** is. A critical section is the stretch of code that has to run without another thread interfering with the same state. In `check-then-act` it's both lines together: the check, and the act that depends on the check. Not the `put`. Not the `containsKey`. The pair. If you can't name the pair, you can't protect it.

And the result is permanently wrong, not just briefly stale. That's the discriminator between a race condition and a visibility problem. With visibility, waiting a bit helps and the system converges. Here the lie has already been told to the customer.

## Where the JDK stands on this

The JDK has been telling you this since Java 5, if you read the right paragraph. The `ConcurrentHashMap` Javadoc says retrievals reflect the most recently completed updates, and that `putIfAbsent`, `compute`, `computeIfAbsent` and `merge` are atomic. It doesn't say that two separate calls compose. They don't. `computeIfAbsent` arrived in Java 8 alongside the rewritten map, and it's the better tool when creating the value is the expensive part, with one caveat: keep the mapping function short and don't touch the same map from inside it.

I went looking for a smoking gun in Doug Lea's comments and didn't find one. The class comment never promises compound atomicity because there's nothing to promise. The sentence you want is in the Javadoc, and it's about the *single* methods.

## The wrong turn

My first instinct was that `Collections.synchronizedMap` would fix it. Synchronizing every method is what people reach for, and honestly it feels like it must work: no call can overlap another call, the map is a monitor-guarded object, how could anything go wrong?

```java
Map<Integer,String> seats = Collections.synchronizedMap(new HashMap<>());
if (!seats.containsKey(s)) { seats.put(s, who); mine++; }   // each call locks. The pair doesn't.

```

Five runs, all oversold: 4, 4, 7, 11, 6\. A fully lock-protected map, no data race anywhere, same bug. This is the cleanest demonstration I know that **race condition and data race are different failures**. The monitor makes each call atomic. It does nothing about the gap.

The fix there is a client-side lock, and it works because `synchronizedMap` uses the wrapper object itself as its mutex (the Javadoc tells you to synchronize on the map when iterating, for the same reason):

```java
synchronized (seats) {
    if (!seats.containsKey(s)) { seats.put(s, who); mine++; }
}

```

Five runs, five times `oversold=0`. The details of what `synchronized` guards, and on what, are in the existing [synchronized blocks and methods article](https://ggorantala.dev/how-to-use-synchronized-blocks-and-methods-correctly-in-java/?ref=ggorantala.dev). If you want the full tour of why `ConcurrentHashMap` doesn't need that and what it does instead, that's coming in [ConcurrentHashMap vs synchronizedMap](https://ggorantala.dev/concurrenthashmap-vs-synchronizedmap/?ref=ggorantala.dev).

## The code everyone copies

**Read-modify-write on an atomic.** `if (counter.get() < LIMIT) counter.incrementAndGet();` makes two atomic calls with a gap. Under load you'll overshoot the limit. Use `updateAndGet`, or a `compareAndSet` loop. [Atomics](https://ggorantala.dev/java-atomic-classes/?ref=ggorantala.dev) deserve their own article.

**`volatile` on a field you then increment.** Visible, not atomic. I did this one to death in the [volatile article](https://ggorantala.dev/java-volatile-keyword/?ref=ggorantala.dev).

**Lazy init with an unsynchronized null check.** `if (cache == null) cache = new Cache();` can construct two objects, and with a plain field it can also hand a thread a half-built one. The first half of that is a race condition; the second half is a data race, and it belongs to safe publication, [coming up next](https://ggorantala.dev/java-safe-publication/?ref=ggorantala.dev). I haven't built a lab for lazy init in this article, so treat that sentence as a pointer, not a measurement.

**Sleeping in a test until the bug stops.** If `Thread.sleep(50)` makes your flaky test pass, you've changed the timing, not the logic. That's how races hide.

**Wrapping the whole method in `synchronized` and calling it done.** It does fix the booking loop. It also serialises every caller, and it falls over the day the compound action spans two objects with two locks. Pick the smallest region that holds the invariant.

## Fixing it properly, and what each fix costs

Why each works, in one line, then the price:

| Fix                                              | Why it works                                        | What it costs                                                                   |
| ------------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------- |
| putIfAbsent, computeIfAbsent, merge, compute     | check and act become one atomic call inside the map | none worth worrying about; mapping functions must be short and side-effect free |
| AtomicInteger.updateAndGet or a CAS loop         | one atomic read-modify-write on a single variable   | retries under contention; only covers one variable                              |
| client-side synchronized or ReentrantLock        | only one thread is inside the pair at a time        | a lock to reason about and keep ordered with other locks                        |
| make the data immutable and swap whole snapshots | there's no in-place compound action to interleave   | allocation, and you still need one atomic swap point                            |
| single-thread the state                          | one owner, no interleaving                          | a queue and a handoff; latency                                                  |

The thing I'd do first, almost always, is look for a method on the structure that already *is* the compound action. People write the two-call version because they didn't know the one-call version existed.

None of these are "fixes" by themselves: adding `volatile`, adding `synchronized` to just one of the two calls, or raising a timeout. A lock that guards the `put` but not the `containsKey` leaves the check unguarded. The lock has to cover the whole critical section, and every path that touches that state has to use the same one.

## Catching it before prod does

You can't prove a race condition away with a test that passed. You can only argue it away ("this is one atomic call") or search hard for a counterexample.

What I'd actually do: write the stress test as above, with a start gate, a lot of iterations, and an invariant you can check at the end (confirmed equals seats). Run it on the biggest machine you have and again with `-XX:ActiveProcessorCount=1` and `taskset`, because different core counts produce different timing. For anything more serious than a handful of counters, use a tool that explores interleavings on purpose: jcstress for memory-model questions, Lincheck for linearizability of a data structure. I'll cover both in [testing concurrent code](https://ggorantala.dev/testing-concurrent-code-java/?ref=ggorantala.dev). I couldn't pull jcstress from Maven Central in this environment, so none of the numbers above come from it.

On version safety: nothing here changed across JDK versions. `putIfAbsent` has been atomic on `ConcurrentHashMap` since Java 5, and `computeIfAbsent` since 8\. I ran the labs on 21 only. A virtual thread doing the same check-then-act loses the same way, since the interleaving is the same.

## If you remember nothing else

- A thread-safe class makes each call safe. It says nothing about two calls in a row.
- Name the critical section before you pick a tool. If it's two lines, the lock or atomic call has to cover both.
- Data race and race condition are separate bugs. Fixing one doesn't fix the other.
- Prefer the single method that already does the compound thing (`putIfAbsent`, `merge`, `updateAndGet`).
- `oversold=0` on your laptop is a luck result. Count the failures across thousands of runs.

## Quick answers

**Is a ConcurrentHashMap enough to avoid race conditions?**  
Only for single operations. `containsKey` followed by `put` is still a race, as the lab shows. Use `putIfAbsent`, `compute`, or `merge` (Java 5 and 8 respectively).

**What's the difference between a data race and a race condition in Java?**  
A data race is an unordered conflicting access under the Java Memory Model (JLS 17.4.5). A race condition is a bug whose outcome depends on timing. You can have either without the other.

**Does synchronized on every method make a class free of race conditions?**  
No. It makes each method atomic. A caller doing check-then-act across two methods can still interleave, which my `synchronizedMap` run showed. Lock on the object around both calls.

**Can a race condition happen on a single-core machine?**  
Yes. The OS can preempt a thread mid-sequence. I got oversold bookings pinned to one core on JDK 21, just less often.

## Sources

- JLS 17.4.5, Happens-before Order (data race definition): [https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4.5](https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html?ref=ggorantala.dev#jls-17.4.5)
- `java.util.concurrent.ConcurrentHashMap` Javadoc, JDK 21 (atomic `putIfAbsent`, `compute`, `merge`)
- `java.util.Collections.synchronizedMap` Javadoc, JDK 21 (mutex and iteration note)
- Apache Bugzilla 53498, atomicity violations from misusing concurrent collections, observed 2026-10-24: [https://bz.apache.org/bugzilla/show\_bug.cgi?id=53498](https://bz.apache.org/bugzilla/show%5Fbug.cgi?id=53498&ref=ggorantala.dev)