Advanced Java Concurrency Testing: Thread Safety, Deadlocks, and Race Conditions

Advanced Java Concurrency Testing: Thread Safety, Deadlocks, and Race Conditions

Testing concurrent Java code is harder than testing single-threaded code because race conditions and deadlocks are non-deterministic. This post covers advanced patterns: stress testing with JCStress, testing thread-safe collections, detecting deadlocks, and asserting asynchronous results.

The Problem with Basic Thread Tests

A naive test that starts threads and checks results will only fail intermittently:

// This test will mostly pass even with a buggy counter
@Test
void counterTest() throws InterruptedException {
    var counter = new UnsafeCounter(); // no synchronization
    var threads = new Thread[100];
    for (int i = 0; i < 100; i++) {
        threads[i] = new Thread(counter::increment);
        threads[i].start();
    }
    for (Thread t : threads) t.join();
    // Might pass due to lucky timing
    assertEquals(100, counter.getValue());
}

You need tools that reliably expose concurrency bugs.

JCStress: Stress Testing for Java Memory Model

JCStress is the authoritative tool for testing Java Memory Model correctness. Add it to your project:

<!-- pom.xml -->
<dependency>
    <groupId>org.openjdk.jcstress</groupId>
    <artifactId>jcstress-core</artifactId>
    <version>0.16</version>
    <scope>test</scope>
</dependency>

Write a JCStress test to check if an increment is atomic:

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;

@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "Both increments seen")
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "actor2 ran first")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE, desc = "actor1 ran first")
@Outcome(id = "0, 0", expect = Expect.FORBIDDEN, desc = "Neither increment seen — BUG")
@State
public class CounterStressTest {
    private final AtomicInteger counter = new AtomicInteger(0);

    @Actor
    public void actor1() {
        counter.incrementAndGet();
    }

    @Actor
    public void actor2(II_Result r) {
        r.r1 = counter.incrementAndGet();
        r.r2 = counter.get();
    }
}

Run:

mvn jcstress:run

Testing Thread Safety with CountDownLatch

Force concurrent execution deterministically using CountDownLatch:

@Test
void threadSafeCounterHandlesConcurrentIncrements() throws InterruptedException {
    var counter = new AtomicLong(0);
    int threadCount = 100;
    int iterations = 1000;

    var startGate = new CountDownLatch(1);
    var endGate = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        new Thread(() -> {
            try {
                startGate.await(); // all threads wait here
                for (int j = 0; j < iterations; j++) {
                    counter.incrementAndGet();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                endGate.countDown();
            }
        }).start();
    }

    startGate.countDown(); // release all threads simultaneously
    endGate.await(10, TimeUnit.SECONDS);

    assertEquals((long) threadCount * iterations, counter.get());
}

Testing with ExecutorService

Use ExecutorService with invokeAll for cleaner concurrent test setup:

@Test
void cacheHandlesConcurrentReadsAndWrites() throws Exception {
    var cache = new ConcurrentHashMap<String, Integer>();
    var executor = Executors.newFixedThreadPool(20);
    var errors = new CopyOnWriteArrayList<Throwable>();

    List<Callable<Void>> tasks = new ArrayList<>();
    for (int i = 0; i < 1000; i++) {
        final int n = i;
        tasks.add(() -> {
            try {
                cache.put("key-" + (n % 10), n);
                cache.get("key-" + (n % 10));
            } catch (Exception e) {
                errors.add(e);
            }
            return null;
        });
    }

    executor.invokeAll(tasks);
    executor.shutdown();
    executor.awaitTermination(10, TimeUnit.SECONDS);

    assertTrue(errors.isEmpty(), "Concurrent errors: " + errors);
}

Testing CompletableFuture

Test async code that returns CompletableFuture:

@Test
void asyncServiceCompletesSuccessfully() throws Exception {
    var service = new AsyncUserService();

    CompletableFuture<User> future = service.findUserAsync(1);

    User user = future.get(2, TimeUnit.SECONDS);
    assertNotNull(user);
    assertEquals(1, user.getId());
}

