Hello. In the previous lesson, you separated three different concurrency requirements: atomicity, visibility, and ordering. The key fact for today is that count++ is a read-modify-write operation, not one indivisible action. That makes it a natural place for a race condition.
This lesson turns that idea into a practical diagnostic skill. You will reproduce both a counter lost-update bug and a business-level check-then-act bug, then learn to explain exactly which interleaving violated the intended rule. This is particularly relevant for mutable fields in Spring singleton beans: multiple HTTP request threads can reach the same service instance at once.
What a race condition actually is
A race condition exists when correctness depends on the timing or scheduling order of concurrent operations.
For example, consider a singleton-style component that tracks completed orders in memory:
final class OrderCounter {
private int completed;
void recordCompletedOrder() {
completed++;
}
int completed() {
return completed;
}
}
If two request threads call recordCompletedOrder() at roughly the same time, the intended outcome is that both orders are counted. But Java effectively has to perform several actions:
- Read
completed. - Add one to the value read.
- Write the calculated value back.
Suppose the counter starts at .
| Moment | Request thread A | Request thread B |
|---|---|---|
| 1 | Reads | |
| 2 | Reads | |
| 3 | Calculates | Calculates |
| 4 | Writes | |
| 5 | Writes |
Two increments occurred, yet the final result is , not . One update was silently overwritten. This is a lost update.

