Add multithreading module: odd-even, ping-pong, ordered threads, deadlock detection and fixes, virtual-thread variants (25 tests)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
Claude
2026-10-28 18:10:23 +00:00
parent f80e1079a8
commit 94306c50ab
29 changed files with 1021 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# multithreading
Classic multithreading interview programs in plain Java 25, each runnable and each pinned by a
JUnit test with a hard timeout (no `sleep()` is used to synchronise anything). Companion to
[Java Multithreading Programs for Interviews](https://ankurm.com/java-multithreading-programs-interview-odd-even-ping-pong-deadlock/).
## Versions
| Component | Version | Verified against |
|---|---|---|
| JDK | 25.0.5+8 (Temurin LTS), `--release 25` | `java -version` |
| JUnit Jupiter | 6.1.3 | same version as the other modules in this repo |
| Maven | 3.9.11 | `mvn -version` |
## Run it
```bash
cd multithreading
./scripts/run.sh odd-even # also: ping-pong | ordered | deadlock | virtual
mvn test # 25 tests
./scripts/run-all.sh # regenerates everything in output/
```
## Programs
| Question | Class | Technique |
|---|---|---|
| Odd-even printing | `OddEvenWaitNotify`, `OddEvenSemaphore`, `OddEvenLockCondition` | `synchronized`+`wait/notifyAll`; two `Semaphore`s; `ReentrantLock`+2 `Condition`s |
| Ping-pong | `PingPong` | `ReentrantLock`+2 `Condition`s, boolean turn flag |
| Ordered threads | `OrderedThreads` | `CountDownLatch` chain; `join()` chain; `Semaphore` ring (3 or 1,000 threads) |
| Deadlock | `Transfers`, `DeadlockScenario`, `DeadlockDetector` | naive vs lock ordering vs `tryLock(timeout)`; detection by `ThreadMXBean.findDeadlockedThreads` |
| Virtual threads | every class takes a `ThreadKind` (`PLATFORM` / `VIRTUAL`) | same code, both thread types |
Support: `Group` starts named threads and waits with a deadline (a hang becomes a failing test).
## Captured output (`output/`)
| File | What it shows |
|---|---|
| `01-odd-even.txt` | the three odd-even implementations, 1..10 |
| `02-ping-pong.txt` | 4 rounds of ping/pong |
| `03-ordered-threads.txt` | T3,T2,T1 started in that order, run T1,T2,T3; round-robin of 3 threads |
| `04-deadlock.txt` | real `findDeadlockedThreads` report, both fixes, and virtual threads being invisible to it |
| `05-virtual-single-carrier.txt` | wait/notify and 1,000 virtual threads on ONE carrier thread |
| `06-test-results.txt` | every test name, pass/fail |
## License
MIT, see the repository root.
+6
View File
@@ -0,0 +1,6 @@
OddEvenWaitNotify (platform threads), max=10
[odd:1, even:2, odd:3, even:4, odd:5, even:6, odd:7, even:8, odd:9, even:10]
OddEvenSemaphore (platform threads), max=10
[odd:1, even:2, odd:3, even:4, odd:5, even:6, odd:7, even:8, odd:9, even:10]
OddEvenLockCondition (platform threads), max=10
[odd:1, even:2, odd:3, even:4, odd:5, even:6, odd:7, even:8, odd:9, even:10]
+2
View File
@@ -0,0 +1,2 @@
PingPong, 4 rounds
[ping, pong, ping, pong, ping, pong, ping, pong]
@@ -0,0 +1,5 @@
Started T3, T2, T1 (in that order). Execution order:
latches: [T1, T2, T3]
join chain: [T1, T2, T3]
Round robin, 3 threads, max=10:
[worker-1:1, worker-2:2, worker-3:3, worker-1:4, worker-2:5, worker-3:6, worker-1:7, worker-2:8, worker-3:9, worker-1:10]
+14
View File
@@ -0,0 +1,14 @@
== NAIVE ==
findDeadlockedThreads: DEADLOCK
transfer-A-to-B is WAITING on java.util.concurrent.locks.ReentrantLock$NonfairSync owned by transfer-B-to-A
transfer-B-to-A is WAITING on java.util.concurrent.locks.ReentrantLock$NonfairSync owned by transfer-A-to-B
after interrupting both threads: total balance = 2000
== ORDERED ==
both transfers completed, no deadlock to detect
balances A=930 B=1070
== TRY_LOCK ==
both transfers completed, no deadlock to detect
balances A=930 B=1070 retries>0=true
== NAIVE on virtual threads ==
thread states: [WAITING, WAITING]
findDeadlockedThreads: none (virtual threads are invisible to it)
@@ -0,0 +1,6 @@
carrier parallelism = 1
OddEvenWaitNotify on virtual threads, max=10
[odd:1, even:2, odd:3, even:4, odd:5, even:6, odd:7, even:8, odd:9, even:10]
Round robin: 1000 virtual threads, max=3000 -> 3000 events
first 3: [worker-1:1, worker-2:2, worker-3:3]
last 3: [worker-998:2998, worker-999:2999, worker-1000:3000]
+26
View File
@@ -0,0 +1,26 @@
PASS DeadlockTest.naiveLockingDeadlocksAndTheJvmSaysSo
PASS DeadlockTest.consistentLockOrderingRemovesTheCycle
PASS DeadlockTest.fixesAlsoWorkOnVirtualThreads
PASS DeadlockTest.tryLockWithBackOffTurnsDeadlockIntoCountedRetries
PASS DeadlockTest.deadlockedVirtualThreadsAreInvisibleToFindDeadlockedThreads
PASS OddEvenTest.everyImplementationPrintsExactlyOneToTwentyInOrder(ThreadKind)[1]
PASS OddEvenTest.everyImplementationPrintsExactlyOneToTwentyInOrder(ThreadKind)[2]
PASS OddEvenTest.twoDifferentThreadsDoTheWork(ThreadKind)[1]
PASS OddEvenTest.twoDifferentThreadsDoTheWork(ThreadKind)[2]
PASS OddEvenTest.stableAcrossManyRunsAndOddMax(ThreadKind)[1]
PASS OddEvenTest.stableAcrossManyRunsAndOddMax(ThreadKind)[2]
PASS OddEvenTest.zeroAndOneAreHandled(ThreadKind)[1]
PASS OddEvenTest.zeroAndOneAreHandled(ThreadKind)[2]
PASS OrderedThreadsTest.roundRobinOfThreeThreadsPrintsOneToTen(ThreadKind)[1]
PASS OrderedThreadsTest.roundRobinOfThreeThreadsPrintsOneToTen(ThreadKind)[2]
PASS OrderedThreadsTest.latchesRunT1T2T3EvenThoughT3StartsFirst(ThreadKind)[1]
PASS OrderedThreadsTest.latchesRunT1T2T3EvenThoughT3StartsFirst(ThreadKind)[2]
PASS OrderedThreadsTest.thousandVirtualThreadsStillPrintInOrder
PASS OrderedThreadsTest.joinChainRunsT1T2T3(ThreadKind)[1]
PASS OrderedThreadsTest.joinChainRunsT1T2T3(ThreadKind)[2]
PASS PingPongTest.zeroRoundsPrintNothing(ThreadKind)[1]
PASS PingPongTest.zeroRoundsPrintNothing(ThreadKind)[2]
PASS PingPongTest.alternatesStrictlyForGivenRounds(ThreadKind)[1]
PASS PingPongTest.alternatesStrictlyForGivenRounds(ThreadKind)[2]
PASS SingleCarrierVirtualThreadTest.waitNotifyFinishesOnOneCarrierThread
25 tests, 0 failures
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm.interviewlab</groupId>
<artifactId>multithreading</artifactId>
<version>1.0.0</version>
<name>multithreading</name>
<description>Classic multithreading interview programs: odd-even, ping-pong, ordered threads, deadlock</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.3</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Regenerates every transcript in output/. Needs JDK 25 and Maven 3.9+.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output
./scripts/run.sh odd-even > output/01-odd-even.txt
./scripts/run.sh ping-pong > output/02-ping-pong.txt
./scripts/run.sh ordered > output/03-ordered-threads.txt
./scripts/run.sh deadlock > output/04-deadlock.txt
./scripts/run.sh virtual > output/05-virtual-single-carrier.txt
mvn -B -q test > /dev/null 2>&1
python3 - <<'PY' > output/06-test-results.txt
import glob, xml.etree.ElementTree as ET
total = fail = 0
for f in sorted(glob.glob("target/surefire-reports/TEST-*.xml")):
for tc in ET.parse(f).getroot().iter("testcase"):
bad = tc.find("failure") is not None or tc.find("error") is not None
total += 1; fail += bad
print(("FAIL " if bad else "PASS ") + tc.get("classname").split(".")[-1] + "." + tc.get("name"))
print(f"{total} tests, {fail} failures")
PY
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Runs one demo: ./scripts/run.sh odd-even | ping-pong | ordered | deadlock | virtual
# Needs JDK 25 and Maven (Maven only to compile). The "virtual" demo is started with ONE carrier thread.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q compile 2>&1 | grep -E 'ERROR' || true
FLAGS=(-Xmx256m)
if [ "${1:-}" = "virtual" ]; then
FLAGS+=(-Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1)
fi
java "${FLAGS[@]}" -cp target/classes com.ankurm.interviewlab.multithreading.Demo "$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS'
@@ -0,0 +1,30 @@
package com.ankurm.interviewlab.multithreading;
import java.util.concurrent.locks.ReentrantLock;
/** A bank account with its own lock. The id gives every account a global order (used by the fix). */
public final class Account {
final int id;
final String name;
final ReentrantLock lock = new ReentrantLock();
private long balance; // guarded by lock
public Account(int id, String name, long balance) {
this.id = id;
this.name = name;
this.balance = balance;
}
public String name() { return name; }
/** Callers must hold the lock. */
void withdraw(long amount) { balance -= amount; }
/** Callers must hold the lock. */
void deposit(long amount) { balance += amount; }
public long balance() {
lock.lock();
try { return balance; } finally { lock.unlock(); }
}
}
@@ -0,0 +1,46 @@
package com.ankurm.interviewlab.multithreading;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/** Finds deadlocked threads with ThreadMXBean.findDeadlockedThreads (covers synchronized AND j.u.c locks). */
public final class DeadlockDetector {
private static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();
private DeadlockDetector() { }
/** Polls until the JVM reports a deadlock, or the timeout passes. Detection is the thing under test, so polling is fine. */
public static Optional<List<String>> awaitDeadlock(Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
while (System.nanoTime() < deadline) {
long[] ids = MX.findDeadlockedThreads(); // null when there is no deadlock
if (ids != null) {
return Optional.of(describe(ids));
}
Thread.sleep(5);
}
return Optional.empty();
}
/** One line per deadlocked thread: name, state, the lock type it waits for, and who owns it. */
static List<String> describe(long[] ids) {
List<String> lines = new ArrayList<>();
for (ThreadInfo info : MX.getThreadInfo(ids, true, true)) {
String lockType = info.getLockInfo() == null ? "?" : info.getLockInfo().getClassName();
lines.add(info.getThreadName() + " is " + info.getThreadState()
+ " on " + lockType + " owned by " + info.getLockOwnerName());
}
lines.sort(null); // thread-id order is not stable; name order is
return lines;
}
public static boolean anyDeadlock() {
return MX.findDeadlockedThreads() != null;
}
}
@@ -0,0 +1,63 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;
/** Wires two opposite transfers (A->B and B->A) and runs them with a chosen locking strategy. */
public final class DeadlockScenario {
public enum Strategy { NAIVE, ORDERED, TRY_LOCK }
/** deadlock is empty when none was reported; stuckStates are the two threads' states at check time. */
public record Result(Optional<List<String>> deadlock, List<Thread.State> stuckStates,
long balanceA, long balanceB, int retries) { }
private DeadlockScenario() { }
/**
* @param detectFor how long NAIVE waits for findDeadlockedThreads to report (ignored by the fixes)
*/
public static Result run(Strategy strategy, ThreadKind kind, Duration detectFor) throws Exception {
Account a = new Account(1, "A", 1000);
Account b = new Account(2, "B", 1000);
CyclicBarrier bothHoldFirstLock = new CyclicBarrier(2);
// With ordered locking the second thread cannot even take its "first" lock until the first
// thread is finished, so a barrier there would wait forever: the fix has no interleaving to force.
Runnable hook = strategy == Strategy.ORDERED ? () -> { } : () -> {
try { bothHoldFirstLock.await(); } catch (Exception e) { throw new IllegalStateException(e); }
};
AtomicInteger retries = new AtomicInteger();
Group group = new Group(kind);
group.start("transfer-A-to-B", () -> move(strategy, a, b, 100, hook, retries));
group.start("transfer-B-to-A", () -> move(strategy, b, a, 30, hook, retries));
Optional<List<String>> deadlock = Optional.empty();
List<Thread.State> states = List.of();
if (strategy == Strategy.NAIVE) {
// Wait until both threads are parked on the second lock, then ask the JVM.
long limit = System.nanoTime() + Duration.ofSeconds(5).toNanos();
while (group.threads().stream().anyMatch(t -> t.getState() != Thread.State.WAITING)
&& System.nanoTime() < limit) {
Thread.sleep(2);
}
states = group.threads().stream().map(Thread::getState).toList();
deadlock = DeadlockDetector.awaitDeadlock(detectFor);
group.interruptAll(); // lockInterruptibly lets us break the deadlock and clean up
}
group.awaitAll(Duration.ofSeconds(10));
return new Result(deadlock, states, a.balance(), b.balance(), retries.get());
}
private static void move(Strategy s, Account from, Account to, long amt, Runnable hook,
AtomicInteger retries) throws InterruptedException {
switch (s) {
case NAIVE -> Transfers.naive(from, to, amt, hook);
case ORDERED -> Transfers.ordered(from, to, amt, hook);
case TRY_LOCK -> Transfers.withTryLock(from, to, amt, hook, retries);
}
}
}
@@ -0,0 +1,71 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.List;
/** Prints the transcripts under output/. Usage: Demo odd-even | ping-pong | ordered | deadlock | virtual */
public final class Demo {
public static void main(String[] args) throws Exception {
switch (args.length == 0 ? "" : args[0]) {
case "odd-even" -> oddEven();
case "ping-pong" -> pingPong();
case "ordered" -> ordered();
case "deadlock" -> deadlock();
case "virtual" -> virtual();
default -> throw new IllegalArgumentException("odd-even | ping-pong | ordered | deadlock | virtual");
}
}
private static void oddEven() throws Exception {
for (OddEvenPrinter p : List.of(new OddEvenWaitNotify(), new OddEvenSemaphore(), new OddEvenLockCondition())) {
System.out.println(p.getClass().getSimpleName() + " (platform threads), max=10");
System.out.println(" " + p.print(10, ThreadKind.PLATFORM));
}
}
private static void pingPong() throws Exception {
System.out.println("PingPong, 4 rounds");
System.out.println(" " + new PingPong().play(4, ThreadKind.PLATFORM));
}
private static void ordered() throws Exception {
System.out.println("Started T3, T2, T1 (in that order). Execution order:");
System.out.println(" latches: " + OrderedThreads.withLatches(ThreadKind.PLATFORM));
System.out.println(" join chain: " + OrderedThreads.withJoin(ThreadKind.PLATFORM));
System.out.println("Round robin, 3 threads, max=10:");
System.out.println(" " + OrderedThreads.roundRobin(3, 10, ThreadKind.PLATFORM));
}
private static void deadlock() throws Exception {
for (DeadlockScenario.Strategy s : DeadlockScenario.Strategy.values()) {
DeadlockScenario.Result r = DeadlockScenario.run(s, ThreadKind.PLATFORM, Duration.ofSeconds(5));
System.out.println("== " + s + " ==");
if (s == DeadlockScenario.Strategy.NAIVE) {
System.out.println(" findDeadlockedThreads: " + (r.deadlock().isPresent() ? "DEADLOCK" : "none"));
} else {
System.out.println(" both transfers completed, no deadlock to detect");
}
r.deadlock().ifPresent(lines -> lines.forEach(l -> System.out.println(" " + l)));
System.out.println(s == DeadlockScenario.Strategy.NAIVE
? " after interrupting both threads: total balance = " + (r.balanceA() + r.balanceB())
: " balances A=" + r.balanceA() + " B=" + r.balanceB()
+ (s == DeadlockScenario.Strategy.TRY_LOCK ? " retries>0=" + (r.retries() > 0) : ""));
}
DeadlockScenario.Result v = DeadlockScenario.run(DeadlockScenario.Strategy.NAIVE,
ThreadKind.VIRTUAL, Duration.ofMillis(500));
System.out.println("== NAIVE on virtual threads ==");
System.out.println(" thread states: " + v.stuckStates());
System.out.println(" findDeadlockedThreads: " + (v.deadlock().isPresent() ? "DEADLOCK" : "none (virtual threads are invisible to it)"));
}
private static void virtual() throws Exception {
System.out.println("carrier parallelism = " + System.getProperty("jdk.virtualThreadScheduler.parallelism", "default"));
System.out.println("OddEvenWaitNotify on virtual threads, max=10");
System.out.println(" " + new OddEvenWaitNotify().print(10, ThreadKind.VIRTUAL));
List<String> ring = OrderedThreads.roundRobin(1000, 3000, ThreadKind.VIRTUAL);
System.out.println("Round robin: 1000 virtual threads, max=3000 -> " + ring.size() + " events");
System.out.println(" first 3: " + ring.subList(0, 3));
System.out.println(" last 3: " + ring.subList(ring.size() - 3, ring.size()));
}
}
@@ -0,0 +1,59 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Starts named threads, then waits for all of them with a hard deadline. If a worker throws, the
* failure is rethrown from {@link #awaitAll}. If the deadline passes (a lost wake-up, a deadlock)
* the stragglers are interrupted and a TimeoutException is thrown, so a bug fails fast instead of
* hanging the build.
*/
public final class Group {
@FunctionalInterface
public interface Task { void run() throws Exception; }
private final ThreadKind kind;
private final List<Thread> threads = new ArrayList<>();
private final List<Throwable> failures = new CopyOnWriteArrayList<>();
public Group(ThreadKind kind) { this.kind = kind; }
public Thread start(String name, Task task) {
Thread t = kind.builder(name).unstarted(() -> {
try {
task.run();
} catch (InterruptedException e) {
// interrupted by awaitAll's cleanup; not a failure of its own
} catch (Throwable e) {
failures.add(e);
}
});
threads.add(t);
t.start();
return t;
}
public List<Thread> threads() { return List.copyOf(threads); }
public void interruptAll() { threads.forEach(Thread::interrupt); }
public void awaitAll(Duration timeout) throws InterruptedException, TimeoutException {
long deadline = System.nanoTime() + timeout.toNanos();
for (Thread t : threads) {
long left = deadline - System.nanoTime();
if (left <= 0 || !t.join(Duration.ofNanos(left))) {
threads.forEach(Thread::interrupt);
throw new TimeoutException("thread still running after " + timeout + ": " + t.getName());
}
}
if (!failures.isEmpty()) {
throw new IllegalStateException("worker failed", failures.get(0));
}
}
}
@@ -0,0 +1,47 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* Odd-even printing with ReentrantLock and two Conditions (one wait-set per thread), so each side
* signals exactly the other side instead of waking everybody the way notifyAll does.
*/
public final class OddEvenLockCondition implements OddEvenPrinter {
private final ReentrantLock lock = new ReentrantLock();
private final Condition oddTurn = lock.newCondition();
private final Condition evenTurn = lock.newCondition();
private int next = 1; // guarded by lock
private final List<String> events = new ArrayList<>(); // guarded by lock
@Override
public List<String> print(int max, ThreadKind kind) throws Exception {
Group group = new Group(kind);
group.start("odd", () -> worker("odd", 1, max, oddTurn, evenTurn));
group.start("even", () -> worker("even", 2, max, evenTurn, oddTurn));
group.awaitAll(Duration.ofSeconds(10));
lock.lock();
try { return List.copyOf(events); } finally { lock.unlock(); }
}
private void worker(String name, int first, int max, Condition mine, Condition other)
throws InterruptedException {
for (int n = first; n <= max; n += 2) {
lock.lock();
try {
while (next != n) {
mine.await();
}
events.add(name + ":" + n);
next++;
other.signal();
} finally {
lock.unlock();
}
}
}
}
@@ -0,0 +1,14 @@
package com.ankurm.interviewlab.multithreading;
import java.util.List;
/**
* Interview question 1: two threads, one prints odd numbers and one prints even numbers,
* and together they must print 1, 2, 3 ... max in order.
*
* Each implementation returns the events as "threadName:number" in the exact order they happened,
* so a test can assert BOTH the order and which thread printed each number.
*/
public interface OddEvenPrinter {
List<String> print(int max, ThreadKind kind) throws Exception;
}
@@ -0,0 +1,36 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Semaphore;
/** Odd-even printing with two Semaphores passing a single "turn" permit back and forth. */
public final class OddEvenSemaphore implements OddEvenPrinter {
@Override
public List<String> print(int max, ThreadKind kind) throws Exception {
Semaphore oddTurn = new Semaphore(1); // odd goes first
Semaphore evenTurn = new Semaphore(0);
List<String> events = Collections.synchronizedList(new ArrayList<>());
Group group = new Group(kind);
group.start("odd", () -> {
for (int n = 1; n <= max; n += 2) {
oddTurn.acquire();
events.add("odd:" + n);
evenTurn.release();
}
});
group.start("even", () -> {
for (int n = 2; n <= max; n += 2) {
evenTurn.acquire();
events.add("even:" + n);
oddTurn.release();
}
});
group.awaitAll(Duration.ofSeconds(10));
return List.copyOf(events);
}
}
@@ -0,0 +1,35 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/** Odd-even printing with synchronized + wait()/notifyAll(): the answer the interviewer expects first. */
public final class OddEvenWaitNotify implements OddEvenPrinter {
private final Object monitor = new Object();
private int next = 1; // guarded by monitor
private final List<String> events = new ArrayList<>(); // guarded by monitor
@Override
public List<String> print(int max, ThreadKind kind) throws Exception {
Group group = new Group(kind);
group.start("even", () -> worker("even", 2, max));
group.start("odd", () -> worker("odd", 1, max)); // started second on purpose: order must not matter
group.awaitAll(Duration.ofSeconds(10));
synchronized (monitor) { return List.copyOf(events); }
}
private void worker(String name, int first, int max) throws InterruptedException {
for (int n = first; n <= max; n += 2) {
synchronized (monitor) {
while (next != n) { // a LOOP, never an if: guards against spurious wake-ups
monitor.wait();
}
events.add(name + ":" + n);
next++;
monitor.notifyAll(); // notifyAll, not notify: see the post for why
}
}
}
}
@@ -0,0 +1,69 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
/**
* Interview question 3: three threads are started in the WRONG order (3, 2, 1) but must execute
* T1, then T2, then T3. Plus the round-robin version: N threads print 1..max, thread k printing
* the numbers where (n-1) % N == k-1.
*/
public final class OrderedThreads {
private OrderedThreads() { }
/** Version A: each thread waits on a CountDownLatch that its predecessor counts down. */
public static List<String> withLatches(ThreadKind kind) throws Exception {
List<String> events = Collections.synchronizedList(new ArrayList<>());
CountDownLatch t1Done = new CountDownLatch(1);
CountDownLatch t2Done = new CountDownLatch(1);
Group group = new Group(kind);
group.start("T3", () -> { t2Done.await(); events.add("T3"); });
group.start("T2", () -> { t1Done.await(); events.add("T2"); t2Done.countDown(); });
group.start("T1", () -> { events.add("T1"); t1Done.countDown(); });
group.awaitAll(Duration.ofSeconds(10));
return List.copyOf(events);
}
/** Version B: the classic Thread.join() chain: T2 joins T1, T3 joins T2. */
public static List<String> withJoin(ThreadKind kind) throws Exception {
List<String> events = Collections.synchronizedList(new ArrayList<>());
Group group = new Group(kind);
// Threads must exist before they can be joined, so create T1 first and hand it to T2.
Thread t1 = group.start("T1", () -> events.add("T1"));
Thread t2 = group.start("T2", () -> { t1.join(); events.add("T2"); });
group.start("T3", () -> { t2.join(); events.add("T3"); });
group.awaitAll(Duration.ofSeconds(10));
return List.copyOf(events);
}
/**
* Version C: N threads in a ring of Semaphores. Thread k waits on permit k, prints, then
* releases permit k+1 (wrapping around). Works the same for 3 threads or 1,000 virtual threads.
*/
public static List<String> roundRobin(int threadCount, int max, ThreadKind kind) throws Exception {
List<String> events = Collections.synchronizedList(new ArrayList<>());
Semaphore[] turn = new Semaphore[threadCount];
for (int i = 0; i < threadCount; i++) {
turn[i] = new Semaphore(i == 0 ? 1 : 0);
}
Group group = new Group(kind);
for (int k = 0; k < threadCount; k++) {
final int me = k;
group.start("worker-" + (me + 1), () -> {
for (int n = me + 1; n <= max; n += threadCount) {
turn[me].acquire();
events.add("worker-" + (me + 1) + ":" + n);
turn[(me + 1) % threadCount].release();
}
});
}
group.awaitAll(Duration.ofSeconds(30));
return List.copyOf(events);
}
}
@@ -0,0 +1,46 @@
package com.ankurm.interviewlab.multithreading;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* Interview question 2: one thread prints "ping", another prints "pong", strictly alternating,
* for a given number of rounds. Same shape as odd-even, but the state is a boolean, not a counter.
*/
public final class PingPong {
private final ReentrantLock lock = new ReentrantLock();
private final Condition pingTurn = lock.newCondition();
private final Condition pongTurn = lock.newCondition();
private boolean pingsTurn = true; // guarded by lock
private final List<String> events = new ArrayList<>(); // guarded by lock
public List<String> play(int rounds, ThreadKind kind) throws Exception {
Group group = new Group(kind);
group.start("pong", () -> side("pong", false, rounds, pongTurn, pingTurn));
group.start("ping", () -> side("ping", true, rounds, pingTurn, pongTurn));
group.awaitAll(Duration.ofSeconds(10));
lock.lock();
try { return List.copyOf(events); } finally { lock.unlock(); }
}
private void side(String word, boolean isPing, int rounds, Condition mine, Condition other)
throws InterruptedException {
for (int i = 0; i < rounds; i++) {
lock.lock();
try {
while (pingsTurn != isPing) {
mine.await();
}
events.add(word);
pingsTurn = !isPing;
other.signal();
} finally {
lock.unlock();
}
}
}
}
@@ -0,0 +1,10 @@
package com.ankurm.interviewlab.multithreading;
/** Every program takes a ThreadKind so the SAME code runs on platform and on virtual threads. */
public enum ThreadKind {
PLATFORM, VIRTUAL;
Thread.Builder builder(String name) {
return (this == VIRTUAL ? Thread.ofVirtual() : Thread.ofPlatform()).name(name);
}
}
@@ -0,0 +1,89 @@
package com.ankurm.interviewlab.multithreading;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Interview question 4: deadlock. Three ways to move money between two accounts:
*
* 1. {@link #naive} locks "from" then "to". A->B in one thread and B->A in another deadlocks.
* 2. {@link #ordered} always locks the account with the smaller id first. No cycle is possible.
* 3. {@link #withTryLock} locks "from", then tryLock(timeout) on "to"; on failure it lets go of
* everything and retries. Deadlock becomes a (counted) retry.
*
* The {@code afterFirstLock} hook exists so a test can force the unlucky interleaving
* (both threads hold their first lock) on every run instead of one run in a thousand.
*/
public final class Transfers {
private Transfers() { }
public static void naive(Account from, Account to, long amount, Runnable afterFirstLock)
throws InterruptedException {
from.lock.lockInterruptibly(); // interruptible only so the demo can clean up afterwards
try {
afterFirstLock.run();
to.lock.lockInterruptibly();
try {
from.withdraw(amount);
to.deposit(amount);
} finally {
to.lock.unlock();
}
} finally {
from.lock.unlock();
}
}
public static void ordered(Account from, Account to, long amount, Runnable afterFirstLock)
throws InterruptedException {
Account first = from.id < to.id ? from : to;
Account second = first == from ? to : from;
first.lock.lockInterruptibly();
try {
afterFirstLock.run();
second.lock.lockInterruptibly();
try {
from.withdraw(amount);
to.deposit(amount);
} finally {
second.lock.unlock();
}
} finally {
first.lock.unlock();
}
}
/** Returns after the transfer succeeded; every failed attempt increments {@code retries}. */
public static void withTryLock(Account from, Account to, long amount, Runnable afterFirstLock,
AtomicInteger retries) throws InterruptedException {
boolean firstAttempt = true;
while (true) {
from.lock.lockInterruptibly();
boolean done = false;
try {
if (firstAttempt) {
afterFirstLock.run();
firstAttempt = false;
}
if (to.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
from.withdraw(amount);
to.deposit(amount);
done = true;
} finally {
to.lock.unlock();
}
}
} finally {
from.lock.unlock();
}
if (done) {
return;
}
retries.incrementAndGet();
// Asymmetric back-off so two retrying threads cannot stay in lock-step forever.
Thread.sleep(from.id < to.id ? 0 : 25);
}
}
}
@@ -0,0 +1,69 @@
package com.ankurm.interviewlab.multithreading;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@Timeout(60)
class DeadlockTest {
@Test
void naiveLockingDeadlocksAndTheJvmSaysSo() throws Exception {
DeadlockScenario.Result r = DeadlockScenario.run(DeadlockScenario.Strategy.NAIVE,
ThreadKind.PLATFORM, Duration.ofSeconds(5));
List<String> lines = r.deadlock().orElseThrow(() -> new AssertionError("no deadlock reported"));
assertEquals(2, lines.size());
assertTrue(lines.get(0).startsWith("transfer-A-to-B is WAITING on java.util.concurrent.locks.ReentrantLock"));
assertTrue(lines.get(0).endsWith("owned by transfer-B-to-A"));
assertTrue(lines.get(1).endsWith("owned by transfer-A-to-B"));
// We broke the deadlock by interrupting both threads. That is not atomic: the first interrupt
// can free a lock that the other thread grabs before ITS interrupt lands, so one transfer may
// complete. What must hold either way is that no money was created or lost.
assertEquals(2000, r.balanceA() + r.balanceB());
assertFalse(DeadlockDetector.anyDeadlock(), "threads were cleaned up");
}
@Test
void consistentLockOrderingRemovesTheCycle() throws Exception {
for (int i = 0; i < 100; i++) {
DeadlockScenario.Result r = DeadlockScenario.run(DeadlockScenario.Strategy.ORDERED,
ThreadKind.PLATFORM, Duration.ZERO);
assertEquals(930, r.balanceA()); // 1000 - 100 + 30
assertEquals(1070, r.balanceB());
}
}
@Test
void tryLockWithBackOffTurnsDeadlockIntoCountedRetries() throws Exception {
DeadlockScenario.Result r = DeadlockScenario.run(DeadlockScenario.Strategy.TRY_LOCK,
ThreadKind.PLATFORM, Duration.ZERO);
assertEquals(930, r.balanceA());
assertEquals(1070, r.balanceB());
// the barrier guarantees both threads held their first lock, so at least one tryLock had to fail
assertTrue(r.retries() >= 1, "retries=" + r.retries());
}
@Test
void fixesAlsoWorkOnVirtualThreads() throws Exception {
for (DeadlockScenario.Strategy s : List.of(DeadlockScenario.Strategy.ORDERED, DeadlockScenario.Strategy.TRY_LOCK)) {
DeadlockScenario.Result r = DeadlockScenario.run(s, ThreadKind.VIRTUAL, Duration.ZERO);
assertEquals(930, r.balanceA());
assertEquals(1070, r.balanceB());
}
}
@Test
void deadlockedVirtualThreadsAreInvisibleToFindDeadlockedThreads() throws Exception {
DeadlockScenario.Result r = DeadlockScenario.run(DeadlockScenario.Strategy.NAIVE,
ThreadKind.VIRTUAL, Duration.ofMillis(500));
// both virtual threads really are stuck ...
assertEquals(List.of(Thread.State.WAITING, Thread.State.WAITING), r.stuckStates());
// ... and the MXBean still reports nothing
assertTrue(r.deadlock().isEmpty());
}
}
@@ -0,0 +1,61 @@
package com.ankurm.interviewlab.multithreading;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
@Timeout(60)
class OddEvenTest {
private static final List<Supplier<OddEvenPrinter>> IMPLEMENTATIONS = List.of(
OddEvenWaitNotify::new, OddEvenSemaphore::new, OddEvenLockCondition::new);
static List<String> expected(int max) {
List<String> out = new ArrayList<>();
IntStream.rangeClosed(1, max).forEach(n -> out.add((n % 2 == 1 ? "odd:" : "even:") + n));
return out;
}
@ParameterizedTest
@EnumSource(ThreadKind.class)
void everyImplementationPrintsExactlyOneToTwentyInOrder(ThreadKind kind) throws Exception {
for (Supplier<OddEvenPrinter> impl : IMPLEMENTATIONS) {
assertEquals(expected(20), impl.get().print(20, kind), impl.get().getClass().getSimpleName());
}
}
@ParameterizedTest
@EnumSource(ThreadKind.class)
void stableAcrossManyRunsAndOddMax(ThreadKind kind) throws Exception {
// 200 runs per implementation: a lost wake-up or missing loop would hang (-> @Timeout) or reorder.
for (Supplier<OddEvenPrinter> impl : IMPLEMENTATIONS) {
for (int i = 0; i < 200; i++) {
assertEquals(expected(7), impl.get().print(7, kind));
}
}
}
@ParameterizedTest
@EnumSource(ThreadKind.class)
void zeroAndOneAreHandled(ThreadKind kind) throws Exception {
for (Supplier<OddEvenPrinter> impl : IMPLEMENTATIONS) {
assertEquals(List.of(), impl.get().print(0, kind));
assertEquals(List.of("odd:1"), impl.get().print(1, kind));
}
}
@ParameterizedTest
@EnumSource(ThreadKind.class)
void twoDifferentThreadsDoTheWork(ThreadKind kind) throws Exception {
List<String> events = new OddEvenSemaphore().print(10, kind);
assertTrue(events.stream().filter(e -> e.startsWith("odd:")).count() == 5);
assertTrue(events.stream().filter(e -> e.startsWith("even:")).count() == 5);
}
}
@@ -0,0 +1,44 @@
package com.ankurm.interviewlab.multithreading;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
@Timeout(60)
class OrderedThreadsTest {
@ParameterizedTest
@EnumSource(ThreadKind.class)
void latchesRunT1T2T3EvenThoughT3StartsFirst(ThreadKind kind) throws Exception {
for (int i = 0; i < 200; i++) {
assertEquals(List.of("T1", "T2", "T3"), OrderedThreads.withLatches(kind));
}
}
@ParameterizedTest
@EnumSource(ThreadKind.class)
void joinChainRunsT1T2T3(ThreadKind kind) throws Exception {
for (int i = 0; i < 200; i++) {
assertEquals(List.of("T1", "T2", "T3"), OrderedThreads.withJoin(kind));
}
}
@ParameterizedTest
@EnumSource(ThreadKind.class)
void roundRobinOfThreeThreadsPrintsOneToTen(ThreadKind kind) throws Exception {
List<String> expected = IntStream.rangeClosed(1, 10).mapToObj(n -> "worker-" + ((n - 1) % 3 + 1) + ":" + n).toList();
assertEquals(expected, OrderedThreads.roundRobin(3, 10, kind));
}
@Test
void thousandVirtualThreadsStillPrintInOrder() throws Exception {
List<String> events = OrderedThreads.roundRobin(1000, 5000, ThreadKind.VIRTUAL);
List<String> expected = IntStream.rangeClosed(1, 5000).mapToObj(n -> "worker-" + ((n - 1) % 1000 + 1) + ":" + n).toList();
assertEquals(expected, events);
}
}
@@ -0,0 +1,29 @@
package com.ankurm.interviewlab.multithreading;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
@Timeout(60)
class PingPongTest {
@ParameterizedTest
@EnumSource(ThreadKind.class)
void alternatesStrictlyForGivenRounds(ThreadKind kind) throws Exception {
List<String> expected = Collections.nCopies(500, List.of("ping", "pong")).stream()
.flatMap(List::stream).toList();
for (int i = 0; i < 20; i++) {
assertEquals(expected, new PingPong().play(500, kind));
}
}
@ParameterizedTest
@EnumSource(ThreadKind.class)
void zeroRoundsPrintNothing(ThreadKind kind) throws Exception {
assertEquals(List.of(), new PingPong().play(0, kind));
}
}
@@ -0,0 +1,35 @@
package com.ankurm.interviewlab.multithreading;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
/**
* The scheduler for virtual threads is configured when the JVM starts, so this test launches a
* child JVM with exactly ONE carrier thread. Java 25 (JEP 491) lets a virtual thread blocked in
* Object.wait() give its carrier back, so wait/notify odd-even must still finish.
*/
@Timeout(60)
class SingleCarrierVirtualThreadTest {
@Test
void waitNotifyFinishesOnOneCarrierThread() throws Exception {
Path java = Path.of(System.getProperty("java.home"), "bin", "java");
Process p = new ProcessBuilder(java.toString(), "-Xmx256m",
"-Djdk.virtualThreadScheduler.parallelism=1",
"-Djdk.virtualThreadScheduler.maxPoolSize=1",
"-cp", System.getProperty("java.class.path"),
"com.ankurm.interviewlab.multithreading.Demo", "virtual")
.redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes());
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "child JVM hung");
assertEquals(0, p.exitValue(), out);
assertTrue(out.contains("carrier parallelism = 1"), out);
assertTrue(out.contains("[odd:1, even:2, odd:3, even:4, odd:5, even:6, odd:7, even:8, odd:9, even:10]"), out);
assertTrue(out.contains("3000 events"), out);
}
}