Understanding Race Conditions in Java: Problem and Solutions


Introduction
In multi-threaded programming, a race condition occurs when two or more threads access and modify a shared resource concurrently without proper synchronization. This can lead to unpredictable behavior and incorrect results.
This article demonstrates a simple race condition in Java and presents four solutions:
Using the
synchronized
keyword.Using
AtomicInteger
.Using a
Mutex
(explicit object lock).Using a
ReentrantLock
.
The Problem: Race Condition Example
Consider a Counter
class that maintains a simple integer value and allows incrementing it:
Two threads increment this counter 10000 times each:
Why It Fails
The operation value++
is not atomic; it consists of three steps:
Read the current value.
Add 1.
Write the new value back.
If two threads execute this simultaneously, one increment can overwrite the other, causing lost updates. As a result, the final value may be less than 20000.
On fast processors or with JVM optimizations, the race condition may not always manifest, but the problem remains.
Solution 1: Using synchronized
The simplest way to fix the race condition is to make the increment()
method synchronized:
This ensures that only one thread can execute the increment()
method at a time, preventing lost updates.
Pros of synchronized:
Easy to understand.
Works for complex critical sections, not just integers.
Prevents multiple threads from entering critical sections simultaneously.
Cons of synchronized:
Can reduce performance due to thread blocking.
May cause contention on multi-core systems.
Risk of deadlocks if multiple locks are used incorrectly.
Solution 2: Using AtomicInteger
Another approach is to use the AtomicInteger
class, which provides atomic operations without locking:
AtomicInteger
uses low-level atomic hardware instructions (Compare-And-Swap) to ensure thread-safe updates.
Pros of AtomicInteger:
Lock-free and non-blocking.
Highly efficient for simple atomic operations.
Scales better under high contention.
Cons of AtomicInteger:
Limited to simple operations on primitives.
Not suitable for protecting complex critical sections involving multiple variables.
Code readability may decrease with multiple atomic operations combined.
Solution 3: Using a Mutex
A mutex can be implemented in Java using an explicit Object
lock. This makes locking intention clear:
Pros of Mutex:
Explicit lock object makes synchronization intention clear.
Safer than synchronizing on
this
when sharing instances across code bases.Very simple to implement.
Cons of Mutex:
Functionally similar to
synchronized
.Inherits the same limitations regarding performance and blocking.
Less feature-rich compared to
ReentrantLock
.
Solution 4: Using ReentrantLock
ReentrantLock
is part of java.util.concurrent.locks
and provides more flexibility than synchronized
.
Pros of ReentrantLock:
More flexible than
synchronized
(supports fairness, tryLock, multiple conditions).Can be re-entered safely by the same thread.
Useful for advanced concurrency patterns.
Cons of ReentrantLock:
More verbose, requires explicit
unlock()
infinally
.Slightly more overhead in simple cases compared to
synchronized
.
Conclusion
Race conditions are a common problem in multi-threaded programming. The example above shows how incrementing a shared counter without synchronization can produce incorrect results.
We explored four main solutions:
synchronized
— simple and reliable for general use.AtomicInteger
— lock-free and highly efficient for counters and simple atomic updates.Mutex
— explicit object lock, clear intent, suitable for straightforward critical sections.ReentrantLock
— powerful and flexible for advanced concurrency requirements.
You can check this code on github
Subscribe to my newsletter
Read articles from Luis Gustavo Souza directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