The source expression is small, but the critical section is the whole read, calculate, and write sequence. Diagnosing a race means finding such a critical section and proving that another thread can enter it at the wrong moment.
Race Conditions in Java Multithreading
Watch “Race Conditions in Java Multithreading” by Jakob Jenkov for a visual walkthrough of a shared counter and the read-modify-write interleaving behind lost updates.
Watch the counter example. Focus on the distinction between the source-level count++ expression and its underlying read, modify, and write actions. Notice that repeated runs produce different incorrect totals: nondeterministic output is an important symptom, but not a reliable way to test correctness.
A related term appears in Java concurrency discussions:
- A data race is a low-level condition: conflicting accesses to one memory location, with at least one write, and no appropriate synchronization.
- A race condition is the wider correctness failure: the result depends on an unsafe interleaving.
The unsafe counter has both. But a race condition can also exist even when each individual collection operation is thread-safe. For example, two operations on a ConcurrentHashMap may be internally safe while the combination of checking and then acting is still unsafe.
Recognize the two backend patterns
Most production race conditions reduce to one of two patterns.
Read-modify-write
This is the counter pattern:
completed++;
Other examples include:
balance = balance - withdrawalAmount;
remainingRetries++;
cartTotal = cartTotal + lineTotal;
The updated value depends on the old value. If two threads read the same old value, one of their updates can disappear.
Check-then-act
Now consider a simplified inventory operation:
final class InMemoryInventory {
private int available;
InMemoryInventory(int available) {
this.available = available;
}
boolean reserveOne() {
if (available <= 0) {
return false;
}
available--;
return true;
}
}
With available initially equal to , two concurrent requests can both evaluate the condition before either performs the decrement. Both requests then return true, even though only one unit existed.
The failure is not merely “the count looks odd.” It violates a business invariant:
Successful reservations must never exceed available stock.
This pattern appears frequently in backend services:
- checking whether an idempotency key exists, then inserting it;
- checking whether a coupon is unused, then marking it redeemed;
- checking account balance, then debiting it;
- checking whether a user has a role, then granting access;
- checking whether a job is running, then starting another copy.
A ConcurrentHashMap does not automatically make this safe:
if (!requests.containsKey(idempotencyKey)) {
requests.put(idempotencyKey, requestState);
return true;
}
return false;
Each map method can be safe independently, while the decision formed by containsKey() plus put() remains vulnerable to interleaving. Later, when you study concurrent collections, you will learn to prefer a single purpose-built compound operation when one exists.
Thread Interference (The Java™ Tutorials > Essential Java Classes > Concurrency)
Read Oracle’s “Thread Interference” tutorial for the canonical explanation of why apparently simple operations can overlap when multiple threads act on shared data.
In the “Thread Interference” page, begin with the definition. Focus on the requirement that the operations act on the same data and consist of multiple steps. Then read the example beginning the interleaving. Trace which thread’s result is overwritten and why the same program may also appear correct in another run.
Reproduce a lost-update race with a stress test
The first technique is contention plus repetition. You run many tasks against the same object, align their start as closely as possible, and assert the business expectation.
Here is a JUnit 5 test. It uses only standard Java concurrency utilities.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class UnsafeCounterTest {
@Test
void incrementsCanBeLostUnderContention() throws Exception {
int workerCount = 8;
int incrementsPerWorker = 100_000;
UnsafeCounter counter = new UnsafeCounter();
CountDownLatch ready = new CountDownLatch(workerCount);
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(workerCount);
try {
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < workerCount; i++) {
futures.add(pool.submit(
incrementTask(counter, ready, start, incrementsPerWorker)));
}
assertTrue(ready.await(2, TimeUnit.SECONDS));
start.countDown();
for (Future<?> future : futures) {
future.get(10, TimeUnit.SECONDS);
}
int expected = workerCount * incrementsPerWorker;
assertEquals(expected, counter.value());
} finally {
start.countDown();
pool.shutdownNow();
}
}
private static Runnable incrementTask(
UnsafeCounter counter,
CountDownLatch ready,
CountDownLatch start,
int increments) {
return new Runnable() {
@Override
public void run() {
ready.countDown();
try {
start.await();
for (int i = 0; i < increments; i++) {
counter.increment();
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError(exception);
}
}
};
}
private static final class UnsafeCounter {
private int count;
void increment() {
count++;
}
int value() {
return count;
}
}
}
The important parts are more valuable than memorizing the exact test:
| Test element | Why it is present |
|---|---|
One UnsafeCounter instance | Ensures every worker mutates the same shared heap object. |
| Fixed thread pool | Supplies multiple threads that can execute tasks concurrently. |
ready latch | Prevents an early task from doing most of its work before the others are even scheduled. |
start latch | Releases all waiting tasks at roughly the same moment, increasing contention. |
| Large number of increments | Creates many opportunities for the unsafe read-modify-write sequence to overlap. |
Future.get() | Waits for each task and propagates a task failure to the test thread. |
| Final assertion | States the required outcome: every requested increment must be retained. |
Run this test several times. You will often see a result below the expected total, but it may occasionally pass. That does not prove the code is safe. It only means the harmful scheduling sequence did not occur in that particular run.
This non-repeatability is a defining difficulty in concurrency testing:
- A failure is strong evidence of a defect.
- A passing stress test is useful evidence, but never a proof that an unsynchronized shared-state operation is correct.
- Adding
Thread.sleep()may alter scheduling, but it does not establish a required order and is not a valid fix.
There is a subtle but useful point here. Calling Future.get() establishes that the submitted work has completed before the test checks the count. The test is therefore not failing because it read the result too early. The failure is inside increment(), where the updates themselves are not atomic.
Make a business race deterministic
Stress tests are helpful, but a test that only fails “sometimes” is awkward for diagnosis. When possible, create a controlled test that forces the relevant interleaving.
The following example instruments the inventory class with a test-only hook immediately after the availability check. In application code, the hook would not be part of the public design; it exists here solely to make the unsafe timing visible.
final class UnsafeInventory {
private int available;
private final Runnable afterAvailabilityCheck;
UnsafeInventory(int available, Runnable afterAvailabilityCheck) {
this.available = available;
this.afterAvailabilityCheck = afterAvailabilityCheck;
}
boolean reserveOne() {
if (available <= 0) {
return false;
}
afterAvailabilityCheck.run();
available--;
return true;
}
}
The test uses a CyclicBarrier with two parties. Each task pauses after passing the availability check. Once both have reached that point, the barrier releases them.
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
class UnsafeInventoryTest {
@Test
void onlyOneReservationShouldSucceedForOneUnitOfStock() throws Exception {
CyclicBarrier bothRequestsPassedTheCheck = new CyclicBarrier(2);
UnsafeInventory inventory = new UnsafeInventory(
1,
waitAt(bothRequestsPassedTheCheck));
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<Boolean> reserveTask = inventory::reserveOne;
Future<Boolean> firstRequest = pool.submit(reserveTask);
Future<Boolean> secondRequest = pool.submit(reserveTask);
int successfulReservations =
(firstRequest.get() ? 1 : 0)
+ (secondRequest.get() ? 1 : 0);
assertEquals(1, successfulReservations);
} finally {
pool.shutdownNow();
}
}
private static Runnable waitAt(CyclicBarrier barrier) {
return new Runnable() {
@Override
public void run() {
try {
barrier.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError(exception);
} catch (BrokenBarrierException exception) {
throw new AssertionError(exception);
}
}
};
}
}
This assertion should fail reliably: both calls return true, so there are two successful reservations for one available unit.
The controlled schedule is:
| Stage | Request A | Request B |
|---|---|---|
| Availability check | Sees one unit available | |
| Availability check | Waits at the barrier | Sees one unit available |
| Barrier | Waits | Reaches barrier |
| Reservation decision | Continues and returns success | Continues and returns success |
Both requests made their decision from the same stale business fact: “there is stock.” The issue is not that Java failed to decrement an integer elegantly. The issue is that the intended operation was:
Check availability and reserve one unit as one indivisible business transition.
This style of test is powerful because it captures the correctness rule directly. A future safe implementation should make this test pass without relying on lucky timing.
Diagnose a race condition systematically
When a concurrent test fails, avoid jumping directly to synchronized, volatile, or a concurrent collection. First establish what failed and why.
1. State the invariant and expected outcome
For the counter:
After successful increment requests, the count must equal .
For inventory:
With one unit available, at most one reservation may succeed.
A weak diagnostic statement is “the value was unexpected.” A strong statement names the business rule that was violated.
2. Identify the shared mutable state
Ask:
- Is there one object instance shared by several threads?
- Is a mutable field reachable from that object?
- Can more than one request, scheduled task, or callback write it?
In a Spring Boot application, normal @Service, @Component, and @RestController beans are singletons by default. A field such as this is therefore shared by request threads:
@Service
class ReservationService {
private int reservationsProcessed;
void recordReservation() {
reservationsProcessed++;
}
}
Method-local variables and request DTOs are usually thread-confined. An instance field in a singleton is not.
3. Expand deceptively compact statements
Rewrite the suspect operation in a temporary diagnostic branch:
int observed = count;
int updated = observed + 1;
count = updated;
For check-then-act code, the boundary is usually obvious:
if (available > 0) {
available--;
}
The entire condition and update are one logical transition. If another thread can run between them, the invariant is exposed.
4. Construct a concrete harmful schedule
Do not say only “threads ran at the same time.” Show the necessary ordering:
- Request A reads the initial state.
- Request B reads the same initial state.
- Each request acts on its locally observed decision.
- The state or outcome no longer reflects both operations correctly.
This is the explanation you should be able to give in a pull request, incident review, or interview.
5. Separate the root cause from the symptom
A lower final count is a symptom. The root cause is the non-atomic critical section.
Similarly, two successful reservations are the symptom. The root cause is that the availability check and decrement were allowed to interleave.
This distinction matters because changes such as logging, sleeping, increasing the thread pool size, or making a field volatile may change how often the symptom appears without protecting the compound operation.
6. Use debugging tools with realistic expectations
A traditional debugger can help you understand a race, but it changes scheduling significantly. Use it to inspect the code and construct an interleaving, not to “prove” that a race has disappeared.
Useful tactics include:
- Give submitted tasks meaningful thread names when investigating a production-like reproduction.
- Put breakpoints around the expanded read and write steps, not merely on
count++. - Record the expected and actual final values in test failure output.
- Use controlled coordination primitives such as
CountDownLatchandCyclicBarrierin tests to create a known schedule. - Keep the test focused on the shared state and its invariant; avoid unrelated HTTP, database, or framework setup until the core behavior is understood.
A thread dump is often more helpful for blocked threads, deadlocks, or starvation. A race condition commonly finishes quickly with the wrong answer, so the failing invariant and a controlled interleaving are usually the better evidence.
Common incorrect conclusions
“It passed ten times, so it is thread-safe.”
No. A race is schedule-dependent. Passing merely means the dangerous schedule was not observed.
“I made the field volatile, so count++ is safe.”
No. volatile helps with visibility and ordering of individual reads and writes. Two threads can still read the same visible value and both write the same incremented result.
“I used a concurrent collection, so my logic is safe.”
Not necessarily. A concurrent collection protects its own operations. It cannot infer that your separate containsKey() and put() calls are one business operation.
“I can fix it with a sleep.”
No. A sleep is a timing hint, not synchronization. It may conceal a defect on one machine and expose it on another.
“Only counters have race conditions.”
No. Counters are simply easy to demonstrate. The costly defects are usually business races: duplicate payment capture, oversold inventory, duplicate job execution, inconsistent idempotency records, or a state-machine transition performed twice.
Interview revision: concise explanation
A strong answer to “How would you diagnose a race condition in Java?” could be:
First, I identify shared mutable state and define the invariant it must preserve. I expand compound operations such as increment or check-then-act into their individual reads and writes, then construct an interleaving where two threads observe the same old state and act on it. To reproduce it, I use concurrent tasks with latches for stress, or a barrier at a test seam to force the unsafe schedule deterministically. A passing stress test is not proof of safety; the correct fix must make the whole state transition atomic.
Key takeaways
- A race condition is a correctness failure whose outcome depends on thread scheduling.
count++is a read-modify-write critical section and can lose updates when the counter is shared.- Check-then-act logic can violate business rules even if every individual collection call is thread-safe.
- A stress test increases the chance of exposing a race; a coordinated barrier test can make a known unsafe interleaving reproducible.
- Diagnose by naming the invariant, locating shared mutable state, expanding compound operations, and documenting a concrete harmful schedule.
- Do not treat passing concurrent tests,
Thread.sleep(),volatile, or a concurrent collection as universal solutions.
Next, you will protect a compound state transition correctly using synchronized blocks or explicit locks, and connect the protection mechanism to the happens-before guarantees from the previous lesson.
Can't find a good explanation? Sign up and we'll make it for you
Sign up