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.
Thread class and the synchronized keyword, making multithreading a core language feature rather than an OS-level add-on.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.
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.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.
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.
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.
if (map.containsKey(key)) { map.get(key).doSomething(); }. Between the check and the action, another thread could remove the key, causing a NullPointerException.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.
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.
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.if (balance >= amount). Since balance is 100 and amount is 80, the condition is true. Thread A is about to subtract, but the scheduler pauses it here.balance as 100 (because Thread A hasn't modified it yet). Since 100 ≥ 70, Thread B's condition is also true.balance = 100 - 80. The balance is now 20.balance = 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.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. |
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.
Practice Problems
count++ is not safe to use in a multithreaded Java program without synchronization. What are the hidden steps?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?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?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.Lesson 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.