Race Condition in Java: Explained With a Real-World Example
A race condition in Java is one of the most common concurrency problems in
multithreaded applications. In this guide, you will learn what a race condition is, how it
occurs, and eight practical ways to fix it — from synchronized and
Semaphore to database-level locking.
What is a Race Condition?
A race condition is a concurrency problem where multiple threads access shared mutable data concurrently, and the outcome depends on the timing or order of their execution.
We can prevent it using synchronization mechanisms such as locks/mutexes, semaphores, atomic operations, synchronized blocks, read-write locks, message passing, or by avoiding shared mutable state.
Race Condition Example in Java
In the example below, we have two threads: John and Swan. Both threads are trying to print a table using their own numbers. However, because the CPU can switch between threads at any time, the execution order is not predictable.
Due to this context switching, the output from both threads may become interleaved, as shown in the example below. This unpredictable execution is one of the common causes of a race condition in multithreaded programs.
package com.deepsingh44;
public class RaceCondition {
public static void main(String[] args) {
SharedResource sharedResource = new SharedResource();
Thread john = new Thread(() -> sharedResource.printTable(2));
Thread swan = new Thread(() -> sharedResource.printTable(5));
john.start();
swan.start();
}
}
class SharedResource {
public void printTable(int num) {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
}
}
Output (interleaved):
5 * 1 = 5
5 * 2 = 10
5 * 3 = 15
5 * 4 = 20
5 * 5 = 25
5 * 6 = 30
5 * 7 = 35
5 * 8 = 40
5 * 9 = 45
2 * 1 = 2
5 * 10 = 50
2 * 2 = 4
2 * 3 = 6
2 * 4 = 8
2 * 5 = 10
2 * 6 = 12
2 * 7 = 14
2 * 8 = 16
2 * 9 = 18
2 * 10 = 20
In this example, we cannot predict the output order. However, if a table is being printed, it must be completed without any context switching. We can handle this requirement in the following ways:
- Monitor / synchronized block
- Semaphore
- Mutex / Lock
- Atomic operations
- Read-write locks
- Message passing
- Redis locking
- Database transactions / locking
1. Monitor / synchronized Block
The synchronized keyword ensures that only one thread can execute the block
or method at a time. It is the simplest way to prevent a race condition in Java.
package com.deepsingh44;
public class RaceCondition {
public static void main(String[] args) {
SharedResource sharedResource = new SharedResource();
Thread john = new Thread(() -> sharedResource.printTable(2));
Thread swan = new Thread(() -> sharedResource.printTable(5));
john.start();
swan.start();
}
}
class SharedResource {
public synchronized void printTable(int num) {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
}
}
Or using a synchronized block:
class SharedResource {
public void printTable(int num) {
synchronized (this) {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
}
}
}
Output (sequential):
2 * 1 = 2
2 * 2 = 4
2 * 3 = 6
2 * 4 = 8
2 * 5 = 10
2 * 6 = 12
2 * 7 = 14
2 * 8 = 16
2 * 9 = 18
2 * 10 = 20
5 * 1 = 5
5 * 2 = 10
5 * 3 = 15
5 * 4 = 20
5 * 5 = 25
5 * 6 = 30
5 * 7 = 35
5 * 8 = 40
5 * 9 = 45
5 * 10 = 50
2. Semaphore
A Semaphore with a permit count of 1 acts as a mutual exclusion lock (mutex).
Only one thread can acquire the permit at a time, so the shared resource is protected.
package com.deepsingh44;
import java.util.concurrent.Semaphore;
public class RaceCondition {
public static void main(String[] args) {
SharedResource sharedResource = new SharedResource();
Thread john = new Thread(() -> {
try {
sharedResource.printTable(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread swan = new Thread(() -> {
try {
sharedResource.printTable(5);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
john.start();
swan.start();
}
}
class SharedResource {
private final Semaphore semaphore = new Semaphore(1);
public void printTable(int num) throws InterruptedException {
semaphore.acquire();
try {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
} finally {
semaphore.release();
}
}
}
Output: Tables print one after another without interleaving.
3. Mutex / ReentrantLock
ReentrantLock provides the same mutual exclusion as synchronized,
but with more flexibility: tryLock(), interruptible locking, and fair-locking
support.
package com.deepsingh44;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class RaceCondition {
public static void main(String[] args) {
SharedResource sharedResource = new SharedResource();
Thread john = new Thread(() -> sharedResource.printTable(2));
Thread swan = new Thread(() -> sharedResource.printTable(5));
john.start();
swan.start();
}
}
class SharedResource {
private final Lock lock = new ReentrantLock();
public void printTable(int num) {
lock.lock();
try {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
} finally {
lock.unlock();
}
}
}
Note: ReentrantLock was introduced in Java 5 as part of the
java.util.concurrent.locks package. It provides more flexibility and control
than the traditional synchronized keyword, with features like
tryLock(), interruptible locking, and fair-locking support.
4. Atomic Operations
The java.util.concurrent.atomic package provides lock-free thread-safe
variables. AtomicBoolean, AtomicInteger, and
AtomicReference use Compare-And-Swap (CAS) to guarantee atomicity.
package com.deepsingh44;
import java.util.concurrent.atomic.AtomicBoolean;
public class RaceCondition {
public static void main(String[] args) {
SharedResource sharedResource = new SharedResource();
Thread john = new Thread(() -> sharedResource.printTable(2));
Thread swan = new Thread(() -> sharedResource.printTable(5));
john.start();
swan.start();
}
}
class SharedResource {
private final AtomicBoolean printing = new AtomicBoolean(false);
public void printTable(int num) {
while (!printing.compareAndSet(false, true)) {
Thread.yield();
}
try {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
} finally {
printing.set(false);
}
}
}
5. Read-Write Locks
ReentrantReadWriteLock allows multiple readers OR a single writer at a time.
It is useful when reads are frequent and writes are rare.
package com.deepsingh44;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class RaceCondition {
public static void main(String[] args) {
SharedResource sharedResource = new SharedResource();
Thread john = new Thread(() -> sharedResource.printTable(2));
Thread swan = new Thread(() -> sharedResource.printTable(5));
john.start();
swan.start();
}
}
class SharedResource {
ReadWriteLock lock = new ReentrantReadWriteLock();
public void printTable(int num) {
lock.writeLock().lock();
try {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
} finally {
lock.writeLock().unlock();
}
}
}
6. Message Passing
Instead of multiple threads directly modifying the same shared data, they communicate by sending messages through a thread-safe queue.
package com.deepsingh44;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class RaceCondition {
public static void main(String[] args) throws InterruptedException {
SharedResource sharedResource = new SharedResource();
Thread john = new Thread(() -> sharedResource.sendTable(2));
Thread swan = new Thread(() -> sharedResource.sendTable(5));
Thread worker = new Thread(sharedResource::process);
worker.start();
john.start();
swan.start();
john.join();
swan.join();
sharedResource.sendTable(-1);
worker.join();
}
}
class SharedResource {
private final BlockingQueue<Integer> queue =
new ArrayBlockingQueue<>(2);
public void sendTable(int num) {
try {
queue.put(num);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public void process() {
try {
while (true) {
int num = queue.take();
if (num == -1) {
break;
}
printTable(num);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void printTable(int num) {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
}
}
7. Database Transactions & Locking
If both requests enter the processing logic simultaneously, the table may be processed twice, or the output may become inconsistent. The two common approaches to prevent this are:
- Pessimistic Locking
- Optimistic Locking
1. Pessimistic Locking
Pessimistic locking locks the database row before working with it, assuming that another transaction may try to modify or process the same row.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT t
FROM MyJob t
WHERE t.number = :number
""")
Optional<MyJob> findByNumberForUpdate(
@Param("number") Long number
);
In the service layer:
@Transactional
public void processTable(Long number) {
TableJob table = repository
.findByNumberForUpdate(number)
.orElseThrow();
// Row is locked
printTable(table.getId());
table.setStatus("COMPLETED");
}
2. Optimistic Locking
Optimistic locking does not lock the row while reading. Instead, it adds a version number and detects the conflict when updating.
@Entity
@Table(name = "my_job")
public class MyJob {
@Id
private Long id;
private Long number;
private String status;
@Version
private Long version;
}
When to Use Optimistic vs Pessimistic Locking
To decide which locking strategy to use, ask this question: What should happen when two requests try to modify/process the same record at the same time?
Here are some practical cases:
User Profile Update → Optimistic Locking
Two users rarely edit the same profile simultaneously. Use @Version.
If a conflict occurs, reject or retry the update.
Last Available Inventory Item → Pessimistic Locking
Multiple users may try to purchase the same last item. Lock the inventory row before updating. This prevents two requests from claiming the same item.
Bank Account Balance Update → Pessimistic Locking
Concurrent withdrawals/deposits can cause incorrect balances. Lock the account row during the short transaction. This ensures balance calculations are serialized.
Long-Running Table/PDF Processing → Redis/Redisson Lock
Find the table ID from the database and process it for several seconds/minutes. Do not
keep a database transaction locked during the entire operation. Use a Redis lock such as
table-lock:{id}.
Frequently Asked Questions
What is a race condition in Java?
A race condition is a concurrency problem where multiple threads access shared mutable data at the same time, and the final outcome depends on the timing or order of their execution. It leads to unpredictable and often incorrect results.
How can we prevent a race condition in Java?
We can prevent race conditions using synchronization mechanisms such as synchronized blocks or methods, Semaphore, ReentrantLock, AtomicBoolean, ReadWriteLock, message passing, and database-level locking (optimistic or pessimistic).
What is the difference between synchronized and ReentrantLock?
synchronized is a keyword built into the Java language, while
ReentrantLock is a class from java.util.concurrent.locks
introduced in Java 5. ReentrantLock offers extra features like
tryLock(), interruptible locking, and fair-locking support.
What is the difference between optimistic and pessimistic locking?
Pessimistic locking locks the database row before processing it, assuming conflicts are likely. Optimistic locking does not lock during read but detects conflicts on update using a version field. Optimistic locking is better when conflicts are rare; pessimistic locking is safer when conflicts are frequent.
Can a Semaphore prevent a race condition?
Yes. A Semaphore with a permit count of 1 acts as a mutual exclusion lock
(mutex) and ensures that only one thread can access a shared resource at a time,
preventing race conditions.
Conclusion
A race condition in Java is a serious concurrency bug that leads to unpredictable results.
By understanding how thread scheduling works and applying the right synchronization
mechanism — whether synchronized, Semaphore,
ReentrantLock, atomic operations, read-write locks, message passing, or
database locking — you can write thread-safe Java applications.
Choose the lightest tool that solves your problem. Use optimistic locking for rare conflicts, pessimistic locking for critical sections, and distributed locks (Redis) for long-running background jobs.
Related topics: Java Multithreading, Java Concurrency Utilities, ExecutorService, CompletableFuture, Java Memory Model, Deadlock in Java
No comments:
Post a Comment