What is Race Conditions?
"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."
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:
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 way:
- Monitor / synchronized block
- Semaphore
- Mutex / Lock
- Atomic operations
- Read-write locks
- Message passing
- Redis Locking
- Database transactions/locking
1. Monitor / synchronized block
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
class SharedResource {
public void printTable(int num) {
synchronized (this) {
for (int i = 1; i <= 10; i++) {
System.out.println(num + " * " + i + " = " + (num * i));
}
}
}
}
Output:
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
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:
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
3. Mutex / Lock
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();
}
}
}
Output:
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
Note: ReentrantLock was introduced in Java 5 as part of the java.util.concurrent.locks package. It was designed to provide more flexibility and control over locking compared to the traditional synchronized keyword, with features like tryLock(), interruptible locking, and fair-locking support.
4. Atomic operations
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);
}
}
}
Output:
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 * 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. Read-write locks
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();
}
}
}
Output:
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
6. Message passing
Instead of multiple threads directly modifying the same shared data, they communicate by sending messages.
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 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));
}
}
}
Output:
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
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 are:
- Pessimistic Locking
- Optimistic Locking
1. Pessimistic Locking
Pessimistic locking — lock the database row before working with it. Or Someone may interrupt, so I'll lock it first.
Pessimistic locking assumes that a conflict could happen, so the database row is locked before processing.
Pessimistic locking is a database concurrency mechanism where you lock a database row before processing 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 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 — don't lock the row while reading; detect a conflict when updating.
Probably nobody will interrupt; I'll detect it if they do.
Optimistic locking takes a different approach. Instead of locking the row when reading it, we add a version number.
@Entity
@Table(name = "my_job")
public class MyJob {
@Id
private Long id;
private Long number;
private String status;
@Version
private Long version;
}
Now, How to decide when we need to apply optimistic and pessimistic locking.
For this we need to understand the below Question:
What should happen when two requests try to modify/process the same record at the same time?Here are some practical cases to decide between optimistic, pessimistic, and distributed locking:
- User profile update → Optimistic Locking
Two users rarely edit the same profile simultaneously. Use @Version. If conflict occurs, reject or retry the update.
Multiple users may try to purchase the same last item. Lock the inventory row before updating. Prevents two requests from claiming the same item.
Concurrent withdrawals/deposits can cause incorrect balances. Lock the account row during the short transaction. Ensures balance calculations are serialized.
Find the table ID from DB and process it for several seconds/minutes.
Don't keep a database transaction locked during the entire operation.
Use a Redis lock such as table-lock:{id}.
