Deepen PracticeOrdered learning track

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.

16 min read3157 words
PrevNext
Lesson 2935 lesson track2029 Deepen Practice
#java#patterns#concurrency#testing+3 more

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:

  1. identify which code actually needs concurrency tests;
  2. separate deterministic behavior tests from probabilistic stress tests;
  3. test safety properties: no lost update, no duplicate transition, no torn state, no invariant violation;
  4. test liveness properties: no deadlock, starvation, unbounded wait, blocked shutdown, or stuck task;
  5. design race-oriented tests that start threads at the same time and collect all outcomes;
  6. use jcstress-style thinking for memory-model-sensitive code;
  7. test CompletableFuture, executor, virtual-thread, actor, queue, cache, and workflow concurrency;
  8. validate timeout, cancellation, interruption, and shutdown behavior;
  9. avoid flaky tests caused by arbitrary sleeps;
  10. decide when not to write custom concurrency primitives at all.

1.2 Sub-skills

Sub-skillWhat you practiceFailure if ignored
Contract identificationstate what must hold under concurrencytests check implementation noise
Interleaving designcreate simultaneous access windowsrace never appears
Outcome classificationrecord acceptable vs forbidden resultsfailures look random
Liveness testingverify progress and shutdownsystem hangs in production
Cancellation testingpropagate stop signals correctlyzombie tasks
Visibility testingvalidate publication and ordering assumptionsstale or partial reads
Stress harnessingrepeat dangerous paths at scalefalse confidence
Determinism injectioncontrol clocks/executors/schedulersflaky tests
Contention modelingtest under realistic pressureperformance cliff hidden
Review disciplinereject clever unsafe codeconcurrency 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:

QuestionExample
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:

CategoryFrequencyPurpose
Unit concurrency testevery buildquick safety check
Stress race testCI/nightlyamplify rare interleavings
jcstress/litmus testtargetedmemory-model-sensitive primitive
Soak testpre-releaselong-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;
  • VarHandle logic;
  • 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:

  1. standard library primitive;
  2. explicit documentation;
  3. litmus/stress test;
  4. peer review from someone strong in JMM;
  5. 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:

PolicyBehavior
share failureall waiters receive same failure
retry next callerfailure is not cached
negative cachefailure cached for short TTL
stale fallbackstale 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.

ComponentSafety testsLiveness testsStress testsObservability tests
Repositoryno lost update, version conflictconnection timeoutconcurrent savelock wait metric
Workflowone terminal transitionno stuck transitionconcurrent commandsstate transition timeline
Cacheload once, tenant isolationrefresh completesstampedehit/miss/load/error
Queuebounded capacitydrains on shutdownproducer flooddepth/oldest age
Actorserial per actormailbox drainsmany messagesmailbox depth
CompletableFutureexception mappingtimeout/cancelfan-out loadtask duration
Virtual threadsresource boundscope closesmany blocking callsDB concurrency gauge
Outboxone claimrelay exitsduplicate publishlag and duplicate count

26. Pattern: Test the Metric That Would Reveal the Bug

Concurrency bugs are often found first through telemetry.

Examples:

BugMetric/log that reveals it
stuck workerqueue oldest age rising
duplicate consumeridempotency duplicate count
hot partitionper-partition lag skew
lock contentionlock wait duration
deadlockno progress + blocked thread dump
retry stormretry attempts per dependency
executor saturationactive threads + queue depth + rejection count
leaked virtual taskstask 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:

  1. concurrent tryStart accepts exactly one caller;
  2. duplicate after completion returns same result;
  3. failure does not leave key stuck forever;
  4. 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:

  1. normal shutdown;
  2. stuck task shutdown;
  3. new work rejected after close;
  4. 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:

  1. test safety and liveness separately;
  2. avoid sleep-based synchronization;
  3. classify allowed and forbidden outcomes;
  4. use simultaneous-start harnesses to amplify races;
  5. repeat dangerous interleavings outside normal fast unit tests;
  6. use jcstress-style tests for memory-model-sensitive primitives;
  7. inject executors, clocks, sleepers, and delay hooks for control;
  8. test cancellation, interruption, shutdown, and overload;
  9. assert resource bounds, especially with virtual threads;
  10. 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

Lesson Recap

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.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.