Hook: A race condition is the bug that hides in plain sight: each thread looks fine alone, but two threads arriving together can corrupt the result.
Question: What is a race condition in Java multithreading?
Answer: A race condition happens when the result depends on the timing or interleaving of two or more threads. In Java, it usually appears when threads share mutable data, like a counter or a list, without proper synchronization. The code may work most of the time, then fail randomly under load.
Interview-Ready Answer: A race condition is when multiple threads access shared mutable state and the final result depends on who runs first. For example, counter++ is not atomic, so two threads can both read the same old value and one update gets lost. I fix it with synchronized, Lock, or atomic classes like AtomicInteger, depending on whether I need simplicity or higher concurrency.
Detailed Explanation: The easiest way to remember a race condition is: same shared data + no proper coordination + unlucky timing. The bug is not that threads exist; the bug is that the program assumes they will take turns nicely, and the CPU does not promise that.
counter++, that means read the current value, add 1, then write it back.happens-before, which is the guarantee that one action becomes visible before another.That is why volatile is not enough for count++. volatile helps visibility, but it does not make the whole read-add-write sequence atomic, which means indivisible.
You use synchronization when multiple threads must safely update shared state. If you can avoid shared mutable state entirely, that is even better. Immutable data, meaning data that never changes after creation, is the cleanest defense because there is nothing to race over.
| Approach | What it gives | Best for | Gotcha |
|---|---|---|---|
| synchronized | Mutual exclusion + visibility | Simple critical sections | One lock at a time |
| ReentrantLock | Same protection + extras | Timeouts, try-lock, fairness | Must unlock in finally |
| AtomicInteger | Lock-free atomic update | Single counters/flags | Not for multi-step logic |
| volatile | Visibility only | Stop flags, state reads | Does not fix compound actions |
| Immutable data | No shared mutation | Read-mostly designs | Requires redesign |
The logical cost of a simple update is still O(1), but real speed depends on contention, meaning how many threads fight over the same lock or variable. On a server with 8 to 32 worker threads, a single shared counter can become a hotspot very quickly. AtomicInteger uses CAS, short for compare-and-set, which retries if another thread wins first; this is often fast, but under heavy contention it can spin and waste CPU. synchronized blocks, on the other hand, may block threads instead of retrying, which is simpler but can reduce throughput.
if (!list.contains(x)) list.add(x) is a race unless the whole check and add are protected together.volatile on the instance field.Memory hook: think of one whiteboard and two people writing at once. If both erase and rewrite the same line without agreeing on turns, someone’s message disappears.
Real-World Example: Imagine a checkout service during a flash sale. The inventory table says there is exactly one headset left. Two request threads enter the purchase flow at nearly the same time, both see stock = 1, and both try to reserve it. Without synchronization, both orders may succeed logically, then one inventory update is lost or the stock goes negative. Users see weird errors like duplicate reservations, payment captured but order cancelled later, or logs such as remainingStock=-1. In production, that bug turns into angry customers, manual refunds, and support tickets that are hard to reproduce because the timing window is tiny.
import java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class RaceConditionDemo {\n private static final int THREADS = 8;\n private static final int INCREMENTS_PER_THREAD = 100_000;\n\n interface Counter {\n void increment();\n int get();\n }\n\n static class UnsafeCounter implements Counter {\n private int value = 0;\n\n @Override\n public void increment() {\n // This is intentionally unsafe. We widen the timing window so the race\n // is easier to observe during a demo.\n int current = value;\n Thread.yield();\n value = current + 1;\n }\n\n @Override\n public int get() {\n return value;\n }\n }\n\n static class SynchronizedCounter implements Counter {\n private int value = 0;\n\n @Override\n public synchronized void increment() {\n value++;\n }\n\n @Override\n public synchronized int get() {\n return value;\n }\n }\n\n static class AtomicCounter implements Counter {\n private final AtomicInteger value = new AtomicInteger();\n\n @Override\n public void increment() {\n value.incrementAndGet();\n }\n\n @Override\n public int get() {\n return value.get();\n }\n }\n\n public static void main(String[] args) throws InterruptedException {\n runExperiment("UnsafeCounter", new UnsafeCounter());\n runExperiment("SynchronizedCounter", new SynchronizedCounter());\n runExperiment("AtomicCounter", new AtomicCounter());\n }\n\n private static void runExperiment(String name, Counter counter) throws InterruptedException {\n ExecutorService pool = Executors.newFixedThreadPool(THREADS);\n CountDownLatch startGate = new CountDownLatch(1);\n CountDownLatch doneGate = new CountDownLatch(THREADS);\n\n for (int i = 0; i < THREADS; i++) {\n pool.submit(() -> {\n try {\n // All workers start together so the race is much more likely.\n startGate.await();\n for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {\n counter.increment();\n }\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n } finally {\n doneGate.countDown();\n }\n });\n }\n\n long start = System.nanoTime();\n startGate.countDown();\n doneGate.await();\n long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);\n\n pool.shutdown();\n pool.awaitTermination(1, TimeUnit.SECONDS);\n\n int expected = THREADS * INCREMENTS_PER_THREAD;\n int actual = counter.get();\n\n System.out.printf("%s -> expected=%d, actual=%d, time=%d ms%n", name, expected, actual, elapsedMs);\n\n if (actual != expected) {\n System.out.println(" Loss detected: two threads overwrote each other's updates.");\n } else if ("UnsafeCounter".equals(name)) {\n System.out.println(" This run looked correct by luck; the race is still there.");\n }\n }\n}Follow-up & Tricky Questions:
volatile not fix counter++? Because volatile gives visibility, not atomicity. The read, add, and write can still be interrupted by another thread.synchronized and ReentrantLock? Both give mutual exclusion, but ReentrantLock adds tryLock, timed waits, and fairness options. synchronized is simpler and releases automatically when the block exits.Common Mistakes:
volatile for increments: wrong fix; use synchronized or atomic classes for compound updates.Memory Hook: Picture two people erasing and rewriting the same line on a whiteboard at the same time. Whoever writes last wins, and the other person’s change disappears.
Cheat Sheet:
counter++ is not atomic.volatile gives visibility, not safety for compound actions.synchronized and Lock provide mutual exclusion.AtomicInteger uses CAS for lock-free updates.Practice Tasks:
AtomicLong and compare the result.ReentrantLock version and verify correctness.if not present, then add helper for a shared list.