Home » Race Condition in Java Explained – With Real-World Examples

Race Condition in Java Explained – With Real-World Examples

Illustration showing a race condition in Java, with two threads competing to update a shared counter variable.

A race condition in Java occurs when two or more threads access shared data at the same time, leading to unpredictable results. In multithreaded Java applications, this can cause data corruption, inconsistent output, or even system crashes. Understanding how a race condition in Java happens — and how to prevent it — is essential for writing thread-safe, reliable programs.

Definition: What Is a Race Condition?

A race condition occurs when the behavior of a program depends on the relative timing of events — such as the order in which threads are scheduled.
If two or more threads access and modify shared data simultaneously without proper coordination, the final result can vary between runs.

In short:

A race condition happens when “who gets there first” changes your program’s outcome.

Simple Java Example

Let’s start with a simple case — incrementing a shared counter.

Java
public class RaceConditionExample {
    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> increment());
        Thread t2 = new Thread(() -> increment());

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final counter value: " + counter);
    }

    public static void increment() {
        for (int i = 0; i < 100000; i++) {
            counter++;
        }
    }
}

You might expect the output to be 200000, but often it’s less.
Why? Because both threads are updating the counter at the same time — one reads the value while the other is still writing it.
The two operations interleave unpredictably, corrupting the final result.

Why Race Conditions Happen

In Java (and most languages), the operation counter++ is not atomic.
It actually performs three low-level steps:

  1. Read counter from memory
  2. Add 1 to it
  3. Write the new value back

If Thread A reads the value at the same time Thread B writes it, one update can be lost — leading to incorrect results.

Real-World Race Condition Scenarios

Let’s look at a few real-world examples to make this more concrete.

1. Banking System: Double Withdrawal

Imagine two users withdrawing from the same bank account simultaneously:

Java
public class BankAccount {
    private int balance = 1000;

    public void withdraw(int amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

If two threads call withdraw(800) at the same time, both might pass the check balance >= amount before either reduces it.
The result?
Final balance might become -600, even though that should never happen.

2. E-Commerce: Double Purchase

In an online store, two users may try to buy the last item in stock simultaneously.

Java
public class Inventory {
    private int stock = 1;

    public void buy() {
        if (stock > 0) {
            stock--;
            System.out.println("Item purchased!");
        } else {
            System.out.println("Out of stock!");
        }
    }
}

If two purchase requests are processed at the same time, both might read stock > 0 as true before either decrements it.
Both sales go through, but only one item existed!

3. Game Server: Item Duplication

In multiplayer games, if two players pick up the same loot item simultaneously, both could end up owning it — unless access to shared game state is synchronized.
This is a classic race condition in distributed systems.

How to Prevent Race Conditions

There are several approaches to fix race conditions in Java, depending on the problem and performance needs.

1. Use the synchronized Keyword

You can make a method or block synchronized so only one thread can execute it at a time:

Java
public synchronized void withdraw(int amount) {
    if (balance >= amount) {
        balance -= amount;
    }
}

or:

Java
public void withdraw(int amount) {
    synchronized(this) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

Now, only one thread can enter the critical section — preventing concurrent modification.

2. Use Locks (ReentrantLock)

ReentrantLock provides more control and flexibility than synchronized.

Java
import java.util.concurrent.locks.ReentrantLock;

public class SafeCounter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

This approach is useful for complex logic or when you need to try locking with timeouts.

3. Use Atomic Variables

For simple operations, AtomicInteger or other atomic classes provide thread-safe atomic updates.

Java
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private final AtomicInteger counter = new AtomicInteger(0);

    public void increment() {
        counter.incrementAndGet();
    }

    public int getValue() {
        return counter.get();
    }
}

Atomic variables handle synchronization internally, giving better performance for lightweight operations.

4. Design for Immutability

Instead of fixing race conditions with locks, you can avoid them by designing your data as immutable — meaning once created, it never changes.
This approach is common in functional programming and distributed systems.

Race Conditions in Multi-Service Architectures

Race conditions don’t only happen in threads — they also appear in microservices and databases.

For instance:

  • Two API servers processing the same request simultaneously might write conflicting updates to a shared database.
  • A cache invalidation race can occur when one service updates a record while another reads stale data.

Solutions:

  • Use transactions in databases (e.g., optimistic locking in JPA or Hibernate).
  • Apply distributed locks (e.g., Redis Redlock, Zookeeper, or etcd).
  • Use message queues to serialize operations that must occur in order.

Testing and Detecting Race Conditions

Race conditions are notoriously hard to detect because they depend on timing.
Some strategies include:

  • Running stress tests or load tests with high concurrency.
  • Using Thread Sanitizers (like IntelliJ’s concurrency analysis tools).
  • Adding logging and tracing to detect inconsistent state.

Conclusion

Race conditions are subtle yet powerful sources of bugs in concurrent applications.
They can lead to corrupted data, double transactions, or security issues. By learning how to detect and fix a race condition in Java, developers can build safer and more predictable applications.

Leave a Reply

Your email address will not be published. Required fields are marked *