Race Conditions in Java
A race condition occurs when multiple threads access shared data concurrently and the program's result depends on the unpredictable timing or ordering of their operations.
The word "race" is appropriate: the threads are effectively racing to read, modify, or write shared state. If the operations are not properly coordinated, one thread can interfere with another and produce an incorrect or inconsistent result.
Core idea: A race condition is not simply "two threads running at the same time." It occurs when concurrent access to shared state can produce incorrect behavior because the required ordering or atomicity is not guaranteed.
Why Race Conditions Happen
Consider a shared counter:
class Counter {
int count = 0;
void increment() {
count++;
}
}
The expression count++ looks like one simple operation, but conceptually it involves several steps.
int current = count; int updated = current + 1; count = updated;
Now imagine two threads performing these operations simultaneously.
Thread A:
Read count = 0
Thread B:
Read count = 0
Thread A:
Calculate 0 + 1
Thread B:
Calculate 0 + 1
Thread A:
Write 1
Thread B:
Write 1
Both threads performed an increment, but the final value is 1 instead of the expected 2.
This is a classic race condition caused by an unsafe interleaving of operations.
Real-World Analogy
Imagine a ticket system showing only one seat remaining. Two customers request that seat at almost the same time.
Both employees check the system and see "1 seat available." Both approve the booking. The system has now sold one seat twice.
The underlying problem is that checking availability and reserving the seat were not treated as one coordinated operation.
Remember: Race conditions often occur when a program performs a check → act → update sequence on shared state without appropriate coordination.
Example of a Race Condition
class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class RaceConditionDemo {
public static void main(String[] args)
throws InterruptedException {
Counter counter = new Counter();
Thread first = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
});
Thread second = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
});
first.start();
second.start();
first.join();
second.join();
System.out.println(
"Expected: 200000"
);
System.out.println(
"Actual: " + counter.getCount()
);
}
}
The expected result is 200000, but the actual result may be smaller because increments can be lost when the two threads interfere with one another.
The exact incorrect value is not fixed. It depends on how the threads are scheduled and how their operations interleave during that particular execution.
Why Race Conditions Are Difficult to Debug
Race conditions are particularly dangerous because the program may appear to work correctly most of the time.
For example, the same program might produce:
Expected: 200000 Actual: 200000
during one execution and:
Expected: 200000 Actual: 184731
during another.
Changing logging statements, CPU load, machine speed, thread counts, or debugger settings can change the timing enough to make the bug disappear temporarily.
Industry insight: A race condition that disappears when you add logging has not been fixed. The timing changed; the underlying synchronization problem may still exist.
Race Condition vs Data Race
These terms are related but should not be treated as perfect synonyms.
A data race specifically concerns conflicting accesses to the same memory location by different threads, where at least one access is a write, without the required synchronization or other ordering guarantees.
A race condition is broader. It describes correctness depending on timing or ordering between concurrent operations. Some race conditions can involve coordination between events even when the issue is not simply an unsynchronized read/write of the same variable.
| Term | Meaning |
|---|---|
| Race Condition | Program correctness depends on the timing or ordering of concurrent operations. |
| Data Race | Conflicting concurrent memory accesses occur without the required synchronization or ordering. |
How Synchronization Prevents the Counter Race
One straightforward solution is to synchronize the increment operation.
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Now, when multiple threads call increment() on the same Counter object, only one thread at a time can execute that synchronized method under the object's monitor.
The read-modify-write sequence is therefore protected from conflicting concurrent execution.
Using a Synchronized Block
The same idea can be implemented with a synchronized block.
class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
public int getCount() {
synchronized (this) {
return count;
}
}
}
The synchronization boundary explicitly surrounds the operations that access the shared state.
Atomic Operations and Race Conditions
Another approach is to use classes from java.util.concurrent.atomic when the problem fits an atomic variable abstraction.
import java.util.concurrent.atomic.AtomicInteger;
class Counter {
private final AtomicInteger count =
new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
Here, AtomicInteger provides atomic operations suitable for this simple counter use case without manually synchronizing the increment method.
The important lesson is not "always use AtomicInteger." Choose the synchronization mechanism according to the shared-state operation and the guarantees the application needs.
Race Conditions in Check-Then-Act Code
Race conditions are not limited to counters. A particularly common pattern is check-then-act.
if (account.getBalance() >= amount) {
account.withdraw(amount);
}
If another thread can change the account balance between the check and the withdrawal, the original condition may no longer be true when the withdrawal occurs.
The check and the state-changing operation may therefore need to be protected as one atomic business operation.
Example: Unsafe Bank Account
class BankAccount {
private int balance = 1000;
public void withdraw(int amount) {
if (balance >= amount) {
System.out.println(
"Processing withdrawal"
);
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
Suppose two threads simultaneously attempt to withdraw 700. Both can observe the original balance of 1000 before either updates it.
The program can therefore allow both operations even though the business rule says the account should not become overdrawn.
Making the Business Operation Atomic
class BankAccount {
private int balance = 1000;
public synchronized boolean withdraw(
int amount) {
if (balance < amount) {
return false;
}
balance -= amount;
return true;
}
public synchronized int getBalance() {
return balance;
}
}
Now the balance check and subtraction happen within the same synchronization boundary. Another thread cannot enter the synchronized withdrawal operation for the same account until the current withdrawal finishes.
Design lesson: Synchronize the complete operation required to preserve the business invariant, not merely one line that happens to modify a field.
Race Conditions and Shared Mutable State
Race conditions become much less likely when threads do not share mutable state.
For example, if each thread works with its own local variables, those variables belong to that thread's execution context and do not require synchronization merely because multiple threads exist.
Thread worker = new Thread(() -> {
int localCount = 0;
for (int i = 0; i < 1000; i++) {
localCount++;
}
System.out.println(localCount);
});
The local variable localCount is not shared between threads simply because it has the same name in another thread.
Practical rule: The less shared mutable state your application has, the fewer synchronization problems you have to solve.
Race Conditions Are Not Fixed by sleep()
A common beginner attempt is to add Thread.sleep() and hope the timing problem disappears.
void increment() {
try {
Thread.sleep(1);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
count++;
}
This does not make the operation thread-safe. In fact, changing timing can make the race condition more or less visible, but it does not establish the required synchronization guarantees.
Sleep changes timing. Synchronization changes the rules governing access to shared state.
Race Conditions and volatile
The volatile keyword can provide visibility guarantees for a shared variable, but it does not automatically make compound operations such as count++ atomic.
private volatile int count = 0;
void increment() {
count++;
}
The variable is volatile, but the increment still consists of a read-modify-write sequence. Two threads can still interfere with each other's updates.
Interview insight: volatile is primarily about visibility and ordering guarantees; it does not turn arbitrary compound operations into atomic operations.
How to Prevent Race Conditions
There is no single universal solution. The appropriate technique depends on the shared state and the required concurrency semantics.
- Use synchronized methods or blocks when mutual exclusion is appropriate.
- Use atomic classes for suitable single-variable atomic operations.
- Use explicit locks when more flexible locking behavior is required.
- Use immutable objects to eliminate unnecessary shared mutation.
- Reduce shared mutable state wherever practical.
- Use higher-level concurrency utilities for complex coordination.
Common Beginner Mistakes
- Assuming a single line such as count++ is automatically atomic.
- Adding sleep() and assuming the race condition is fixed.
- Assuming volatile makes every operation thread-safe.
- Synchronizing only the write while leaving a related check outside the critical section.
- Using different lock objects when threads need to coordinate around the same shared state.
- Assuming a race condition will reproduce consistently.
Best Practices
- Identify all shared mutable state explicitly.
- Protect complete state transitions that must remain consistent.
- Prefer immutable data when practical.
- Use atomic classes for operations they are designed to support.
- Do not use timing tricks such as sleep() to solve synchronization problems.
- Test concurrent code under realistic load because timing-sensitive bugs may not appear in simple tests.
Interview Insights
Question: What is a race condition?
A race condition occurs when concurrent operations interact with shared state and the program's correctness depends on unpredictable timing or ordering between those operations.
Question: Why is count++ unsafe in multithreading?
Because it is a read-modify-write sequence rather than one indivisible operation. Two threads can read the same value and overwrite each other's updates.
Question: Does volatile solve race conditions?
No. Volatile provides important visibility and ordering guarantees, but it does not make compound operations such as incrementing a counter atomic.
Question: Can sleep() prevent race conditions?
No. Sleep only changes timing. It does not provide mutual exclusion or the synchronization guarantees required for safe shared-state access.
Question: How can a race condition be prevented?
Depending on the problem, use synchronization, locks, atomic variables, immutable data, reduced shared mutable state, or higher-level concurrency utilities.
Quick Revision
| Concept | Key Point |
|---|---|
| Race Condition | Correctness depends on unpredictable concurrent timing or ordering. |
| Shared State | The main source of many race conditions is shared mutable data. |
| count++ | A read-modify-write operation that is not automatically atomic. |
| Synchronization | Can provide mutual exclusion and memory visibility. |
| Atomic Classes | Provide atomic operations for suitable use cases. |
| volatile | Provides visibility and ordering guarantees but not general atomicity. |
| sleep() | Changes timing but does not make shared-state access safe. |
| Best defense | Reduce shared mutation and protect complete state transitions consistently. |
A race condition is ultimately a design problem: multiple threads are allowed to interact with shared state without enough coordination to preserve the program's rules. The solution is not to slow the threads down or hope they execute in a convenient order; it is to establish explicit synchronization, atomicity, visibility, or ownership boundaries. Once you understand why race conditions occur, the next chapter—Thread Safety—can bring these ideas together and show what it really means for a class or component to be safe under concurrent use.
