Java Tutorial
🔍

Race Conditions

Race Conditions

A race condition happens when two or more threads access shared mutable state concurrently, at least one of them modifies it, and the program's correctness ends up depending on the unpredictable order the threads actually happen to run in. The defining, frustrating trait of a race condition is that the same code can produce a correct result on one run and a wrong one on the next.

What Is a Race Condition?

A race condition is not a crash and not a compiler warning — it is a correctness bug that only shows up when timing lines up the wrong way. stockRemaining-- looks like one operation, but it is actually three separate steps: read the value, subtract one, write it back. Nothing in the language stops two threads from interleaving those steps against each other.

A race condition needs at least two things at once: shared mutable state, and no coordination over who touches it when.

Why Thread Safety Matters Here

A race condition rarely announces itself. The code compiles, the happy-path test passes, and the bug only surfaces once real concurrent load hits the exact interleaving that breaks it — often weeks after the code shipped, and often in a way that is nearly impossible to reproduce on a developer's machine afterward. That combination — silent, intermittent, and hard to reproduce on demand — is what makes race conditions more dangerous in production than almost any other class of bug a fresher will encounter.

During code reviews, seniors commonly flag any method that reads a shared field and later writes a value derived from that read, even when nothing about the code looks obviously threaded — because the caller might be threaded, even if the method itself gives no hint of it.

How It Works

A short sentence before the diagram: the following trace shows exactly where a decrement gets lost when two threads interleave on an unsynchronized counter.

Time  Thread A                  Thread B                  stockRemaining
----  ------------------------  ------------------------  --------------
t0    read stockRemaining = 5                              5
t1                              read stockRemaining = 5    5
t2    compute 5 - 1 = 4                                    5
t3    write stockRemaining = 4                             4
t4                              compute 5 - 1 = 4           4
t5                              write stockRemaining = 4    4

Both threads read the same starting value, both computed the same result, and both wrote it back — the counter should have ended at 3, but one entire decrement vanished with no exception and no warning. This is called a lost update, and it takes two common shapes in real code.

A lost update, traced above, happens when two threads both read the same value before either writes back the result of modifying it. A check-then-act race is a close cousin — two threads both check a condition is true before either acts on it, so both proceed even though only one should have.

1// File: CheckThenActRace.java 2// Illustrative only - two threads can both see initialized == false 3// before either sets it to true, so both may perform the initialization. 4public class CheckThenActRace { 5 6 static boolean initialized = false; 7 8 static void initializeIfNeeded() { 9 if (!initialized) { 10 // expensive setup work would go here 11 initialized = true; 12 } 13 } 14}

Both shapes show up constantly in real systems: shared counters and accumulators whenever more than one thread increments or decrements the same field, lazy initialization without proper locking exactly as CheckThenActRace shows above, compound operations on a shared collection such as "add if not already present," and stock or quota enforcement where a limited resource must not be handed out more times than it actually exists — covered in full in the real-world example below.

Code Examples

Running ten threads against a plain, unsynchronized counter reliably loses updates — the exact final value varies from run to run and machine to machine, which is precisely the danger.

1// File: BeforeRaceCondition.java 2// Illustrative only - do not rely on this for a correct count. Without 3// synchronization, this reliably loses updates under concurrent access, 4// but the exact final value differs from run to run and machine to machine. 5import java.util.concurrent.CountDownLatch; 6 7public class BeforeRaceCondition { 8 9 static int stockRemaining = 100; 10 11 public static void main(String[] args) throws InterruptedException { 12 int threadCount = 10; 13 CountDownLatch done = new CountDownLatch(threadCount); 14 15 for (int i = 0; i < threadCount; i++) { 16 new Thread(() -> { 17 for (int j = 0; j < 10; j++) { 18 stockRemaining--; 19 } 20 done.countDown(); 21 }).start(); 22 } 23 24 done.await(); 25 System.out.println("Expected 0, got: " + stockRemaining); 26 } 27}

Wrapping the decrement in synchronized makes the read-subtract-write sequence happen as one uninterruptible step, so the final total is guaranteed correct every time.

1// File: AfterRaceCondition.java 2import java.util.concurrent.CountDownLatch; 3 4public class AfterRaceCondition { 5 6 static int stockRemaining = 100; 7 static final Object LOCK = new Object(); 8 9 public static void main(String[] args) throws InterruptedException { 10 int threadCount = 10; 11 CountDownLatch done = new CountDownLatch(threadCount); 12 13 for (int i = 0; i < threadCount; i++) { 14 new Thread(() -> { 15 for (int j = 0; j < 10; j++) { 16 synchronized (LOCK) { 17 stockRemaining--; 18 } 19 } 20 done.countDown(); 21 }).start(); 22 } 23 24 done.await(); 25 System.out.println("Expected 0, got: " + stockRemaining); 26 } 27}
Output:
Expected 0, got: 0

