Race Conditions and Thread Safety
A race condition happens when the correctness of a program depends on the timing of threads: which one runs first, or how their steps interleave. The result is code that usually works but sometimes gives wrong answers, which makes these bugs hard to find. Code is thread-safe if it behaves correctly no matter how threads are scheduled. In this lesson, you will learn the two classic kinds of race, see them happen, and learn the main ways to make code thread-safe.
The Two Classic Races
| Race | Pattern | Example |
|---|---|---|
| Read-modify-write | Read a value, change it, write it back | count++, balance = balance - amount |
| Check-then-act | Check a condition, then act on it | if (!map.containsKey(k)) map.put(k, v) |
In both, another thread can slip in between the steps.
Example: A Check-Then-Act Race
Let's see an example where two people withdraw from one account at the same time. The withdrawals use a short pause between the check and the update, which makes the race show up reliably.
public class BankRace {
static int balance = 100;
static void withdraw(String who, int amount) {
if (balance >= amount) {
pause(50);
balance -= amount;
System.out.println(who + " withdrew " + amount);
}
}
static synchronized void safeWithdraw(String who, int amount) {
withdraw(who, amount);
}
public static void main(String[] args) throws InterruptedException {
run(false);
System.out.println("Unsafe balance: " + balance);
balance = 100;
run(true);
System.out.println("Safe balance: " + balance);
}
static void run(boolean safe) throws InterruptedException {
Thread a = new Thread(() -> {
if (safe) safeWithdraw("Navika", 80);
else withdraw("Navika", 80);
});
Thread b = new Thread(() -> {
if (safe) safeWithdraw("Srihitha", 80);
else withdraw("Srihitha", 80);
});
a.start();
b.start();
a.join();
b.join();
}
static void pause(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Navika withdrew 80
Srihitha withdrew 80
Unsafe balance: -60
Navika withdrew 80
Safe balance: 20Explanation: In the unsafe run, both threads checked balance >= 80 while the balance was still 100, then both withdrew, leaving -60. In the safe run, safeWithdraw is synchronized, so the second thread waits until the first has finished checking and withdrawing; it then sees only 20 left and withdraws nothing. (Which person withdraws first can vary.)
Ways to Make Code Thread-Safe
| Approach | Example |
|---|---|
| Don't share | Keep data local to one thread |
| Don't change | Immutable objects, records, List.of |
| Lock | synchronized, ReentrantLock |
| Use atomic operations | AtomicInteger.incrementAndGet() |
| Use thread-safe collections | ConcurrentHashMap.merge, putIfAbsent |
Common Mistakes
Testing once and concluding the code is safe. Races may appear only under load or on other machines.
Making each step synchronized but not the whole sequence. Check and act must be one locked unit.
Thinking volatile fixes count++. It makes changes visible but doesn't make the three steps atomic.
Interview Corner
What is a race condition, and how do you prevent it?
A race condition occurs when two or more threads access shared mutable data concurrently and at least one modifies it, so the result depends on thread interleaving. Typical forms are read-modify-write (such as count++) and check-then-act (such as if (!map.containsKey(k)) map.put(k, v)). Prevent it by making the compound action atomic with synchronized or locks, using atomic classes or concurrent collection methods such as putIfAbsent, or by avoiding shared mutable state through immutability and thread confinement.
Practice
- Easy: Rewrite the counter from "Synchronization in Java" with
AtomicIntegerand check that it always reaches 200,000. - Harder: Write a check-then-act race on a
HashMap(two threads adding a key only if it's missing) and fix it withConcurrentHashMap.putIfAbsent.
Key Takeaways
Was this tutorial helpful?