Concurrency Testing Patterns
Learn Java Patterns - Part 029
Concurrency testing patterns for advanced Java systems: interleaving risk, race-oriented tests, jcstress-style litmus tests, deterministic scheduling, stress harnesses, liveness tests, timeout tests, cancellation tests, virtual-thread tests, and production-grade concurrency review.
Part 029 — Concurrency Testing Patterns
Goal: learn how to test Java code whose correctness depends on interleaving, visibility, ordering, cancellation, contention, and liveness.
Most ordinary tests are bad at concurrency.
They execute one path, once, under a friendly scheduler, on a developer laptop, usually with no contention, no CPU pressure, no timing pressure, and no hostile interleaving. Then production runs the same code for weeks under load and eventually discovers the bug.
Concurrency testing is not about proving every possible interleaving. For most application teams, that is unrealistic. The practical goal is better:
encode the concurrency contract, expose forbidden outcomes, amplify dangerous interleavings, and make concurrency assumptions visible during review.
A top-tier Java engineer does not say, “It passed locally.” They say:
“This invariant is protected by this ownership rule, this happens-before edge, this cancellation rule, this bounded queue, this timeout, and these tests make the dangerous failure modes observable.”
1. Kaufman Skill Map
1.1 Target performance level
After this part, you should be able to:
- identify which code actually needs concurrency tests;
- separate deterministic behavior tests from probabilistic stress tests;
- test safety properties: no lost update, no duplicate transition, no torn state, no invariant violation;
- test liveness properties: no deadlock, starvation, unbounded wait, blocked shutdown, or stuck task;
- design race-oriented tests that start threads at the same time and collect all outcomes;
- use jcstress-style thinking for memory-model-sensitive code;
- test
CompletableFuture, executor, virtual-thread, actor, queue, cache, and workflow concurrency; - validate timeout, cancellation, interruption, and shutdown behavior;
- avoid flaky tests caused by arbitrary sleeps;
- decide when not to write custom concurrency primitives at all.
1.2 Sub-skills
| Sub-skill | What you practice | Failure if ignored |
|---|---|---|
| Contract identification | state what must hold under concurrency | tests check implementation noise |
| Interleaving design | create simultaneous access windows | race never appears |
| Outcome classification | record acceptable vs forbidden results | failures look random |
| Liveness testing | verify progress and shutdown | system hangs in production |
| Cancellation testing | propagate stop signals correctly | zombie tasks |
| Visibility testing | validate publication and ordering assumptions | stale or partial reads |
| Stress harnessing | repeat dangerous paths at scale | false confidence |
| Determinism injection | control clocks/executors/schedulers | flaky tests |
| Contention modeling | test under realistic pressure | performance cliff hidden |
| Review discipline | reject clever unsafe code | concurrency debt accumulates |
1.3 The 20-hour practice loop
For concurrency, the practice loop should be concrete:
1. Pick one shared invariant.
2. State the concurrent actors that can touch it.
3. State the allowed outcomes.
4. State the forbidden outcomes.
5. Create a tiny harness that amplifies the dangerous interleaving.
6. Run it repeatedly.
7. Fix the design, not only the test.
8. Replace sleep-based waiting with explicit synchronization.
9. Add one liveness assertion.
10. Document the ownership rule in code review notes.
Do this for caches, repositories, workflow transitions, event consumers, queues, executor shutdown, and async aggregation.
2. Mental Model: Test the Contract, Not the Schedule
A scheduler is allowed to interleave your code in many ways. You do not control the exact interleaving. You control the contract.
A concurrency test should answer:
| Question | Example |
|---|---|
| What is shared? | case status, cache entry, account balance, outbox row, actor mailbox |
| Who can access it? | HTTP request thread, message consumer, scheduled job, retry worker |
| What must never happen? | duplicate approval, negative balance, missing event, stale authorization grant |
| What must eventually happen? | queue drains, worker exits, scope cancels children, lock releases |
| What orders must be preserved? | create before publish, transition before audit record, cancel before cleanup |
| What can be repeated? | idempotent command, retry, duplicate message, cache refresh |
| What must be bounded? | wait time, queue size, retry count, executor lifetime |
Concurrency testing is easier when the design has clear ownership. It is hardest when every service mutates shared state directly.
3. Safety vs Liveness Tests
Concurrency bugs fall into two large families.
3.1 Safety bug
A safety bug means something bad happened.
Examples:
- two workers processed the same idempotency key;
- a workflow skipped a required state;
- a cache returned data from another tenant;
- an aggregate version was overwritten;
- a supposedly immutable snapshot was mutated;
- a queue accepted more work than its bound;
- an audit record was emitted without the corresponding state change.
Safety tests assert forbidden outcomes.
assertThat(finalState).isNotEqualTo(ILLEGAL_STATE);
assertThat(duplicates).isZero();
assertThat(violations).isEmpty();
3.2 Liveness bug
A liveness bug means something good never happened.
Examples:
- deadlock;
- starvation;
- stuck shutdown;
- worker never exits;
- future never completes;
- cancellation never reaches child task;
- actor mailbox never drains;
- retry loop never stops.
Liveness tests assert bounded progress.
assertTimeoutPreemptively(Duration.ofSeconds(2), () -> service.shutdownGracefully());
Use liveness tests carefully. A too-short timeout creates flaky tests. A too-long timeout hides design failure and slows feedback.
4. Pattern: Simultaneous Start Harness
4.1 Problem
You want multiple threads to hit the same critical section at almost the same time.
Naively starting threads in a loop does not guarantee overlap.
4.2 Pattern
Use a start gate and a completion gate.
final class RaceHarness {
static void runConcurrently(int actors, Runnable task) throws InterruptedException {
var ready = new CountDownLatch(actors);
var start = new CountDownLatch(1);
var done = new CountDownLatch(actors);
var failures = new ConcurrentLinkedQueue<Throwable>();
for (int i = 0; i < actors; i++) {
Thread.ofPlatform().start(() -> {
ready.countDown();
try {
start.await();
task.run();
} catch (Throwable t) {
failures.add(t);
} finally {
done.countDown();
}
});
}
ready.await();
start.countDown();
if (!done.await(5, TimeUnit.SECONDS)) {
throw new AssertionError("concurrent task did not finish");
}
if (!failures.isEmpty()) {
var error = new AssertionError("concurrent task failed");
failures.forEach(error::addSuppressed);
throw error;
}
}
}
4.3 Use case: duplicate workflow transition
@Test
void approvalShouldHappenOnlyOnce() throws Exception {
var repository = new InMemoryCaseRepository();
var service = new CaseApprovalService(repository);
var caseId = repository.createPendingCase();
RaceHarness.runConcurrently(16, () -> service.approve(caseId, "supervisor-1"));
var history = repository.history(caseId);
assertThat(history)
.filteredOn(e -> e.type().equals("CASE_APPROVED"))
.hasSize(1);
}
4.4 What this test catches
- missing optimistic lock;
- non-atomic check-then-act;
- duplicate event emission;
- incorrect in-memory synchronization;
- unsafe repository update;
- idempotency bug.
4.5 Limitation
This test improves odds; it does not prove correctness.
Repeat it, vary actor count, add delay injection, and use database-level constraints for real protection.
5. Pattern: Repeated Race Probe
5.1 Problem
A race appears only once every few thousand runs.
5.2 Pattern
Run a small race many times and stop on first forbidden result.
@Test
void idempotencyKeyShouldNotBeConsumedTwice() throws Exception {
for (int iteration = 0; iteration < 10_000; iteration++) {
var store = new IdempotencyStore();
var accepted = new AtomicInteger();
RaceHarness.runConcurrently(2, () -> {
if (store.tryStart("cmd-123")) {
accepted.incrementAndGet();
}
});
assertThat(accepted.get())
.as("iteration %s", iteration)
.isEqualTo(1);
}
}
5.3 Avoid
Do not hide this inside ordinary fast unit tests if it is slow. Put it in a stress-test profile or nightly pipeline.
Recommended categories:
| Category | Frequency | Purpose |
|---|---|---|
| Unit concurrency test | every build | quick safety check |
| Stress race test | CI/nightly | amplify rare interleavings |
| jcstress/litmus test | targeted | memory-model-sensitive primitive |
| Soak test | pre-release | long-running resource and liveness bugs |
6. Pattern: Outcome Table Test
6.1 Problem
Concurrent outcomes are not always single-valued. Some outcomes are legal; others are forbidden.
6.2 Pattern
Collect outcomes and classify them.
record Outcome(int actor1Result, int actor2Result, int finalValue) {}
@Test
void concurrentIncrementOutcomeShouldNeverLoseBothUpdates() throws Exception {
var outcomes = new ConcurrentHashMap<Outcome, AtomicInteger>();
for (int i = 0; i < 20_000; i++) {
var counter = new AtomicInteger(0);
var r1 = new AtomicInteger();
var r2 = new AtomicInteger();
RaceHarness.runConcurrently(2, new Runnable() {
final AtomicInteger actor = new AtomicInteger();
@Override
public void run() {
if (actor.getAndIncrement() == 0) {
r1.set(counter.incrementAndGet());
} else {
r2.set(counter.incrementAndGet());
}
}
});
var outcome = new Outcome(r1.get(), r2.get(), counter.get());
outcomes.computeIfAbsent(outcome, __ -> new AtomicInteger()).incrementAndGet();
}
assertThat(outcomes.keySet())
.allMatch(o -> o.finalValue() == 2);
}
This is the mental model behind litmus-style concurrency testing: run many times, observe outcomes, classify outcomes.
7. Pattern: jcstress-Style Litmus Test
7.1 Problem
Some bugs involve visibility and reordering, not just ordinary race conditions.
Example: one actor writes data and a flag; another actor reads flag and then data. Without a proper happens-before relationship, the reader can observe surprising results.
7.2 When to use this pattern
Use jcstress-style testing when you write or review:
- lock-free structures;
- volatile protocols;
- double-checked initialization;
- custom ring buffers;
- custom memoizers;
- unsafe publication;
VarHandlelogic;- atomic field updaters;
- high-performance caches;
- code that relies on Java Memory Model subtleties.
Do not use it to test ordinary business services. Prefer simpler ownership and database constraints there.
7.3 Example conceptual litmus
// Conceptual example. Real jcstress tests use jcstress annotations.
final class PublicationExample {
int data;
boolean ready;
void writer() {
data = 42;
ready = true;
}
int reader() {
if (ready) {
return data;
}
return -1;
}
}
Forbidden by intent:
ready observed as true, but data observed as 0
Without synchronization, that outcome may be possible. The fix is not “hope harder”. The fix is to create a happens-before edge:
final class SafePublicationExample {
int data;
volatile boolean ready;
void writer() {
data = 42;
ready = true;
}
int reader() {
if (ready) {
return data;
}
return -1;
}
}
7.4 Review rule
If code relies on memory ordering subtlety, it needs one of these:
- standard library primitive;
- explicit documentation;
- litmus/stress test;
- peer review from someone strong in JMM;
- performance evidence proving the complexity is justified.
Otherwise, reject the clever primitive.
8. Pattern: Deterministic Executor Injection
8.1 Problem
Async code is hard to test because the scheduler is hidden.
Bad design:
class ReportService {
CompletableFuture<Report> generate(String caseId) {
return CompletableFuture.supplyAsync(() -> loadReport(caseId));
}
}
This uses the default executor, making tests less predictable and production isolation weaker.
8.2 Pattern
Inject the executor.
final class ReportService {
private final Executor executor;
private final ReportRepository repository;
ReportService(Executor executor, ReportRepository repository) {
this.executor = executor;
this.repository = repository;
}
CompletableFuture<Report> generate(String caseId) {
return CompletableFuture.supplyAsync(() -> repository.load(caseId), executor);
}
}
Test with direct executor for deterministic behavior:
final class DirectExecutor implements Executor {
@Override
public void execute(Runnable command) {
command.run();
}
}
@Test
void reportGenerationUsesRepository() {
var service = new ReportService(new DirectExecutor(), new FakeReportRepository());
var report = service.generate("case-1").join();
assertThat(report.caseId()).isEqualTo("case-1");
}
Test with real executor for concurrency behavior:
@Test
void reportGenerationCompletesOnExecutor() {
try (var executor = Executors.newFixedThreadPool(4)) {
var service = new ReportService(executor, new SlowReportRepository());
var future = service.generate("case-1");
assertThat(future.join().caseId()).isEqualTo("case-1");
}
}
8.3 Rule
Use deterministic executors for behavior tests. Use real executors for concurrency semantics tests.
9. Pattern: Test Clock and Deadline Control
9.1 Problem
Timeout and retry tests often become slow and flaky.
Bad test:
Thread.sleep(5_000);
assertThat(retryCount.get()).isEqualTo(3);
This is slow, nondeterministic, and hostile to CI.
9.2 Pattern
Inject clock, scheduler, and retry policy.
record RetryPolicy(int maxAttempts, Duration baseDelay) {}
interface Sleeper {
void sleep(Duration duration) throws InterruptedException;
}
final class RetryExecutor {
private final RetryPolicy policy;
private final Sleeper sleeper;
RetryExecutor(RetryPolicy policy, Sleeper sleeper) {
this.policy = policy;
this.sleeper = sleeper;
}
<T> T execute(Callable<T> action) throws Exception {
Exception last = null;
for (int attempt = 1; attempt <= policy.maxAttempts(); attempt++) {
try {
return action.call();
} catch (Exception e) {
last = e;
if (attempt == policy.maxAttempts()) {
throw e;
}
sleeper.sleep(policy.baseDelay());
}
}
throw last;
}
}
Test without waiting:
@Test
void retrySleepsBetweenAttemptsWithoutRealTime() {
var slept = new ArrayList<Duration>();
var retry = new RetryExecutor(
new RetryPolicy(3, Duration.ofMillis(250)),
slept::add
);
var attempts = new AtomicInteger();
assertThatThrownBy(() -> retry.execute(() -> {
attempts.incrementAndGet();
throw new IOException("temporary");
})).isInstanceOf(IOException.class);
assertThat(attempts.get()).isEqualTo(3);
assertThat(slept).containsExactly(Duration.ofMillis(250), Duration.ofMillis(250));
}
9.3 Rule
Real time is a production dependency. Treat it like one.
10. Pattern: Interrupt and Cancellation Test
10.1 Problem
Java code often catches InterruptedException incorrectly.
Bad code:
try {
queue.take();
} catch (InterruptedException ignored) {
// bad: interruption swallowed
}
This can break shutdown and cancellation.
10.2 Pattern
Test that interruption stops the worker and preserves interrupt status when appropriate.
final class Worker implements Runnable {
private final BlockingQueue<String> queue;
private final AtomicBoolean stopped = new AtomicBoolean();
Worker(BlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
var item = queue.take();
process(item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
stopped.set(true);
}
}
boolean stopped() {
return stopped.get();
}
private void process(String item) {
// work
}
}
Test:
@Test
void workerStopsWhenInterrupted() throws Exception {
var queue = new LinkedBlockingQueue<String>();
var worker = new Worker(queue);
var thread = Thread.ofPlatform().start(worker);
thread.interrupt();
thread.join(Duration.ofSeconds(2));
assertThat(thread.isAlive()).isFalse();
assertThat(worker.stopped()).isTrue();
}
10.3 Review rule
Every loop that blocks must have a shutdown story.
11. Pattern: Graceful Shutdown Test
11.1 Problem
Executors are easy to start and easy to forget.
Failure modes:
- test JVM does not exit;
- production instance hangs during deploy;
- tasks continue after request is cancelled;
- resources leak;
- queue accepts work after shutdown;
- partial work is not recorded.
11.2 Pattern
Create explicit lifecycle and test it.
final class ManagedExecutor implements AutoCloseable {
private final ExecutorService executor;
ManagedExecutor(ExecutorService executor) {
this.executor = executor;
}
Future<?> submit(Runnable task) {
if (executor.isShutdown()) {
throw new RejectedExecutionException("executor closed");
}
return executor.submit(task);
}
@Override
public void close() {
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
Test:
@Test
void closeStopsExecutorAndRejectsNewWork() {
var managed = new ManagedExecutor(Executors.newFixedThreadPool(2));
managed.close();
assertThatThrownBy(() -> managed.submit(() -> {}))
.isInstanceOf(RejectedExecutionException.class);
}
11.3 Test the stuck task path
@Test
void closeDoesNotHangForeverWhenTaskIsStuck() {
var started = new CountDownLatch(1);
var executor = Executors.newSingleThreadExecutor();
var managed = new ManagedExecutor(executor);
managed.submit(() -> {
started.countDown();
LockSupport.park();
});
await(started);
assertTimeout(Duration.ofSeconds(10), managed::close);
}
12. Pattern: Deadlock Probe
12.1 Problem
Deadlocks are liveness failures. They often do not show up until two paths acquire locks in opposite order.
12.2 Pattern
Create two actors that acquire resources in conflicting order, then assert completion.
@Test
void transferShouldNotDeadlockWhenAccountsAreLockedInStableOrder() throws Exception {
var service = new TransferService();
var a = new AccountId("A");
var b = new AccountId("B");
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
RaceHarness.runConcurrently(2, new Runnable() {
private final AtomicInteger actor = new AtomicInteger();
@Override
public void run() {
if (actor.getAndIncrement() == 0) {
service.transfer(a, b, Money.of(10));
} else {
service.transfer(b, a, Money.of(10));
}
}
});
});
}
12.3 Better design
Use stable lock ordering:
void transfer(AccountId from, AccountId to, Money amount) {
var first = from.compareTo(to) < 0 ? from : to;
var second = from.compareTo(to) < 0 ? to : from;
synchronized (lockFor(first)) {
synchronized (lockFor(second)) {
doTransfer(from, to, amount);
}
}
}
12.4 Rule
Deadlock tests should be paired with a lock-ordering rule in code.
A test without a design rule only detects some failures.
13. Pattern: Starvation and Fairness Test
13.1 Problem
The system makes progress overall, but some work never gets processed.
Examples:
- priority queue starves low-priority items;
- hot tenant monopolizes worker pool;
- actor mailbox never processes delayed messages;
- retry loop floods executor;
- rate limiter is globally fair but tenant-unfair.
13.2 Pattern
Track per-key progress under load.
@Test
void noTenantShouldBeCompletelyStarved() throws Exception {
var scheduler = new TenantAwareScheduler(4);
var processed = new ConcurrentHashMap<String, AtomicInteger>();
for (int i = 0; i < 10_000; i++) {
scheduler.submit("hot", () -> increment(processed, "hot"));
}
for (String tenant : List.of("t1", "t2", "t3")) {
for (int i = 0; i < 50; i++) {
scheduler.submit(tenant, () -> increment(processed, tenant));
}
}
scheduler.drain(Duration.ofSeconds(10));
assertThat(processed.get("t1").get()).isGreaterThan(0);
assertThat(processed.get("t2").get()).isGreaterThan(0);
assertThat(processed.get("t3").get()).isGreaterThan(0);
}
private static void increment(Map<String, AtomicInteger> counters, String key) {
counters.computeIfAbsent(key, __ -> new AtomicInteger()).incrementAndGet();
}
13.3 Production metric
Test alone is insufficient. Also emit:
queue.depth{tenant}
queue.oldest_age{tenant}
work.processed{tenant}
work.rejected{tenant}
worker.utilization
Starvation is often an observability problem before it becomes an outage.
14. Pattern: Linearizability-Oriented Test
14.1 Problem
You need an operation to appear atomic.
Examples:
- reserve slot;
- consume idempotency key;
- transition case state;
- acquire distributed lock;
- decrement inventory;
- insert unique command.
14.2 Mental model
An operation is linearizable when it appears to take effect at one instant between invocation and response.
For application engineering, you usually do not need a full formal checker. You need to assert business-level atomicity.
14.3 Example: reservation
@Test
void onlyCapacityReservationsShouldSucceed() throws Exception {
var capacity = 10;
var service = new ReservationService(capacity);
var successes = new AtomicInteger();
RaceHarness.runConcurrently(100, () -> {
if (service.tryReserve("hearing-room-1")) {
successes.incrementAndGet();
}
});
assertThat(successes.get()).isEqualTo(capacity);
assertThat(service.remaining("hearing-room-1")).isZero();
}
14.4 Real protection
Use one of:
- database unique constraint;
- atomic compare-and-set;
- version column;
- single-writer actor;
- partition-owned command processor;
- serializable transaction;
- explicit lock with stable ownership.
Do not protect important atomicity only with a probabilistic test.
15. Pattern: Idempotency Race Test
15.1 Problem
Idempotency is frequently implemented as:
if not exists key:
perform side effect
save key
This is wrong under concurrency.
15.2 Correct shape
try insert key as STARTED
if conflict:
return existing result or in-progress response
perform side effect
save final result
15.3 Test
@Test
void sameCommandShouldProduceOneSideEffect() throws Exception {
var sideEffects = new AtomicInteger();
var service = new IdempotentCommandService(new InMemoryIdempotencyStore(), () -> {
sideEffects.incrementAndGet();
return "ok";
});
RaceHarness.runConcurrently(32, () -> service.handle("cmd-1"));
assertThat(sideEffects.get()).isEqualTo(1);
}
15.4 Add failure path
@Test
void failedInProgressCommandShouldNotStayStuckForever() {
var store = new InMemoryIdempotencyStore();
var service = new IdempotentCommandService(store, () -> {
throw new RuntimeException("downstream failed");
});
assertThatThrownBy(() -> service.handle("cmd-1"));
assertThat(store.status("cmd-1"))
.isIn(IdempotencyStatus.FAILED, IdempotencyStatus.RETRYABLE);
}
Idempotency tests must cover duplicate success, duplicate in-progress, duplicate after failure, and duplicate after timeout.
16. Pattern: Actor Mailbox Test
16.1 Problem
Actor or single-writer systems move concurrency away from shared state into message ordering and mailbox behavior.
You must test:
- one-at-a-time processing;
- message order per key;
- bounded mailbox;
- rejection/backpressure;
- shutdown;
- poison message behavior;
- supervision/restart;
- timer behavior.
16.2 Test serial processing
@Test
void actorShouldProcessOneMessageAtATime() throws Exception {
var concurrentExecutions = new AtomicInteger();
var maxConcurrent = new AtomicInteger();
var actor = new CaseActor(command -> {
int current = concurrentExecutions.incrementAndGet();
maxConcurrent.accumulateAndGet(current, Math::max);
try {
Thread.sleep(10);
} finally {
concurrentExecutions.decrementAndGet();
}
});
for (int i = 0; i < 100; i++) {
actor.tell(new ApproveCase("case-1"));
}
actor.drain(Duration.ofSeconds(5));
assertThat(maxConcurrent.get()).isEqualTo(1);
}
16.3 Test bounded mailbox
@Test
void actorShouldRejectWhenMailboxIsFull() {
var actor = new CaseActor(1, command -> LockSupport.park());
actor.tell(new ApproveCase("case-1"));
assertThatThrownBy(() -> actor.tell(new ApproveCase("case-1")))
.isInstanceOf(MailboxFullException.class);
}
16.4 Rule
Actor tests are not “no concurrency tests needed”. Actor tests shift from lock tests to ordering, queue, and lifecycle tests.
17. Pattern: Cache Stampede Test
17.1 Problem
When many threads miss a cache at the same time, they can all call the expensive backend.
17.2 Test
@Test
void concurrentCacheMissShouldLoadOnce() throws Exception {
var loads = new AtomicInteger();
var cache = new SingleFlightCache<String, String>(key -> {
loads.incrementAndGet();
Thread.sleep(50);
return "value-for-" + key;
});
RaceHarness.runConcurrently(32, () -> {
assertThat(cache.get("case-1")).isEqualTo("value-for-case-1");
});
assertThat(loads.get()).isEqualTo(1);
}
17.3 Also test failure
If the load fails, decide:
| Policy | Behavior |
|---|---|
| share failure | all waiters receive same failure |
| retry next caller | failure is not cached |
| negative cache | failure cached for short TTL |
| stale fallback | stale value returned if available |
@Test
void failedSingleFlightShouldNotPoisonKeyForever() {
var attempts = new AtomicInteger();
var cache = new SingleFlightCache<String, String>(key -> {
if (attempts.incrementAndGet() == 1) {
throw new IOException("temporary");
}
return "ok";
});
assertThatThrownBy(() -> cache.get("case-1"));
assertThat(cache.get("case-1")).isEqualTo("ok");
}
18. Pattern: Workflow Concurrent Transition Test
18.1 Problem
State machines are often correct in single-threaded tests but wrong under concurrent commands.
Example:
PENDING_REVIEW
-> APPROVED
-> REJECTED
Only one terminal transition should win.
18.2 Test
@Test
void concurrentTerminalTransitionsShouldHaveOneWinner() throws Exception {
var repository = new CaseRepositoryWithOptimisticLocking();
var service = new CaseWorkflowService(repository);
var caseId = repository.create(PENDING_REVIEW);
var results = new ConcurrentLinkedQueue<TransitionResult>();
RaceHarness.runConcurrently(2, new Runnable() {
private final AtomicInteger actor = new AtomicInteger();
@Override
public void run() {
if (actor.getAndIncrement() == 0) {
results.add(service.approve(caseId));
} else {
results.add(service.reject(caseId));
}
}
});
assertThat(results).filteredOn(TransitionResult::accepted).hasSize(1);
assertThat(results).filteredOn(result -> !result.accepted()).hasSize(1);
var state = repository.get(caseId).status();
assertThat(state).isIn(APPROVED, REJECTED);
}
18.3 Required production invariant
A case must have at most one terminal transition.
Enforce with one or more:
- optimistic version column;
- transition table uniqueness;
- aggregate lock;
- single-writer case actor;
- command idempotency;
- state transition guard inside transaction.
19. Pattern: Outbox Relay Concurrency Test
19.1 Problem
Multiple relay workers can publish the same outbox row.
Sometimes that is acceptable if consumers are idempotent. Sometimes you still want to reduce duplicates.
19.2 Test claim semantics
@Test
void outboxRowShouldBeClaimedByOneRelayWorker() throws Exception {
var outbox = new InMemoryOutbox();
outbox.insert(new OutboxMessage("msg-1", "CaseApproved"));
var claimed = new ConcurrentLinkedQueue<OutboxMessage>();
RaceHarness.runConcurrently(8, () -> {
outbox.tryClaimNext("relay-1").ifPresent(claimed::add);
});
assertThat(claimed).hasSize(1);
}
19.3 Test duplicate consumer anyway
@Test
void consumerShouldIgnoreDuplicateEvent() {
var inbox = new InMemoryInbox();
var projection = new CaseProjection();
var consumer = new CaseApprovedConsumer(inbox, projection);
var event = new EventEnvelope("event-1", "case-1", "CaseApproved");
consumer.handle(event);
consumer.handle(event);
assertThat(projection.approvalCount("case-1")).isEqualTo(1);
}
19.4 Rule
Relay tests reduce duplicates. Consumer idempotency handles duplicates.
You need both because distributed systems do not give exactly-once business effects for free.
20. Pattern: Virtual Thread Concurrency Test
20.1 Problem
Virtual threads make high concurrency easier, but they do not remove data races, transaction boundary problems, resource exhaustion, or cancellation bugs.
20.2 Test many blocking tasks cheaply
@Test
void virtualThreadServiceShouldHandleManyBlockingCalls() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var service = new BlockingCaseLookupService(executor, new FakeSlowRepository());
var futures = IntStream.range(0, 10_000)
.mapToObj(i -> service.lookup("case-" + i))
.toList();
for (var future : futures) {
assertThat(future.get(5, TimeUnit.SECONDS)).isNotNull();
}
}
}
20.3 Test resource bulkhead
Virtual threads are cheap; database connections are not.
@Test
void virtualThreadsShouldStillRespectDatabaseConnectionLimit() throws Exception {
var maxConcurrentDbCalls = new AtomicInteger();
var currentDbCalls = new AtomicInteger();
var repository = new FakeRepository(() -> {
int current = currentDbCalls.incrementAndGet();
maxConcurrentDbCalls.accumulateAndGet(current, Math::max);
try {
Thread.sleep(20);
} finally {
currentDbCalls.decrementAndGet();
}
});
var service = new CaseLookupService(repository, new Semaphore(20));
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 1_000)
.mapToObj(i -> executor.submit(() -> service.lookup("case-" + i)))
.toList();
for (var future : futures) {
future.get();
}
}
assertThat(maxConcurrentDbCalls.get()).isLessThanOrEqualTo(20);
}
20.4 Rule
Virtual thread tests should assert resource bounds, not just task completion.
21. Pattern: Structured Concurrency Cancellation Test
21.1 Problem
When one subtask fails, related subtasks should often be cancelled.
Without structured concurrency, orphan work is easy.
21.2 Conceptual test
@Test
void failingSubtaskShouldCancelSiblingWork() throws Exception {
var slowCancelled = new AtomicBoolean();
assertThatThrownBy(() -> new CaseSummaryService().loadSummary(
() -> { throw new IOException("case service down"); },
() -> {
try {
Thread.sleep(Duration.ofSeconds(30));
return "slow notes";
} catch (InterruptedException e) {
slowCancelled.set(true);
Thread.currentThread().interrupt();
throw e;
}
}
)).isInstanceOf(IOException.class);
assertThat(slowCancelled).isTrue();
}
21.3 What this verifies
- failure does not leave sibling tasks running;
- cancellation reaches blocking operation;
- interruption is handled correctly;
- parent does not return before child task lifecycle is resolved.
21.4 Rule
Structured concurrency tests should assert the task tree, not only the final response.
22. Pattern: Bounded Queue Backpressure Test
22.1 Problem
An unbounded queue turns overload into memory pressure and latency collapse.
22.2 Test rejection
@Test
void queueShouldRejectWhenFull() {
var queue = new ArrayBlockingQueue<Command>(2);
var dispatcher = new CommandDispatcher(queue);
dispatcher.submit(new Command("1"));
dispatcher.submit(new Command("2"));
assertThatThrownBy(() -> dispatcher.submit(new Command("3")))
.isInstanceOf(RejectedExecutionException.class);
}
22.3 Test caller-runs policy
@Test
void callerRunsPolicyShouldApplyBackpressure() {
var executor = new ThreadPoolExecutor(
1,
1,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.CallerRunsPolicy()
);
var callerThreadExecutions = new AtomicInteger();
var callerThreadName = Thread.currentThread().getName();
executor.submit(() -> LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)));
executor.submit(() -> LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)));
executor.submit(() -> {
if (Thread.currentThread().getName().equals(callerThreadName)) {
callerThreadExecutions.incrementAndGet();
}
});
assertThat(callerThreadExecutions.get()).isEqualTo(1);
executor.shutdownNow();
}
22.4 Rule
Backpressure tests should assert boundedness explicitly.
23. Pattern: No-Sleep Awaiting
23.1 Problem
Sleep-based tests are either flaky or slow.
Bad:
Thread.sleep(500);
assertThat(queue).isEmpty();
23.2 Pattern
Await a condition with a deadline.
static void eventually(Duration timeout, Runnable assertion) {
var deadline = System.nanoTime() + timeout.toNanos();
AssertionError last = null;
while (System.nanoTime() < deadline) {
try {
assertion.run();
return;
} catch (AssertionError e) {
last = e;
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
}
}
if (last != null) {
throw last;
}
}
Use:
@Test
void workerEventuallyPublishesEvent() {
worker.submit(new ApproveCase("case-1"));
eventually(Duration.ofSeconds(2), () ->
assertThat(eventSink.events()).extracting(Event::type).contains("CaseApproved")
);
}
23.3 Rule
Use explicit signals when possible. Use polling-await only when the system is naturally asynchronous.
24. Pattern: Delay Injection
24.1 Problem
Races hide because critical windows are too small.
24.2 Pattern
Inject delays at dangerous seams.
interface DelayHook {
void pause(String point);
static DelayHook none() {
return point -> {};
}
}
Production:
final class CaseWorkflowService {
private final CaseRepository repository;
private final DelayHook delayHook;
CaseWorkflowService(CaseRepository repository, DelayHook delayHook) {
this.repository = repository;
this.delayHook = delayHook;
}
void approve(CaseId id) {
var caze = repository.get(id);
delayHook.pause("after-read-before-transition");
caze.approve();
repository.save(caze);
}
}
Test:
@Test
void staleReadWindowShouldBeProtectedByVersion() throws Exception {
var barrier = new CyclicBarrier(2);
var delay = (DelayHook) point -> {
if (point.equals("after-read-before-transition")) {
await(barrier);
}
};
var service = new CaseWorkflowService(new VersionedRepository(), delay);
RaceHarness.runConcurrently(2, () -> service.approve(new CaseId("case-1")));
assertThat(service.history("case-1", "CaseApproved")).hasSize(1);
}
24.3 Rule
Delay hooks should be test-only seams, not business logic.
25. Pattern: Concurrency Test Matrix
Use a matrix to avoid random test selection.
| Component | Safety tests | Liveness tests | Stress tests | Observability tests |
|---|---|---|---|---|
| Repository | no lost update, version conflict | connection timeout | concurrent save | lock wait metric |
| Workflow | one terminal transition | no stuck transition | concurrent commands | state transition timeline |
| Cache | load once, tenant isolation | refresh completes | stampede | hit/miss/load/error |
| Queue | bounded capacity | drains on shutdown | producer flood | depth/oldest age |
| Actor | serial per actor | mailbox drains | many messages | mailbox depth |
| CompletableFuture | exception mapping | timeout/cancel | fan-out load | task duration |
| Virtual threads | resource bound | scope closes | many blocking calls | DB concurrency gauge |
| Outbox | one claim | relay exits | duplicate publish | lag and duplicate count |
26. Pattern: Test the Metric That Would Reveal the Bug
Concurrency bugs are often found first through telemetry.
Examples:
| Bug | Metric/log that reveals it |
|---|---|
| stuck worker | queue oldest age rising |
| duplicate consumer | idempotency duplicate count |
| hot partition | per-partition lag skew |
| lock contention | lock wait duration |
| deadlock | no progress + blocked thread dump |
| retry storm | retry attempts per dependency |
| executor saturation | active threads + queue depth + rejection count |
| leaked virtual tasks | task started minus completed |
Test that the signal exists.
@Test
void rejectedWorkShouldIncrementMetric() {
var metrics = new FakeMetrics();
var dispatcher = new BoundedDispatcher(1, metrics);
dispatcher.submit(new Command("1"));
assertThatThrownBy(() -> dispatcher.submit(new Command("2")))
.isInstanceOf(RejectedExecutionException.class);
assertThat(metrics.counter("command.rejected")).isEqualTo(1);
}
27. Common Anti-Patterns
27.1 “It passed once”
A single successful run says little about concurrency correctness.
Fix:
- repeat;
- stress;
- classify outcomes;
- assert invariants;
- use stronger primitives.
27.2 Sleep as synchronization
Bad:
Thread.sleep(100);
Fix:
CountDownLatch;CyclicBarrier;- explicit callback;
- event sink;
- condition-await loop with deadline.
27.3 Testing internals instead of contract
Bad:
verify(lock).lock();
Better:
assertThat(duplicates).isZero();
27.4 Ignoring failure path
A concurrency component must be tested under:
- success;
- exception;
- timeout;
- cancellation;
- duplicate;
- shutdown;
- overload.
27.5 Unbounded stress in normal unit tests
Do not make every build run 10 million iterations.
Use test categories:
fast unit
integration
stress
soak
benchmark
27.6 Custom primitive without proof
If you are writing custom concurrency primitives in application code, pause.
Prefer:
ConcurrentHashMap;BlockingQueue;Semaphore;CompletableFuture;StructuredTaskScope;- database constraints;
- actor/single-writer design;
- library implementation.
28. Production Review Checklist
Before approving concurrent Java code, ask:
Shared state
[ ] What state is shared?
[ ] Who owns it?
[ ] Is mutation confined, locked, atomic, or serialized?
[ ] Are multi-field invariants protected as one unit?
Visibility and ordering
[ ] What creates happens-before?
[ ] Is publication safe?
[ ] Are volatile/atomic/synchronized used correctly?
[ ] Is there any check-then-act race?
Safety
[ ] Can duplicate commands happen?
[ ] Can concurrent terminal transitions happen?
[ ] Can outbox/inbox duplicate processing happen?
[ ] Can cache stampede happen?
[ ] Can stale authorization/data leak happen?
Liveness
[ ] Can a lock be held while calling external code?
[ ] Can shutdown hang?
[ ] Can a queue grow without bound?
[ ] Can a worker starve a tenant/key?
[ ] Can cancellation fail to propagate?
Testing
[ ] Is there at least one race-oriented test for important invariants?
[ ] Are sleeps replaced by explicit synchronization?
[ ] Are timeout/cancellation/shutdown paths tested?
[ ] Are stress tests separated from fast unit tests?
[ ] Are forbidden outcomes explicit?
Operations
[ ] Are queue depth, lag, rejection, duplicate, and lock-wait signals visible?
[ ] Is there a thread dump / JFR / diagnostic plan?
[ ] Does overload fail boundedly?
29. Practice Drills
Drill 1: Idempotency under race
Implement an IdempotencyStore with:
boolean tryStart(String key);
void complete(String key, String result);
Optional<String> result(String key);
Write tests for:
- concurrent
tryStartaccepts exactly one caller; - duplicate after completion returns same result;
- failure does not leave key stuck forever;
- stress test with 32 callers and 10,000 iterations.
Drill 2: Workflow terminal transition
Create a case lifecycle:
OPEN -> UNDER_REVIEW -> APPROVED
OPEN -> UNDER_REVIEW -> REJECTED
Write a test where approve and reject race. Ensure only one terminal state wins.
Drill 3: Cache stampede
Build a simple single-flight cache. Test that 100 concurrent callers load the same key once.
Drill 4: Executor shutdown
Create a managed executor. Test:
- normal shutdown;
- stuck task shutdown;
- new work rejected after close;
- interrupt status preserved.
Drill 5: Virtual thread bulkhead
Run 5,000 virtual-thread tasks that call a fake database. Enforce maximum 20 concurrent DB calls with Semaphore. Assert the max never exceeds 20.
30. Part Summary
Concurrency testing is not about making nondeterminism disappear. It is about making concurrency contracts explicit enough that dangerous outcomes become observable.
Key takeaways:
- test safety and liveness separately;
- avoid sleep-based synchronization;
- classify allowed and forbidden outcomes;
- use simultaneous-start harnesses to amplify races;
- repeat dangerous interleavings outside normal fast unit tests;
- use jcstress-style tests for memory-model-sensitive primitives;
- inject executors, clocks, sleepers, and delay hooks for control;
- test cancellation, interruption, shutdown, and overload;
- assert resource bounds, especially with virtual threads;
- prefer simpler ownership over clever synchronization.
The senior move is not writing heroic concurrency tests for bad design.
The senior move is designing concurrency so that tests, invariants, and operations all agree on one clear ownership model.
References
- OpenJDK jcstress: https://openjdk.org/projects/code-tools/jcstress/
- Oracle Java concurrency tutorial: https://docs.oracle.com/javase/tutorial/essential/concurrency/
- Java
java.util.concurrentpackage summary: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/package-summary.html - Oracle virtual threads guide: https://docs.oracle.com/en/java/javase/25/core/virtual-threads.html
- JUnit User Guide: https://docs.junit.org/current/user-guide/
You just completed lesson 29 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.