Back to Blog
Java

Java Race Condition: Causes, Fixes, and Prevention

java race condition: Learn how race conditions occur in Java, how to detect them, and how to fix them using synchronized, volatile, atomic classes, and locks.

concurrencysynchronizationthread-safetyatomicityvolatilelocks
Diagram showing two threads racing to update a shared counter, illustrating a Java race condition.

Java Race Condition: Causes, Fixes, and Prevention

A Java race condition occurs when multiple threads access shared mutable data without coordination, and the final result depends on the unpredictable timing of thread execution. Consider a simple counter shared between two threads:

public class Counter { private int count = 0; public void increment() { count++; } public int value() { return count; } }

If two threads call increment() at the same time, the final value may be less than 2, even though each thread called the method exactly once. The reason is that count++ is not a single atomic operation; it is a read-modify-write sequence that can interleave between threads.

Why the Increment Operation Is Not Atomic

The bytecode for count++ expands into several steps: load the current value of count, add 1 to it, and store the new value back. The Java memory model does not guarantee that these steps execute without interference. Two threads can both read the same initial value, both add 1, and both write back the same result, losing one update.

This is the essence of a race condition: the correctness of the program depends on the relative timing of thread execution, which is not deterministic. The problem is not limited to counters; any shared mutable state that is read and written by multiple threads can be affected.

Common Patterns That Cause Race Conditions

Race conditions typically appear in a few recurring patterns:

  • Read-modify-write: operations like count++, x = x + 1, or list.add(item) where the current value is read, modified, and written back.
  • Check-then-act: code that checks a condition and then acts on it, such as if (map.containsKey(key)) { map.put(key, value); }. Between the check and the act, another thread can change the map.
  • Lazy initialization: a singleton or cache that is initialized on first use. Two threads can both see the field as null and both create instances.

These patterns are common in real-world code, and they all require coordination to become thread-safe.

Using synchronized to Protect Critical Sections

The simplest way to prevent a race condition is to make the critical section mutually exclusive using synchronized. When a thread enters a synchronized block or method, it acquires a lock that no other thread can acquire until the block exits.

public class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int value() { return count; } }

Now only one thread can execute increment() at a time, so the read-modify-write sequence is atomic with respect to other synchronized methods on the same object. This guarantees that two concurrent increment() calls produce a final value of 2.

Synchronization also establishes a happens-before relationship: a write that occurs before a thread releases a lock is visible to any thread that subsequently acquires the same lock. This addresses both atomicity and memory visibility.

Using volatile for Visibility, Not Atomicity

The volatile keyword ensures that reads and writes to a field are always performed on main memory, not on a thread-local cache. This solves visibility problems: if one thread writes to a volatile field, another thread that reads it immediately sees the new value.

However, volatile does not provide atomicity for compound operations. Marking count as volatile does not make count++ safe, because the read and write are still separate operations. Two threads can still interleave between the read and the write.

public class Counter { private volatile int count = 0; public void increment() { count++; // still not atomic } }

Use volatile only when the field is written by one thread and read by others, or when the field is a flag that controls execution, such as a shutdown flag. It is not a substitute for synchronization or atomic classes when multiple threads modify the same field.

Using Atomic Classes for Compound Operations

The java.util.concurrent.atomic package provides classes like AtomicInteger, AtomicLong, and AtomicReference that support lock-free, thread-safe operations. These classes use compare-and-swap (CAS) instructions to update a value atomically without requiring a lock.

import java.util.concurrent.atomic.AtomicInteger; public class Counter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int value() { return count.get(); } }

incrementAndGet() performs the read-modify-write operation atomically. If the CAS fails because another thread updated the value in the meantime, the operation retries. This approach avoids the overhead of locking and is often more scalable under low to moderate contention.

Atomic classes also provide methods like compareAndSet, getAndSet, and updateAndGet, which are useful for more complex atomic updates.

Choosing the Right Synchronization Mechanism

The choice between synchronized, volatile, atomic classes, and explicit locks depends on the specific concurrency requirement:

MechanismAtomicityVisibilityTypical Use Case
volatileNoYesFlags, single-writer scenarios
synchronizedYesYesProtecting critical sections, multiple operations
Atomic classesYesYesSingle-variable compound operations
ReentrantLockYesYesAdvanced locking, try-lock, condition variables

For a simple counter, an atomic class is often the best fit. For a multi-step operation that must be atomic as a whole, such as transferring money between two accounts, synchronized or a lock is more appropriate because it can protect a block of code, not just a single variable.

ReentrantLock provides additional features like timed lock acquisition, interruptible locks, and condition variables. Use it when you need those capabilities, but remember that it requires explicit lock() and unlock() calls, usually in a try-finally block to guarantee release.

Performance and Scalability Considerations

Synchronization can become a bottleneck when many threads contend for the same lock. The JVM optimizes uncontended locks, but under contention, threads may block and wake up, which adds overhead.

Atomic classes use CAS, which avoids blocking. Under low contention, they are faster than locks. Under very high contention, CAS retries can cause a performance drop, though modern JVMs handle this reasonably well.

Choosing the wrong mechanism can hurt scalability. For example, using a single global lock to protect a large collection can serialize all access. A more granular approach, such as striped locks or concurrent data structures like ConcurrentHashMap, can improve throughput.

When designing for concurrency, measure the actual contention profile. Do not assume that one approach is always faster. The right choice depends on the number of threads, the frequency of access, and the complexity of the operation.

Testing and Detecting Race Conditions

Race conditions are notoriously difficult to reproduce because they depend on timing. A test that runs correctly 99% of the time can still fail under a different scheduler or hardware.

Common detection techniques include:

  • Stress testing with many threads and repeated iterations to increase the chance of interleaving.
  • Thread sanitizers or static analysis tools that can flag unsynchronized access to shared fields.
  • Code review with a focus on shared mutable state and the patterns described above.

A practical approach is to write a test that launches several threads that perform the same operation many times and then assert that the final state matches the expected value. If the test fails intermittently, it is a strong signal of a race condition.

However, passing such a test does not guarantee correctness. The only reliable way to eliminate race conditions is to ensure that every shared mutable variable is accessed with proper synchronization, atomicity, or confinement.

java race condition: Practical Usage and Code Examples | RYUSLOG DEV