diff --git a/README.md b/README.md
index 261b82c..7d7b881 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ One module per article. This repository currently holds:
| [`hibernate-jpa`](hibernate-jpa/) | Top 40 Hibernate and JPA Interview Questions (2026) with Runnable Answers | this commit |
| [`junit-mockito`](junit-mockito/) | Top 30 JUnit 6 and Mockito Interview Questions (2026) | this commit |
| [`hashmap-vs-concurrenthashmap`](hashmap-vs-concurrenthashmap/) | HashMap vs ConcurrentHashMap in Java: Internals, Thread Safety and Interview Answers | this commit |
+| [`multithreading`](multithreading/) | Java Multithreading Programs for Interviews: Odd-Even Printing, Ping-Pong, Ordered Threads, Deadlock | this commit |
## `collections` module
diff --git a/multithreading/README.md b/multithreading/README.md
new file mode 100644
index 0000000..dd959fe
--- /dev/null
+++ b/multithreading/README.md
@@ -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.
diff --git a/multithreading/output/01-odd-even.txt b/multithreading/output/01-odd-even.txt
new file mode 100644
index 0000000..ef657d6
--- /dev/null
+++ b/multithreading/output/01-odd-even.txt
@@ -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]
diff --git a/multithreading/output/02-ping-pong.txt b/multithreading/output/02-ping-pong.txt
new file mode 100644
index 0000000..f0092ac
--- /dev/null
+++ b/multithreading/output/02-ping-pong.txt
@@ -0,0 +1,2 @@
+PingPong, 4 rounds
+ [ping, pong, ping, pong, ping, pong, ping, pong]
diff --git a/multithreading/output/03-ordered-threads.txt b/multithreading/output/03-ordered-threads.txt
new file mode 100644
index 0000000..605aaf0
--- /dev/null
+++ b/multithreading/output/03-ordered-threads.txt
@@ -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]
diff --git a/multithreading/output/04-deadlock.txt b/multithreading/output/04-deadlock.txt
new file mode 100644
index 0000000..a2953cd
--- /dev/null
+++ b/multithreading/output/04-deadlock.txt
@@ -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)
diff --git a/multithreading/output/05-virtual-single-carrier.txt b/multithreading/output/05-virtual-single-carrier.txt
new file mode 100644
index 0000000..4b2a596
--- /dev/null
+++ b/multithreading/output/05-virtual-single-carrier.txt
@@ -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]
diff --git a/multithreading/output/06-test-results.txt b/multithreading/output/06-test-results.txt
new file mode 100644
index 0000000..51de37f
--- /dev/null
+++ b/multithreading/output/06-test-results.txt
@@ -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
diff --git a/multithreading/pom.xml b/multithreading/pom.xml
new file mode 100644
index 0000000..079c5f2
--- /dev/null
+++ b/multithreading/pom.xml
@@ -0,0 +1,27 @@
+
+
+ 4.0.0
+
+ com.ankurm.interviewlab
+ multithreading
+ 1.0.0
+ multithreading
+ Classic multithreading interview programs: odd-even, ping-pong, ordered threads, deadlock
+
+
+ 25
+ UTF-8
+ 6.1.3
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
diff --git a/multithreading/scripts/run-all.sh b/multithreading/scripts/run-all.sh
new file mode 100755
index 0000000..ea6641f
--- /dev/null
+++ b/multithreading/scripts/run-all.sh
@@ -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
diff --git a/multithreading/scripts/run.sh b/multithreading/scripts/run.sh
new file mode 100755
index 0000000..fd2685f
--- /dev/null
+++ b/multithreading/scripts/run.sh
@@ -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'
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Account.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Account.java
new file mode 100644
index 0000000..a431e62
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Account.java
@@ -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(); }
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/DeadlockDetector.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/DeadlockDetector.java
new file mode 100644
index 0000000..2d9aa79
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/DeadlockDetector.java
@@ -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> 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 describe(long[] ids) {
+ List 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;
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/DeadlockScenario.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/DeadlockScenario.java
new file mode 100644
index 0000000..6e6e6fa
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/DeadlockScenario.java
@@ -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> deadlock, List 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> deadlock = Optional.empty();
+ List 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);
+ }
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Demo.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Demo.java
new file mode 100644
index 0000000..6a004cc
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Demo.java
@@ -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 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()));
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Group.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Group.java
new file mode 100644
index 0000000..b471fd5
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Group.java
@@ -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 threads = new ArrayList<>();
+ private final List 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 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));
+ }
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenLockCondition.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenLockCondition.java
new file mode 100644
index 0000000..6d7ea5b
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenLockCondition.java
@@ -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 events = new ArrayList<>(); // guarded by lock
+
+ @Override
+ public List 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();
+ }
+ }
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenPrinter.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenPrinter.java
new file mode 100644
index 0000000..2dfbbe4
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenPrinter.java
@@ -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 print(int max, ThreadKind kind) throws Exception;
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenSemaphore.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenSemaphore.java
new file mode 100644
index 0000000..f3265a1
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenSemaphore.java
@@ -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 print(int max, ThreadKind kind) throws Exception {
+ Semaphore oddTurn = new Semaphore(1); // odd goes first
+ Semaphore evenTurn = new Semaphore(0);
+ List 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);
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenWaitNotify.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenWaitNotify.java
new file mode 100644
index 0000000..0212c02
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OddEvenWaitNotify.java
@@ -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 events = new ArrayList<>(); // guarded by monitor
+
+ @Override
+ public List 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
+ }
+ }
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OrderedThreads.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OrderedThreads.java
new file mode 100644
index 0000000..7f4b0e5
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/OrderedThreads.java
@@ -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 withLatches(ThreadKind kind) throws Exception {
+ List 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 withJoin(ThreadKind kind) throws Exception {
+ List 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 roundRobin(int threadCount, int max, ThreadKind kind) throws Exception {
+ List 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);
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/PingPong.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/PingPong.java
new file mode 100644
index 0000000..1203bc0
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/PingPong.java
@@ -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 events = new ArrayList<>(); // guarded by lock
+
+ public List 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();
+ }
+ }
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/ThreadKind.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/ThreadKind.java
new file mode 100644
index 0000000..42c619e
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/ThreadKind.java
@@ -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);
+ }
+}
diff --git a/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Transfers.java b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Transfers.java
new file mode 100644
index 0000000..3288518
--- /dev/null
+++ b/multithreading/src/main/java/com/ankurm/interviewlab/multithreading/Transfers.java
@@ -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);
+ }
+ }
+}
diff --git a/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/DeadlockTest.java b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/DeadlockTest.java
new file mode 100644
index 0000000..57c748a
--- /dev/null
+++ b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/DeadlockTest.java
@@ -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 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());
+ }
+}
diff --git a/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/OddEvenTest.java b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/OddEvenTest.java
new file mode 100644
index 0000000..95baa4e
--- /dev/null
+++ b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/OddEvenTest.java
@@ -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> IMPLEMENTATIONS = List.of(
+ OddEvenWaitNotify::new, OddEvenSemaphore::new, OddEvenLockCondition::new);
+
+ static List expected(int max) {
+ List 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 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 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 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 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);
+ }
+}
diff --git a/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/OrderedThreadsTest.java b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/OrderedThreadsTest.java
new file mode 100644
index 0000000..972217b
--- /dev/null
+++ b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/OrderedThreadsTest.java
@@ -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 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 events = OrderedThreads.roundRobin(1000, 5000, ThreadKind.VIRTUAL);
+ List expected = IntStream.rangeClosed(1, 5000).mapToObj(n -> "worker-" + ((n - 1) % 1000 + 1) + ":" + n).toList();
+ assertEquals(expected, events);
+ }
+}
diff --git a/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/PingPongTest.java b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/PingPongTest.java
new file mode 100644
index 0000000..0a4a1ce
--- /dev/null
+++ b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/PingPongTest.java
@@ -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 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));
+ }
+}
diff --git a/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/SingleCarrierVirtualThreadTest.java b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/SingleCarrierVirtualThreadTest.java
new file mode 100644
index 0000000..a41a99d
--- /dev/null
+++ b/multithreading/src/test/java/com/ankurm/interviewlab/multithreading/SingleCarrierVirtualThreadTest.java
@@ -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);
+ }
+}