@Test
void asyncServiceFailsGracefullyOnInvalidId() {
    var service = new AsyncUserService();

    CompletableFuture<User> future = service.findUserAsync(-1);

    ExecutionException ex = assertThrows(
        ExecutionException.class,
        () -> future.get(2, TimeUnit.SECONDS)
    );
    assertInstanceOf(IllegalArgumentException.class, ex.getCause());
}

@Test
void pipelineCompletesAllStagesInOrder() throws Exception {
    var pipeline = new DataPipeline();

    List<String> executionOrder = new CopyOnWriteArrayList<>();

    pipeline
        .fetch("data")
        .thenApply(raw -> { executionOrder.add("parsed"); return raw.toUpperCase(); })
        .thenApply(upper -> { executionOrder.add("transformed"); return upper + "!"; })
        .thenAccept(result -> executionOrder.add("consumed"))
        .get(5, TimeUnit.SECONDS);

    assertEquals(List.of("parsed", "transformed", "consumed"), executionOrder);
}

Detecting Deadlocks

Use ThreadMXBean to detect deadlocks in tests:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class DeadlockDetector {
    private static final ThreadMXBean threadMXBean =
        ManagementFactory.getThreadMXBean();

    public static void assertNoDeadlock() {
        long[] deadlockedThreadIds = threadMXBean.findDeadlockedThreads();
        if (deadlockedThreadIds != null) {
            var threadInfos = threadMXBean.getThreadInfo(deadlockedThreadIds);
            var message = Arrays.stream(threadInfos)
                .map(ti -> ti.getThreadName() + " waiting on: " + ti.getLockName())
                .collect(Collectors.joining("\n"));
            fail("Deadlock detected:\n" + message);
        }
    }
}

@Test
void lockOrderingDoesNotDeadlock() throws InterruptedException {
    var resource1 = new Object();
    var resource2 = new Object();
    var executor = Executors.newFixedThreadPool(2);

    // Both threads acquire locks in the same order → no deadlock
    executor.submit(() -> {
        synchronized (resource1) {
            Thread.sleep(10);
            synchronized (resource2) { /* work */ }
        }
        return null;
    });
    executor.submit(() -> {
        synchronized (resource1) { // same order as above
            synchronized (resource2) { /* work */ }
        }
        return null;
    });

    executor.shutdown();
    executor.awaitTermination(5, TimeUnit.SECONDS);
    DeadlockDetector.assertNoDeadlock();
}

Testing Virtual Threads (Java 21+)

@Test
void virtualThreadsHandleMassiveConcurrency() throws InterruptedException {
    var counter = new AtomicLong(0);
    var latch = new CountDownLatch(10_000);

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 10_000; i++) {
            executor.submit(() -> {
                counter.incrementAndGet();
                latch.countDown();
            });
        }
    }

    latch.await(10, TimeUnit.SECONDS);
    assertEquals(10_000, counter.get());
}

AssertJ Concurrency Assertions

With AssertJ and AbstractFutureAssert:

import org.assertj.core.api.Assertions;

@Test
void futureCompletesWithExpectedValue() {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "hello");

    Assertions.assertThat(future)
        .succeedsWithin(Duration.ofSeconds(1))
        .isEqualTo("hello");
}

@Test
void futureFailsWithExpectedException() {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        throw new IllegalStateException("service unavailable");
    });

    Assertions.assertThat(future)
        .failsWithin(Duration.ofSeconds(1))
        .withThrowableOfType(ExecutionException.class)
        .havingCause()
        .isInstanceOf(IllegalStateException.class)
        .withMessage("service unavailable");
}

CI Configuration

# .github/workflows/test.yml
- name: Run concurrent tests (repeated for flakiness detection)
  run: mvn test -Dsurefire.rerunFailingTestsCount=3

rerunFailingTestsCount retries flaky tests — if a concurrency bug only surfaces occasionally, repeated runs catch it.

Key Takeaways

  • Use CountDownLatch start gates to force true simultaneous thread execution
  • JCStress is the gold standard for Java Memory Model correctness testing
  • ThreadMXBean.findDeadlockedThreads() can detect deadlocks programmatically in tests
  • CopyOnWriteArrayList safely collects errors from background threads
  • Run CI tests with -Dsurefire.rerunFailingTestsCount=3 to catch intermittent races

Read more