Ten threads each decrementing ten times should leave stockRemaining at exactly zero — the unsynchronized version cannot reliably promise that; the synchronized version can.

Real-World Example

A flash-sale coupon service must never redeem more coupons than it actually has, even when many customers attempt to redeem one at the exact same moment — solved with a compare-and-swap retry loop instead of a plain check-then-decrement.

1// File: CouponRedemptionService.java 2import java.util.concurrent.atomic.AtomicInteger; 3 4public class CouponRedemptionService { 5 private final AtomicInteger remaining; 6 7 public CouponRedemptionService(int totalCoupons) { 8 this.remaining = new AtomicInteger(totalCoupons); 9 } 10 11 public boolean redeem() { 12 int current; 13 do { 14 current = remaining.get(); 15 if (current <= 0) { 16 return false; 17 } 18 } while (!remaining.compareAndSet(current, current - 1)); 19 return true; 20 } 21 22 public int getRemaining() { 23 return remaining.get(); 24 } 25}
1// File: CouponRedemptionDemo.java 2import java.util.concurrent.CountDownLatch; 3import java.util.concurrent.atomic.AtomicInteger; 4 5public class CouponRedemptionDemo { 6 public static void main(String[] args) throws InterruptedException { 7 CouponRedemptionService service = new CouponRedemptionService(5); 8 int customerCount = 20; 9 CountDownLatch done = new CountDownLatch(customerCount); 10 AtomicInteger successCount = new AtomicInteger(); 11 12 for (int i = 0; i < customerCount; i++) { 13 new Thread(() -> { 14 if (service.redeem()) { 15 successCount.incrementAndGet(); 16 } 17 done.countDown(); 18 }).start(); 19 } 20 21 done.await(); 22 23 System.out.println("Successful redemptions: " + successCount.get()); 24 System.out.println("Remaining coupons: " + service.getRemaining()); 25 } 26}
Output:
Successful redemptions: 5
Remaining coupons: 0

Twenty customers race for exactly five coupons, and exactly five succeed — never more, regardless of how the twenty threads happen to be scheduled. A mistake that appears often in fresher pull requests is checking a stock count with a plain if statement and then separately calling a decrement as two distinct operations, assuming that because the counter itself is atomic, the check-then-decrement sequence built on top of it is safe too — it is not, since another thread can slip in between the check and the decrement. Looping on compareAndSet() until it succeeds, exactly as redeem() does here, is what actually closes that gap.

Best Practices

Identify every piece of shared mutable state explicitly, and treat every operation that reads and then writes it as a potential race unless it is provably atomic.

Prefer immutable data wherever possible — an object that is never modified after construction cannot have a race condition on it at all, since there is nothing to concurrently change.

Use a compare-and-swap retry loop, exactly as CouponRedemptionService.redeem() does, for a single-value check-then-act pattern, rather than a plain synchronized block when lock-free atomics are sufficient.

volatile guarantees visibility but not atomicity — necessary but not sufficient to fix a race condition on its own. compareAndSet() and the rest of the atomic classes are what actually make a check-then-act sequence safe without full locking, and ConcurrentHashMap offers the same kind of atomic per-key check-then-act operations, like computeIfAbsent(), for map entries specifically.

Do not trust that a race condition's absence in testing means it does not exist — a race can go unnoticed through thousands of test runs and still surface reliably once real production load introduces the right timing.

Common Mistakes

Assuming a race that "rarely happens in testing" is safe to ship is a genuinely dangerous mindset — race conditions are famously hard to reproduce on demand precisely because they depend on timing, and a race that never showed up in a light test environment can surface constantly once real concurrent load hits it in production.

Assuming a thread-safe collection like Collections.synchronizedList() makes every operation built on top of it safe is a second, very common mistake — each individual method call is thread-safe, but a compound check-then-act sequence built from two separate calls is not.

1// File: SynchronizedCollectionMistake.java 2// Illustrative only - Collections.synchronizedList() makes each individual 3// method call thread-safe, but a check-then-act sequence built from two 4// separate calls is still not atomic as a whole. 5import java.util.*; 6 7public class SynchronizedCollectionMistake { 8 static List<String> names = Collections.synchronizedList(new ArrayList<>()); 9 10 static void addIfAbsent(String name) { 11 if (!names.contains(name)) { 12 names.add(name); 13 } 14 } 15}

Two threads can both call contains() and see false before either calls add(), resulting in a duplicate entry — the fix is to synchronize on the list itself around the entire check-then-act sequence, not to rely on the list's own internal synchronization for anything beyond each individual call.

Interview Questions

Q1. What is a race condition?

A situation where two or more threads access shared mutable state concurrently, at least one modifies it, and the program's correctness depends on the unpredictable order the threads actually run in — producing a result that can differ from run to run. Interviewers are listening for whether you name timing, not just "multiple threads," as the actual cause.

