Thread Model: Legacy Control vs. Virtual Threads
Objective
Early Java (before Java 2) let you pause, restart, and kill a thread from the outside with Thread.suspend(), resume(), and stop(). All three were deprecated for being able to corrupt shared state, and the fix — cooperative, flag-checked shutdown instead of external control — is still the shape of correct thread lifecycle code today. Their decline didn't stop at deprecation either: suspend()/resume() were removed outright from the API in Java SE 23, and stop() — still present and still deprecated for removal — now always throws UnsupportedOperationException instead of doing anything. That same pre-2 era also treated a Thread as an expensive, OS-backed resource to be rationed with pools. Virtual threads (JEP 444, standard since Java 21) don't change why suspend/resume/stop were unsafe, but they do remove the reason those old pooling habits existed in the first place.
Use Cases
- Recognizing
Thread.suspend()/resume()/stop()(or code that still calls them) as a correctness bug, not just a style nit, when reading or porting old Java code. - Implementing pause/resume/cancel for a long-running task using a checked flag instead of external thread control.
- Deciding whether a piece of concurrent code needs a bounded
ExecutorService(platform threads, a scarce resource) or can just spin up one virtual thread per task (an abundant one) — the same cost/pooling question the book's era answered only one way.
Deep Dive
Why suspend()/resume()/stop() were deprecated
java// Legacy control API — deprecated since Java 1.2, shown for what NOT to do.
// On current JDKs this no longer even compiles as written; see below.
Thread worker = new Thread(() -> updateCriticalStructure());
worker.start();
worker.suspend(); // Thread.suspend() — removed from the API in Java SE 23
worker.stop(); // Thread.stop() — still present, but always throws nowsuspend() freezes a thread wherever it happens to be — including in the middle of holding a lock on a critical data structure. Because a suspended thread doesn't release what it holds, any other thread waiting on that same lock deadlocks: the only thread that could release the lock (by calling resume()) is usually a different thread than the one now blocked waiting for it. stop() is worse: it force-releases every lock the target thread holds, at whatever point execution happened to be, so a data structure that was mid-update is left in a corrupted state — and that corrupted state is now visible to any other thread that was waiting on the just-released lock.
This is not just historical color: both methods were @Deprecated(forRemoval = true) for years, and the JVM has since followed through in stages. Thread.suspend()/resume() were first changed to always throw UnsupportedOperationException (Java 20), then removed from Thread entirely in Java SE 23 — calling worker.suspend() against a current JDK fails to compile with "cannot find symbol." Thread.stop() took the softer path: the method is still declared on Thread (still deprecated for removal), but its body now unconditionally throws UnsupportedOperationException — it compiles, but it can never again stop a thread.
The replacement: cooperative shutdown via a checked flag
javaclass Worker extends Thread {
private volatile boolean running = true;
@Override
public void run() {
while (running) {
doUnitOfWork();
}
}
void requestStop() { // called from another thread
running = false;
}
}Instead of an external thread reaching in and freezing/killing another thread mid-instruction, the target thread checks its own flag at a safe point between units of work and exits run() on its own terms — never mid-update, never while holding a lock it doesn't know it's abandoning. This is the same idea the book's own wait()/notify()-based suspend/resume example demonstrates, and it's still exactly how correct cooperative cancellation works today (Thread.interrupt() plus a checked isInterrupted()/InterruptedException is the standard-library version of the same pattern).
What virtual threads change — and what they don't
java// One virtual thread per task, no pool sizing decision to make
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Result> f1 = executor.submit(() -> callServiceA());
Future<Result> f2 = executor.submit(() -> callServiceB());
}The book's threading model treats a Thread as a scarce, OS-backed resource — one reason thread pools (reusing a bounded set of platform threads across many tasks) became standard practice: creating thousands of platform threads risks exhausting OS resources. Virtual threads (JEP 444) are mapped many-to-one onto a small number of carrier (platform) threads and unmounted while blocked on I/O, so millions can exist at once — the official guidance is explicitly to stop pooling them and instead create one per task, using a Semaphore if concurrency needs to be capped. This doesn't touch the suspend/resume/stop problem at all — a virtual thread has the exact same java.lang.Thread contract and would corrupt state exactly the same way if those deprecated methods still worked on it. Virtual threads solve a cost/scale problem (how many threads you can afford to have), not a correctness problem (what a thread is allowed to do to shared state while it's paused or killed).
Trade-offs
- The old API didn't just get a warning label — the JVM eventually enforced it —
stop()still compiles (deprecated for removal) but its body now unconditionally throws, so old code calling it fails loudly at run time instead of silently corrupting state;suspend()/resume()went further and were deleted fromThread, so old source calling them no longer compiles at all.
javaThread t = new Thread(() -> {});
t.start();
t.stop(); // compiles; throws UnsupportedOperationException at run time
t.suspend(); // compile error: cannot find symbol — removed in Java SE 23- Cooperative shutdown adds a small amount of ceremony — the task's own loop has to check a flag or handle
InterruptedException, versus the (unsafe) one-linerstop(); there's no way to force-terminate a thread that isn't cooperating, by design. - Pooling habits from the platform-thread era don't just disappear — code that wraps virtual threads in a fixed-size
ExecutorService(instead ofnewVirtualThreadPerTaskExecutor()) keeps the old scarcity mindset and throws away most of the scalability virtual threads offer, without being wrong in any way the compiler can flag.