Course contents

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.

FreeMust know for interviewsLesson24 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: two cashiers, one ledger
  2. What can race, and what can't
  3. Under the hood: count++ is three steps
  4. The two shapes every race takes
  5. "But I used a thread-safe collection"
  6. Atomicity and critical sections, in plain words
  7. Race condition vs data race
  8. Why races hide on your laptop
  9. A related teaser: visibility
  10. Real world
  11. Advanced: tearing, the toolbox, and hunting races
  12. 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)

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 using javap (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 ConcurrentHashMap is still racy, and fix it with merge, putIfAbsent and computeIfAbsent
  • 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/double tearing and shared SimpleDateFormat

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:

Fig. 1

Two cashiers, one paper ledger

Two cashiers, one paper ledgerTwo cashiers stand at one shared paper ledger that says the till holds 100. Cashier A sells a 10 coffee and cashier B sells a 5 muffin. Both read 100 at the same moment, A writes 110 and then B writes 105 over it. The ledger ends at 105 instead of 115: the coffee sale vanished.Cashier ACashier BLEDGERtill: 100reads 100reads 100writes 110writes 10512A sold a coffee (+10). B sold a muffin (+5).- expected: 115- the ledger says: 105- whoever writes last wins; the other sale is goneno error, no crash,just a wrong number
Two cashiers, one ledger. Nobody did anything wrong on their own. The bug only exists because both read before either wrote.

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:

Fig. 2

count++ is three steps, and two threads can interleave them

count++ is three steps, and two threads can interleave themTwo swimlanes, thread A and thread B, with the shared field count in the middle starting at 0. Step 1: A reads 0. Step 2: B reads 0. Step 3: A adds 1 in its own register, getting 1. Step 4: B adds 1, also getting 1. Step 5: A writes 1. Step 6: B writes 1. Two increments ran, but count ends at 1, not 2.Thread AThread Bcount = 0 (heap)1getfield: read 02getfield: read 03iadd: 0 + 1 = 14iadd: 0 + 1 = 15putfield: write 1count = 16putfield: write 1count = 1 (again)two increments, final count = 1, not 2
A lost update, step by step. Follow badges 1 to 6. Both threads read 0 before either writes, so the second write overwrites the first.

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:

  1. We lost more than half the increments. Not a rounding error: 2.7 million updates disappeared on every single run.
  2. The answer changes every run. Same code, same input, different output. That's the signature of a race.
  3. That odd tick line 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 big count += 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:

Fig. 3

Check-then-act: both threads pass the check before either acts

Check-then-act: both threads pass the check before either actsTwo people try to withdraw 100 from an account that holds 100. Step 1: Alice checks, balance is 100, enough. Step 2: Bob checks, balance is still 100, enough. Step 3: Alice takes 100. Step 4: Bob takes 100. The bank paid out 200 from a 100 balance, because the check was stale by the time Bob acted. The same shape sells the last concert seat twice.Alice (phone)Bob (ATM)Accountbalance: 100is 100 >= 100? yes1is 100 >= 100? yes2take 1003take 1004check (if)if (balance >= amount)true for BOTH, becausenobody has acted yetact (then)balance -= amount;200 paid out of 100same shape: the last seat sold twice
Check-then-act. Both checks say yes because neither withdrawal has happened yet. The check was true when it ran and stale by the time it mattered.

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.

Fig. 4

The fix: one key to the ledger room

The fix: one key to the ledger roomBefore: both cashiers reach the ledger at the same time and one sale is lost. After: the ledger sits in a small room with one key on a hook. Cashier A takes the key, reads, adds and writes, then hangs the key back. Cashier B waits at the door until then. Read, add and write now happen as one indivisible step: a critical section.Before: open deskABledgerboth read 100both writeone sale lostAfter: ledger room, one keyledgercritical sectionA has keyread, add, writeB waitsdoor lockedJava: synchronized (lock) { count++; }
One key to the ledger room. The read, the add and the write now happen while holding the key, so nobody can sneak in between them.

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 ConcurrentHashMap get then put above. Every access goes through the map's own synchronization, so there's no data race at all. The clicks still vanished. Same for if (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.

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

In the real 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.

In the real world

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.

In the real world

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

Where candidates slip

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.

Performance angle

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. Our DoubleSpend demo 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 get followed by a put. An if on shared state followed by a write. ++ or += on a field. A static mutable field, or a static SimpleDateFormat. 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 to getfield, iadd, putfield. Two threads can both read the old value, so one write overwrites the other. Fix with AtomicInteger or a lock, not volatile.
  • "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. ConcurrentHashMap get then put is a race condition with no data race.
  • "Is ConcurrentHashMap enough?" For single calls, yes. For check-then-act or read-modify-write across calls, no: use merge, putIfAbsent, computeIfAbsent or compute.

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:

Fig. 5

Race conditions on one whiteboard

Race conditions on one whiteboardA whiteboard summary. Left: what races, namely shared mutable state, and the two shapes, read-modify-write and check-then-act. Middle: the definitions of race condition and data race. Right: the fixes in order of preference: do not share, do not mutate, then one atomic step with an atomic class, a concurrent map method or a lock.What races?- shared + mutable + 2 threads- locals: never shared- fields, statics, shared objects: can beTwo shapes- read-modify-write count++, map get + put- check-then-act if (!has) put; if (bal >= x)Race condition- wrong RESULT depends on timing- a logic bugData race- 2 accesses, 1 write, same variable- no happens-before- a memory-model bugFix, in this order1. do not share (confine)2. do not mutate (immutable)3. one atomic step: AtomicInteger, merge(), computeIfAbsent()4. synchronized / lockHunt them- stress test, many runs- jcstress- review: get then put?
The whiteboard summary. What races, the two shapes, the two definitions, and the fixes in the order I'd reach for them.
Practise this topicSynchronization: interview questions & scenarios
Further reading (14)