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:runTesting 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=3rerunFailingTestsCount retries flaky tests — if a concurrency bug only surfaces occasionally, repeated runs catch it.
Key Takeaways
- Use
CountDownLatchstart 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 testsCopyOnWriteArrayListsafely collects errors from background threads- Run CI tests with
-Dsurefire.rerunFailingTestsCount=3to catch intermittent races