Q2. Why is count++ not thread-safe even though it looks like a single operation?

Because it actually compiles to three separate steps — read the current value, add one, write the result back — and another thread can interleave between any of those steps, causing an update to be silently lost, exactly as demonstrated in this article's BeforeRaceCondition example. The nuance being tested is whether you can name the three sub-steps specifically, not just assert "it's not atomic" without explaining why.

Q3. What is a check-then-act race, and how does it differ from a lost-update race?

A lost-update race loses one of two concurrent writes to the same value. A check-then-act race is broader — two threads both evaluate a condition as true before either acts on it, letting both proceed with an action meant to happen only once, exactly as CheckThenActRace illustrates. Product-company interviewers often push further and ask you to name a real example, like double-spending a coupon or double-creating a resource.

Q4. Why can't a race condition be reliably reproduced by running a test a few times?

Because whether the race actually manifests depends on the precise timing and scheduling of threads at runtime, which varies between runs, machines, and load conditions — a race can pass thousands of light test runs and still occur reliably under real production concurrency. The nuance interviewers want is an acknowledgment that "it passed in testing" is not evidence of correctness for concurrent code.

Q5. How does compareAndSet() solve a check-then-act race that a plain read-then-write cannot?

compareAndSet(expectedValue, newValue) only applies the update if the value has not changed since it was read — if another thread modified it in between, the call fails and the caller retries with the new current value, closing the exact window a plain separate read-then-write leaves open. Interviewers are specifically listening for whether you mention the retry loop, since a single unchecked compareAndSet() call is itself a common bug.

Q6. Does making a collection's individual methods synchronized eliminate all race conditions when using that collection?

No, as demonstrated in this article's Common Mistakes section — each individual method call becomes thread-safe on its own, but a compound operation built from two or more separate calls, like "add if not present," is still a check-then-act race unless the whole sequence is synchronized together. This question separates candidates who have only memorized "synchronizedList is thread-safe" from those who understand what that guarantee actually covers.

Q7. What are the two general categories of fix for a race condition?

Mutual exclusion — using synchronized or an explicit Lock so only one thread executes the critical section at a time — and lock-free atomic operations — using compare-and-swap through the atomic classes, as this article's coupon redemption example does, so the check-then-act sequence succeeds or safely retries without ever blocking. Interviewers want concrete Java constructs named for both categories, not just the abstract terms.

FAQs

Is a race condition the same thing as a data race?

They are closely related but not perfectly synonymous. A data race is the precise technical term for unsynchronized concurrent access to the same memory location where at least one access is a write. A race condition is the broader term for any situation where correctness depends on timing — in everyday Java discussion, the two terms are often used interchangeably.

Can a race condition occur with just a single thread?

No. A race condition inherently requires at least two concurrent flows of execution competing over shared state — a single thread's own execution is always strictly sequential with respect to itself.

Does making a variable volatile eliminate race conditions on it?

No, not on its own — volatile guarantees visibility of individual reads and writes, but not atomicity of a compound operation.

Are race conditions specific to Java, or a general concurrent programming problem?

They are a general concurrent programming problem, present in any language or system where multiple threads or processes share mutable state — Java's specific tools for addressing them (synchronized, Lock, the atomic classes) differ from other languages, but the underlying problem is universal.

Can tools automatically detect race conditions?

Partially. Static analysis tools and specialized runtime detectors can catch some patterns, but race conditions are notoriously difficult to detect exhaustively, since whether one manifests often depends on timing that a given analysis run may not explore — careful code review and disciplined use of the fixes covered in this article remain essential.

Is a race condition always a bug, or can it sometimes be harmless?

In rare, deliberately-designed cases, a race on a purely approximate value where exact precision genuinely does not matter can be considered acceptable — but treating any race as intentionally harmless is risky, and should be an explicit, well-documented exception rather than a default assumption.

Does using an immutable object eliminate the possibility of a race condition on it?

Yes. An object that is never modified after construction has no mutable state for concurrent threads to race over — this is exactly why favoring immutability, covered in this article's Best Practices section, is one of the strongest general defenses against race conditions.

Summary

A race condition arises whenever shared mutable state is accessed concurrently without coordination, and it takes two common shapes — a lost update, where a write silently disappears, and a check-then-act race, where a condition and the action based on it are not evaluated as one atomic step. synchronized and Lock fix both through mutual exclusion; the atomic classes' compare-and-swap operations, demonstrated in this article's coupon redemption example, fix both without blocking at all.

The habit worth carrying forward from this article is treating every compound operation on shared state — not just the individual reads and writes, but the sequences built from them — as a potential race, and remembering that a race's absence in testing is never proof of its absence in production.

What to Read Next