Race Conditions and Thread Safety

Naveen AnamaneniUpdated · 4 min read

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

RacePatternExample
Read-modify-writeRead a value, change it, write it backcount++, balance = balance - amount
Check-then-actCheck a condition, then act on itif (!map.containsKey(k)) map.put(k, v)

In both, another thread can slip in between the steps.

Both threads read 5 before either writes, so one increment is lost

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.

BankRace.java▶ Try it
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();
        }
    }
}
Output
Navika withdrew 80
Srihitha withdrew 80
Unsafe balance: -60
Navika withdrew 80
Safe balance:   20

Explanation: 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

ApproachExample
Don't shareKeep data local to one thread
Don't changeImmutable objects, records, List.of
Locksynchronized, ReentrantLock
Use atomic operationsAtomicInteger.incrementAndGet()
Use thread-safe collectionsConcurrentHashMap.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

  1. Easy: Rewrite the counter from "Synchronization in Java" with AtomicInteger and check that it always reaches 200,000.
  2. Harder: Write a check-then-act race on a HashMap (two threads adding a key only if it's missing) and fix it with ConcurrentHashMap.putIfAbsent.

Quick check: Race conditions

4 questions
1What is a race condition?
2Which is a read-modify-write operation?
3Which is a check-then-act pattern?
4Which data is always thread-safe?

Key Takeaways

Was this tutorial helpful?

Lesson 397 of 482