Check-then-act is one of the most common race patterns in Java.
The bug appears when code first checks some condition and then performs an action based on that condition, but another thread can change the state between those two steps.
Problem Statement
Suppose an order service wants to reserve a coupon only if it has not already been used.
The business rule sounds simple:
- check whether the coupon is unused
- mark it used
- continue checkout
If two threads do that concurrently without one atomic boundary, both may pass the check.
Naive Version
class CouponRegistry {
private boolean used;
boolean reserveCoupon() {
if (!used) {
used = true;
return true;
}
return false;
}
}
This looks correct in single-threaded code.
Under concurrency, both threads can read used == false before either writes true.
Then the same coupon gets reserved twice.
Why This Happens
The problem is not the if statement by itself.
The problem is that:
- the check reads shared mutable state
- the action writes shared mutable state
- the invariant depends on both steps staying together
- no synchronization or atomic primitive protects that boundary
Check and act are logically one operation. The code implemented them as two exposed steps.
Runnable Example
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class CheckThenActRaceDemo {
public static void main(String[] args) throws Exception {
CouponRegistry registry = new CouponRegistry();
ExecutorService executor = Executors.newFixedThreadPool(2);
List<Callable<Boolean>> tasks = new ArrayList<>();
tasks.add(registry::reserveCoupon);
tasks.add(registry::reserveCoupon);
List<Future<Boolean>> results = executor.invokeAll(tasks);
executor.shutdown();
int successCount = 0;
for (Future<Boolean> result : results) {
if (result.get()) {
successCount++;
}
}
System.out.println("Successful reservations = " + successCount);
System.out.println("Coupon used = " + registry.isUsed());
}
static final class CouponRegistry {
private boolean used;
boolean reserveCoupon() {
if (!used) {
sleep(30);
used = true;
return true;
}
return false;
}
boolean isUsed() {
return used;
}
}
static void sleep(long millis) {
try {
TimeUnit.MILLISECONDS.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
You may observe two successful reservations even though the invariant allows only one.
The delay only widens the timing window. The bug exists even without it.
Production-Style Scenario
In backend systems, check-then-act appears in code like:
- reserve stock if quantity is available
- create user if email does not exist
- refresh token if current token is expired
- send alert if alert was not already sent
- schedule job if the same job is not already running
These are not minor mistakes. They violate business invariants.
For example, two API nodes may both see “invoice not paid” and both start a downstream settlement flow.
Broken Inventory Example
class InventoryService {
private int available = 1;
boolean reserveOne() {
if (available > 0) {
available--;
return true;
}
return false;
}
}
This is also check-then-act:
- check
available > 0 - act by decrementing
If those steps are not protected together, overselling becomes possible.
Correct Fixes
The fix is to make the whole decision-and-update sequence atomic.
Option 1: Synchronize the critical section
class SafeCouponRegistry {
private boolean used;
synchronized boolean reserveCoupon() {
if (!used) {
used = true;
return true;
}
return false;
}
}
Option 2: Use an atomic primitive when the state shape fits
import java.util.concurrent.atomic.AtomicBoolean;
class AtomicCouponRegistry {
private final AtomicBoolean used = new AtomicBoolean(false);
boolean reserveCoupon() {
return used.compareAndSet(false, true);
}
}
The atomic version is a particularly good fit because the invariant is a single-state transition from false to true.
Decision Guide
Use synchronized or Lock when:
- multiple fields must stay consistent together
- the critical section is more than one simple atomic state change
- you need conditions or lock features
Use an atomic class when:
- one variable represents the whole invariant
- the operation is a compare-and-set style transition
- you want a simpler lock-free state update
Common Mistakes
- making the field
volatileand assuming that fixes the race - synchronizing only the write but not the read
- checking outside a lock and acting inside it
- reading from one object and updating another object without one shared coordination strategy
volatile gives visibility.
It does not combine check and act into one atomic operation.
Testing Notes
Check-then-act bugs are often intermittent. They may disappear in local testing and appear under load.
Useful ways to expose them:
- widen the timing window with sleeps in test-only demos
- run the same operation many times from a thread pool
- assert business invariants, not just field values
- use repeated stress tests instead of one happy-path run
Key Takeaways
- Check-then-act is a race where the decision and the update are split across an unprotected timing window.
- The fix is not “more threads awareness”; the fix is an actual atomic boundary.
volatileis not enough for this pattern.- Many business invariant bugs are just check-then-act races wearing business language.
Next post: Read-Modify-Write Race Condition in Java