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:
+111
@@ -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
|
||||
}
|
||||
}
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+63
@@ -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");
|
||||
}
|
||||
}
|
||||
+42
@@ -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));
|
||||
}
|
||||
}
|
||||
+91
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user