concurrency-interview: companion code for the Top 40 interview post

Adds the concurrency-interview module: a real race condition with its
actual lost-update count, why volatile alone doesn't fix it, a real
JVM-detected deadlock (ThreadMXBean.findDeadlockedThreads()) with the
fix, and ScopedValue's exact child-thread inheritance rules (finalized
in JDK 25 via JEP 506, demonstrated against StructuredTaskScope, still
preview per JEP 505/525). 4 runnable demos, 1 JUnit test class (9
tests), 5 captured output transcripts.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FhzLY5p6okFva3qsnsRyvM
This commit is contained in:
2026-09-30 07:25:12 +00:00
co-authored by Claude Sonnet 5
parent bc5153d2a5
commit 202f2f18a5
15 changed files with 553 additions and 0 deletions
@@ -0,0 +1,111 @@
package com.ankurm.concurrencyinterview;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
/**
* The textbook deadlock: two threads, two locks, acquired in opposite order. Thread-1 takes
* lockA then reaches for lockB; Thread-2 takes lockB then reaches for lockA. Neither can proceed
* and neither will ever release what it's already holding. This demo doesn't just describe that -
* it triggers a real deadlock, detects it with the same java.lang.management API a monitoring
* tool would use (ThreadMXBean.findDeadlockedThreads()), and then runs a second, fixed version
* that acquires both locks in the same global order every time, which completes normally.
*/
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
// Separate locks for the fixed-order demo: the two threads above deadlock forever and never
// release lockA/lockB, so reusing those objects here would just hang a second time.
private static final Object lockC = new Object();
private static final Object lockD = new Object();
private static void triggerDeadlock() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("thread-1: holding lockA, waiting for lockB");
sleep(200);
synchronized (lockB) {
System.out.println("thread-1: this line never prints - deadlocked before reaching it");
}
}
}, "thread-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("thread-2: holding lockB, waiting for lockA");
sleep(200);
synchronized (lockA) {
System.out.println("thread-2: this line never prints - deadlocked before reaching it");
}
}
}, "thread-2");
t1.start();
t2.start();
Thread.sleep(1000); // give the deadlock time to actually form
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
long[] deadlockedIds = threadBean.findDeadlockedThreads();
if (deadlockedIds == null) {
System.out.println("main: findDeadlockedThreads() found nothing - unexpected for this demo");
return;
}
System.out.println("main: findDeadlockedThreads() detected " + deadlockedIds.length + " deadlocked threads");
for (ThreadInfo info : threadBean.getThreadInfo(deadlockedIds, true, true)) {
System.out.println(" " + info.getThreadName() + " is blocked on " + info.getLockInfo()
+ ", owned by " + info.getLockOwnerName());
}
// The two threads above are stuck forever - this JVM process exits without joining them.
}
private static void runFixedOrderedVersion() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lockC) {
sleep(100);
synchronized (lockD) {
System.out.println("thread-1 (fixed order): acquired both locks, done");
}
}
}, "thread-1-fixed");
Thread t2 = new Thread(() -> {
synchronized (lockC) { // same order as thread-1: lockC first, lockD second
sleep(100);
synchronized (lockD) {
System.out.println("thread-2 (fixed order): acquired both locks, done");
}
}
}, "thread-2-fixed");
t1.start();
t2.start();
t1.join(5000);
t2.join(5000);
System.out.println("main: both fixed-order threads finished, isAlive t1=" + t1.isAlive()
+ " t2=" + t2.isAlive());
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Opposite lock order: a real deadlock, detected via ThreadMXBean ===");
triggerDeadlock();
System.out.println();
System.out.println("=== Same lock order on both threads: no deadlock possible ===");
runFixedOrderedVersion();
System.out.println("done (the process exits here - the two deadlocked threads above never finish)");
System.exit(0); // the deadlocked threads from triggerDeadlock() would otherwise hang the JVM forever
}
}
@@ -0,0 +1,46 @@
package com.ankurm.concurrencyinterview;
import java.util.concurrent.atomic.AtomicInteger;
/**
* The canonical interview answer to "why isn't count++ thread-safe?" is that it's a
* read-modify-write sequence, not one atomic step. This demo doesn't just assert that - it runs
* 8 threads each incrementing a plain int 200,000 times (1,600,000 total increments expected) and
* prints whatever the real, lost-update total actually comes out to, side by side with an
* AtomicInteger doing the identical work correctly. The exact deficit is inherently
* non-deterministic - that's the point being demonstrated, not a bug in the demo.
*/
public class RaceConditionDemo {
private static int unsafeCounter = 0;
private static final AtomicInteger safeCounter = new AtomicInteger(0);
private static final int THREADS = 8;
private static final int INCREMENTS_PER_THREAD = 200_000;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
unsafeCounter++; // read-modify-write, not atomic
safeCounter.incrementAndGet(); // genuinely atomic
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
int expected = THREADS * INCREMENTS_PER_THREAD;
System.out.println("expected increments: " + expected);
System.out.println("unsafeCounter (plain int, unsynchronized): " + unsafeCounter);
System.out.println("safeCounter (AtomicInteger): " + safeCounter.get());
if (unsafeCounter != expected) {
System.out.println("unsafeCounter lost " + (expected - unsafeCounter) + " updates to the race");
} else {
System.out.println("unsafeCounter happened to come out correct this run - the race is still real, "
+ "just didn't lose anything THIS time (rerun it - it usually does on this box)");
}
System.out.println("safeCounter matches expected: " + (safeCounter.get() == expected));
}
}
@@ -0,0 +1,63 @@
package com.ankurm.concurrencyinterview;
import java.util.concurrent.StructuredTaskScope;
/**
* ScopedValue (java.lang.ScopedValue) was finalized in JDK 25 via JEP 506 - no preview flag
* needed for the basic API. But its main selling point over ThreadLocal is sharing immutable
* context with CHILD threads, and that inheritance is NOT automatic for just any thread: it only
* happens for threads forked from a StructuredTaskScope, which is still a preview API in JDK 25/26
* (JEP 505/525). This class needs --enable-preview for that reason alone - the ScopedValue calls
* themselves wouldn't require it.
*/
public class ScopedValueDemo {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static void handleRequest() {
System.out.println("handleRequest(): REQUEST_ID.get() = " + REQUEST_ID.get());
logSomewhereDeeper();
}
private static void logSomewhereDeeper() {
System.out.println("logSomewhereDeeper() (indirect callee, same thread): REQUEST_ID.get() = " + REQUEST_ID.get());
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Same-thread binding: visible to direct AND indirect callees ===");
ScopedValue.where(REQUEST_ID, "req-42").run(ScopedValueDemo::handleRequest);
System.out.println();
System.out.println("=== Outside any binding: isBound() is false ===");
System.out.println("main: REQUEST_ID.isBound() = " + REQUEST_ID.isBound());
System.out.println();
System.out.println("=== A plain `new Thread()` does NOT inherit the binding ===");
ScopedValue.where(REQUEST_ID, "req-99").run(() -> {
Thread plainChild = new Thread(() ->
System.out.println("plain child thread: REQUEST_ID.isBound() = " + REQUEST_ID.isBound()));
plainChild.start();
try {
plainChild.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println();
System.out.println("=== A StructuredTaskScope.fork()'d thread DOES inherit the binding ===");
ScopedValue.where(REQUEST_ID, "req-100").run(() -> {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
System.out.println("forked subtask: REQUEST_ID.isBound() = " + REQUEST_ID.isBound()
+ ", REQUEST_ID.get() = " + REQUEST_ID.get());
return null;
});
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("done");
}
}
@@ -0,0 +1,42 @@
package com.ankurm.concurrencyinterview;
import java.util.concurrent.atomic.AtomicInteger;
/**
* A common interview trap: "just mark it volatile" does NOT make count++ thread-safe. volatile
* guarantees visibility (every thread sees the latest write) and ordering, but it does nothing
* about the read-modify-write sequence being non-atomic - two threads can both read the same
* value before either writes back. This runs a volatile int through the identical concurrent
* increment workload as RaceConditionDemo, next to an AtomicInteger, to show the failure mode is
* the same as a plain int, not fixed by the keyword alone.
*/
public class VolatileNotEnoughDemo {
private static volatile int volatileCounter = 0;
private static final AtomicInteger atomicCounter = new AtomicInteger(0);
private static final int THREADS = 8;
private static final int INCREMENTS_PER_THREAD = 200_000;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
volatileCounter++; // visibility guaranteed, atomicity is NOT
atomicCounter.incrementAndGet();
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
int expected = THREADS * INCREMENTS_PER_THREAD;
System.out.println("expected increments: " + expected);
System.out.println("volatileCounter: " + volatileCounter
+ " (volatile guarantees every thread SEES the latest value - it does not make ++ atomic)");
System.out.println("atomicCounter: " + atomicCounter.get());
System.out.println("volatileCounter lost updates: " + (expected - volatileCounter));
System.out.println("atomicCounter matches expected: " + (atomicCounter.get() == expected));
}
}
@@ -0,0 +1,91 @@
package com.ankurm.concurrencyinterview;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import java.util.NoSuchElementException;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Deliberately does NOT assert on the raw-int or volatile-int race demos - whether a given run
* happens to lose an update is inherently non-deterministic, and a test that occasionally passes
* by luck is worse than no test. What's asserted here is what's actually guaranteed: AtomicInteger
* never loses an update under the identical workload, and ScopedValue's inheritance rule is exactly
* what the docs say it is, not "roughly."
*/
class InterviewClaimsTest {
@RepeatedTest(5)
void atomicIntegerNeverLosesAnUpdateUnderTheSameWorkloadThatBreaksAPlainInt() throws InterruptedException {
AtomicInteger counter = new AtomicInteger(0);
int threads = 8;
int incrementsPerThread = 50_000;
Thread[] pool = new Thread[threads];
for (int i = 0; i < threads; i++) {
pool[i] = new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
counter.incrementAndGet();
}
});
}
for (Thread t : pool) t.start();
for (Thread t : pool) t.join();
assertEquals(threads * incrementsPerThread, counter.get());
}
@Test
void scopedValueIsVisibleToDirectAndIndirectCalleesOnTheSameThread() {
ScopedValue<String> value = ScopedValue.newInstance();
AtomicReference<String> seenByCallee = new AtomicReference<>();
ScopedValue.where(value, "abc").run(() -> seenByCallee.set(value.get()));
assertEquals("abc", seenByCallee.get());
}
@Test
void scopedValueGetOutsideAnyBindingThrows() {
ScopedValue<String> value = ScopedValue.newInstance();
assertFalse(value.isBound());
assertThrows(NoSuchElementException.class, value::get);
}
@Test
void plainThreadDoesNotInheritAScopedValueBinding() throws InterruptedException {
ScopedValue<String> value = ScopedValue.newInstance();
AtomicReference<Boolean> boundInChild = new AtomicReference<>();
ScopedValue.where(value, "parent-bound").run(() -> {
Thread child = new Thread(() -> boundInChild.set(value.isBound()));
child.start();
try {
child.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
assertEquals(false, boundInChild.get(), "a plain new Thread() must NOT inherit the binding");
}
@Test
void structuredTaskScopeForkedThreadDoesInheritTheBinding() throws Exception {
ScopedValue<String> value = ScopedValue.newInstance();
AtomicReference<String> seenInSubtask = new AtomicReference<>();
ScopedValue.where(value, "inherited").run(() -> {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
seenInSubtask.set(value.get());
return null;
});
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
assertEquals("inherited", seenInSubtask.get());
}
}