SECTION 1
Historical Context & Motivation
As computers evolved from running one task at a time to juggling many tasks simultaneously, a sneaky new category of bugs appeared. A race condition happens when two or more parts of a program compete to read or write the same piece of data at the same time, and the final result depends on which one finishes first. These bugs are notoriously difficult to find because they do not happen every time you run the program — they depend on timing and luck.
The concept of concurrency — doing multiple things at once — dates back to the earliest operating systems. Engineers quickly realized that when two processes share memory or files, unpredictable ordering can corrupt data. Java, released in 1995, was one of the first mainstream languages to build multithreading directly into the language. That decision made concurrent programming more accessible, but it also meant every Java developer needed to understand race conditions.
1965 • Dijkstra's Mutual Exclusion
1971 • Semaphore Concept
1995 • Java Launches with Built-in Threads
Thread class and the synchronized keyword, making multithreading a core language feature rather than an OS-level add-on.2004 • java.util.concurrent Package
2010s+ • Multi-core Processors Everywhere
The central question this lesson addresses is: How can two threads working with the same variable produce a wrong answer, and how do we recognize when this is happening? Understanding this question is the first step toward writing correct, reliable concurrent Java programs.
SECTION 2
Core Principles & Definitions
Before diving into examples, you need to know a handful of key terms. A thread is an independent path of execution inside a program. Think of it like a separate worker who can carry out instructions at the same time as other workers. When multiple threads access the same variable or object, that data is called shared state. The danger zone — the chunk of code where shared state is read or modified — is known as a critical section.
Thread
main thread), and you can create more to perform tasks concurrently.Shared State
Race Condition
Critical Section
Atomicity
count++ looks atomic but is actually three steps: read, add, write.KEY TAKEAWAY
SECTION 3
Visual Explanation — Two Threads, One Variable
The diagram below shows the classic race condition scenario. Two threads both try to increment the same count variable. Each increment consists of three micro-steps: READ the current value, ADD one to it, and WRITE the new value back. If the threads interleave these steps, one update gets lost.
count as 0, independently compute 1, and both write 1. The expected result after two increments is 2, but only 1 is stored. Thread B's write overwrites Thread A's, causing a lost update.Notice that neither thread made a logical error. Each one correctly read the current value and added one. The bug is entirely about timing. If Thread A had finished writing before Thread B started reading, the result would have been correct. This is what makes race conditions so dangerous — the code looks perfectly fine, and the bug only appears under certain scheduling conditions.
SECTION 4
How It Works — The Read-Modify-Write Problem
The root cause of most race conditions is the read-modify-write pattern. Any time your code reads a value, computes something based on it, and writes the result back, there is a window of time during which another thread could interfere. In Java, even a simple statement like count++ is not a single operation at the machine level. The JVM breaks it into three separate steps.
DECOMPOSITION OF count++
Between any two of these steps, the JVM's thread scheduler can pause the current thread and let another one run. This gap is the window of vulnerability.
A race condition requires three ingredients: (1) shared state that multiple threads can access, (2) at least one thread that writes to that state, and (3) no synchronization mechanism protecting the critical section. Remove any one of these three ingredients, and the race condition disappears.
THREE INGREDIENTS FOR A RACE CONDITION
If data is not shared (each thread has its own copy), or if all threads only read (no writes), or if access is synchronized (only one thread enters the critical section at a time), the race condition cannot occur.
Check-Then-Act: Another Common Pattern
if (map.containsKey(key)) { map.get(key).doSomething(); }. Between the check and the action, another thread could remove the key, causing a NullPointerException.SECTION 5
Types of Race Conditions & How to Spot Them
Race conditions come in several flavors, and each one leaves different clues. The diagram below categorizes the most common types you will encounter in Java programs, along with the symptoms that suggest a race condition is present.
One of the trickiest aspects of race conditions is the Heisenbug phenomenon (named after Heisenberg's uncertainty principle in physics). When you add debugging statements like System.out.println() or step through the code in a debugger, you slow the threads down just enough that they no longer collide. The bug disappears, making you think you imagined it. If a bug only shows up in production or under heavy load, race conditions should be your first suspect.
- ✓ Red flag 1: A shared variable that multiple threads write to without
synchronizedblocks or locks. - ✓ Red flag 2: A conditional check followed by an action on the same shared data, without both being inside the same lock.
- ✓ Red flag 3: Unit tests that pass 99% of the time but randomly fail.
- ✓ Red flag 4: Counter values that are lower than expected after concurrent operations.
SECTION 6
Worked Example — Bank Account Race Condition
Let's walk through a concrete Java scenario. Imagine a simplified bank account class where two threads each try to withdraw money at the same time. We will trace the execution step-by-step to see how a race condition leads to an overdraft that should have been prevented.
The Code
class BankAccount {
int balance = 100;
void withdraw(int amount) {
if (balance >= amount) { // CHECK
balance = balance - amount; // ACT
}
}
}
Thread A calls withdraw(80) and Thread B calls withdraw(70) simultaneously.Tracing the Race Condition
- 1
Step 1 — Thread A checks the condition
Thread A executesif (balance >= amount). Sincebalanceis 100 andamountis 80, the condition istrue. Thread A is about to subtract, but the scheduler pauses it here.Thread A: condition = true, balance still = 100 - 2
Step 2 — Thread B checks the condition
Before Thread A can write, Thread B runs the same check. It also readsbalanceas 100 (because Thread A hasn't modified it yet). Since 100 ≥ 70, Thread B's condition is alsotrue.Thread B: condition = true, balance still = 100 - 3
Step 3 — Thread A performs the withdrawal
Thread A resumes and executesbalance = 100 - 80. The balance is now 20.balance = 20 - 4
Step 4 — Thread B performs the withdrawal
Thread B resumes. It already passed the check (back in Step 2), so it proceeds to executebalance = 100 - 70. Wait — Thread B still uses the value 100 that it read earlier! The balance becomes 30. But this is wrong: both withdrawals totaling 150 from a 100-balance account should have been blocked.balance = 30 (or 20, depending on timing) — both withdrawals succeeded! - 5
Step 5 — Identify the bug
The total withdrawn is 80 + 70 = 150, but the account only had 100. This is a classic check-then-act race condition. The check (is there enough money?) and the action (subtract the money) were not performed as a single atomic operation. Another thread slipped in between the check and the act, using stale information.Fix: wrap the entire if-block in a synchronized block to make check + act atomic.
SECTION 7
Prevention Strategies — Strengths & Limitations
Java provides several tools to prevent race conditions. Each approach has trade-offs between simplicity, performance, and flexibility. The table below compares the most common strategies at a conceptual level.
| Strategy | How It Works | Strengths | Limitations |
|---|---|---|---|
synchronized | Only one thread can execute the synchronized block at a time. Other threads wait until the lock is released. | Simple to use; built into the language; prevents both read-modify-write and check-then-act bugs. | Can reduce performance if many threads are waiting; risk of deadlock if locks are acquired in the wrong order. |
Atomic Variables (AtomicInteger) | Uses special CPU instructions (compare-and-swap) to perform read-modify-write in a single uninterruptible step. | Very fast for simple operations like incrementing counters; no blocking or waiting. | Only works for single-variable operations; cannot protect multi-step compound actions. |
| Immutable Objects | Once created, the object's state never changes. No writes means no race conditions. | Completely thread-safe by design; no synchronization overhead. | Not practical when you genuinely need to update state (e.g., counters, balances). |
| Thread-Local Data | Each thread gets its own private copy of the variable. No sharing means no racing. | Eliminates shared state entirely for that variable; zero contention. | Only useful when threads don't need to communicate through that variable. |
Comparison of race condition prevention strategies in Java
KEY TAKEAWAY
SECTION 8
Connection to Advanced Concurrency Concepts
Understanding race conditions is the foundation for several more advanced topics in Java concurrency. As you continue learning, you will encounter concepts that build directly on what you have learned here.
| This Lesson | Advanced Concept | Connection |
|---|---|---|
| Race condition (two threads, one variable) | Deadlock | Deadlock occurs when two threads each hold a lock the other needs. It is the flip side of over-synchronizing to prevent races. |
synchronized keyword | ReentrantLock & Condition | More flexible locking with features like try-lock, timed waits, and the ability to interrupt a waiting thread. |
| Shared mutable state | Volatile keyword & Memory Model | The Java Memory Model defines when changes by one thread become visible to others. The volatile keyword ensures visibility but does not guarantee atomicity. |
| Atomic counter increments | Lock-Free Data Structures | Advanced collections (like ConcurrentHashMap) use compare-and-swap internally to avoid locks entirely, achieving high throughput. |
The key insight to carry forward is that concurrency bugs are design problems, not coding typos. You cannot reliably test your way out of a race condition because the bug depends on timing. Instead, you must reason about which data is shared, which threads access it, and whether proper synchronization is in place. This kind of careful reasoning becomes even more important as you work with multi-threaded servers, mobile applications, and distributed systems.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
count++ is not safe to use in a multithreaded Java program without synchronization. What are the hidden steps?Show answer
count++ appears to be a single operation, but it is actually three steps: read the current value of count, add 1 to it, and write the result back. Between any of these steps, the thread scheduler can pause the current thread and let another thread run. If a second thread reads count before the first thread writes its updated value, both threads will compute the same result and one increment will be lost. This is a read-modify-write race condition.Question 2: BASIC
count++ exactly 1,000 times on a shared variable count that starts at 0. If there is no synchronization, what is the range of possible final values for count?Show answer
Question 3: INTERMEDIATE
if (!list.contains(item)) {
list.add(item);
}
Two threads execute this code simultaneously with the same item on a shared ArrayList. What race condition pattern is this, and what could go wrong?Show answer
list.contains(item) and gets false. Before Thread A can add the item, Thread B also checks and gets false. Both threads then add the item, resulting in a duplicate entry in the list. The check and the add are not performed atomically, so the check becomes stale by the time the add executes. To fix this, both the check and the add should be inside the same synchronized block.Question 4: APPLIED
ticketsRemaining is shared by 50 threads (one per cashier). Each thread does: check if ticketsRemaining > 0, and if so, decrements it and prints a confirmation. Without synchronization, describe a specific scenario where the system sells more tickets than actually exist. Then propose a fix.Show answer
ticketsRemaining = 1. Cashier thread #12 checks and sees 1 > 0, so it enters the if-block. Before it decrements, Cashier thread #37 also checks and sees 1 > 0 (the decrement hasn't happened yet). Both threads decrement the counter: thread #12 sets it to 0, thread #37 also sets it to 0 (or even −1 depending on timing). Two customers receive confirmations but only one ticket existed. This is a check-then-act race. The fix is to wrap the entire check-and-decrement operation in a synchronized block, or use AtomicInteger.decrementAndGet() with a subsequent check on the returned value.Question 5: CRITICAL THINKING
Show answer
SUMMARY
Lesson Summary
Summary
A race condition occurs when two or more threads access shared state concurrently, and at least one thread writes to it, without proper synchronization. The two most common patterns are read-modify-write (like count++) and check-then-act (like checking a balance before withdrawing). Both patterns involve a gap between two operations that should be atomic but are not.
The symptoms of a race condition include inconsistent results across runs, bugs that vanish under a debugger (Heisenbugs), and counters or balances that are lower than expected. Java provides tools to prevent these issues, including the synchronized keyword, atomic variables, and immutable objects. Remember: you cannot reliably test away a race condition. The key is to reason about your code — identify shared data, check for concurrent writes, and verify that critical sections are properly protected.