Threads, Runnable, Virtual Threads
Java's concurrency unit is the thread. Platform threads wrap OS threads (1MB stack, ~10K max). Virtual threads (Java 21+) are JVM-managed M:N tasks (~few KB), millions per JVM. Use Runnable/Callable to define work; Thread/Executors to schedule.
What threads actually are in Java
Until Java 21, every Java thread was a platform thread, a wrapper around an OS thread. ~1MB stack each, kernel-scheduled, expensive to create (~5-10ms). The JVM-thread-per-OS-thread model meant Java apps maxed out at ~10K threads before kernel scheduling overhead dominated.
Java 21 (LTS) added virtual threads, JVM-managed user-space tasks. Stack is small (a few KB), context switches are cheap (user-space, no kernel transition), spawning millions is realistic. Virtual threads multiplex onto a small pool of carrier (platform) threads.
The big shift The "thread per request" pattern was abandoned in Java around 2010 because platform threads couldn't scale. Async libraries (CompletableFuture, Reactor, RxJava) emerged as the workaround. Java 21 virtual threads bring back the simple synchronous request-per-thread model, at internet scale.
When to use what
| Workload | Choice |
|---|---|
| CPU-bound, sized for cores (4-32) | Platform thread |
| Fewer than ~1000 long-lived threads | Platform |
| I/O-bound, request-per-thread (10K-1M) | Virtual thread (Java 21+) |
| Mixed via ExecutorService | newVirtualThreadPerTaskExecutor (Java 21+) |
| Lightweight async (existing code) | CompletableFuture |
The thread states
A Java thread is always in one of six states. The state machine is small. A new thread starts in NEW, runs in RUNNABLE, and ends in TERMINATED. Three intermediate states describe the different reasons a runnable thread might pause: blocked on a synchronized monitor, waiting indefinitely on a coordination primitive, or waiting for a bounded amount of time.
What each state means and what causes its transitions:
| State | Meaning | How it gets in | How it gets out |
|---|---|---|---|
NEW | Created but never started. | new Thread(...) | start() |
RUNNABLE | On a CPU or in the OS run queue waiting for one. | start() returns; signalled from another wait state | moves to one of the wait states or TERMINATED |
BLOCKED | Wants a synchronized monitor that another thread holds. | hits synchronized while another thread owns it | the monitor is released by its owner |
WAITING | Voluntarily parked with no deadline. | Object.wait(), Thread.join(), LockSupport.park() | notify(), unpark(), or the target thread ends |
TIMED_WAITING | Voluntarily parked with a deadline. | Thread.sleep(ms), Object.wait(ms), Thread.join(ms), LockSupport.parkNanos(ns) | signal arrives or the timeout fires |
TERMINATED | run() returned, normally or via uncaught exception. | run() returns | (terminal, cannot restart) |
Thread.getState() is meant for diagnostics, not control flow. The state can change at any moment, so polling it to decide what to do next is racy. Use real coordination primitives (locks, latches, futures, condition variables) to synchronize between threads.
Cancellation: interrupt + cooperate
Java has no Thread.stop. The cancellation contract:
- Caller invokes
thread.interrupt(). - Thread sees
Thread.currentThread().isInterrupted() == true, OR catchesInterruptedExceptionfrom sleep/wait/join. - Thread does cleanup, returns from
run(). Thread itself decides when to stop.
The most common interrupt bug
Catching InterruptedException and ignoring it. Don't. Either propagate the exception, or re-set the flag with Thread.currentThread().interrupt(). Otherwise the cancellation signal is lost forever and the thread continues running.
Don't create threads directly in production
new Thread(...) works for examples and tests. In production: use ExecutorService (covered in next lesson). Reasons: lifecycle management, naming for diagnostics, exception handling, bounded concurrency, work queue, graceful shutdown. Direct Thread creation provides none of these.
Carrier thread pinning (the one Loom gotcha worth knowing)
A virtual thread normally unmounts from its carrier when it blocks, freeing the carrier to run another virtual thread. Pinning means the virtual thread stays mounted, so the carrier is stuck until the virtual thread unblocks. Pin enough virtual threads at once and the carrier pool (default = Runtime.availableProcessors()) is exhausted, and the service stalls even though the virtual threads themselves haven't done anything wrong.
What pins a virtual thread:
synchronizedblocks and methods. The original Loom shipped with this limitation: while the virtual thread holds (or is waiting for) asynchronizedmonitor, it cannot unmount. Java 24 (JEP 491, fixed in 25) removes this for most cases, but on Java 21-23 it is real.- Native frames (JNI calls). The native code lives on the OS stack of the carrier; the virtual thread can't migrate while it's down there. No language-level fix.
- Some
Object.wait()paths on older JVMs. Now mostly handled by the JIT, but timing-sensitive code can still trip it.
How to detect it:
# Print a stack trace every time a virtual thread pins for >0ms (default 500ms)
java -Djdk.tracePinnedThreads=full -jar app.jar
# 'short' prints just a one-line warning
java -Djdk.tracePinnedThreads=short -jar app.jar
Output looks like:
Thread[#42,ForkJoinPool-1-worker-1,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:181)
java.base/jdk.internal.misc.Blocker.begin(Blocker.java:69)
java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:?)
com.example.LegacyDriver.query(LegacyDriver.java:?) <<< monitors:1
The <<< monitors:N line indicates a synchronized block held N monitors when the pin happened.
How to fix it (where possible):
- Replace
synchronizedwithReentrantLock. ReentrantLock parks the virtual thread without pinning. This is the single most effective fix for high-traffic code paths on Java 21-23. - Move the blocking call out of the synchronized block. Acquire the lock, capture the necessary state, release, then make the I/O call.
- Run on Java 24+ where JEP 491 has removed
synchronizedpinning for most cases. Still pins inside JNI; everything else is fine.
One bad library can pin the whole service
A single legacy JDBC driver that wraps its hot path in synchronized will pin every virtual thread that uses it. Symptom: carrier pool count climbs, then the service freezes for seconds at a time. Run with -Djdk.tracePinnedThreads=short in load tests; treat any output as a bug.
ScopedValue replaces ThreadLocal under virtual threads
ThreadLocal still works on virtual threads but blows up memory. With 1M virtual threads and 50 ThreadLocals, the heap carries ~5GB of per-thread state. ScopedValue (final in Java 25, JEP 506) is the replacement: lexically scoped, auto-cleared, no leak risk, virtual-thread-friendly. Covered in the ThreadLocal lesson.
Primitives by language
- java.lang.Thread (platform thread)
- Thread.ofVirtual / Thread.ofPlatform
- Runnable / Callable
- Executors.newVirtualThreadPerTaskExecutor (Java 21+)
Implementation
Simplest concurrency: Runnable as the task, Thread as the scheduler. start() runs in a new thread; join() waits for it. Thread instances are one-shot, do not reuse.
1 class HelloRunnable implements Runnable {
2 @Override public void run() {
3 System.out.println("Hello from " + Thread.currentThread().getName());
4 }
5 }
6
7 public class Main {
8 public static void main(String[] args) throws InterruptedException {
9 Thread t = new Thread(new HelloRunnable(), "worker-1");
10 t.start();
11 t.join(); // wait for it to finish
12 System.out.println("Worker done, state: " + t.getState());
13 }
14 }Java 21 introduced Thread.ofVirtual() and Thread.ofPlatform() for explicit creation. Virtual threads are JVM-managed; cheap to create; ideal for I/O-bound concurrency.
1 // Platform thread (Java 21+ syntax, same as `new Thread(...)`)
2 Thread platform = Thread.ofPlatform()
3 .name("worker", 0)
4 .start(() -> doWork());
5 platform.join();
6
7 // Virtual thread, Java 21+
8 Thread virtual = Thread.ofVirtual()
9 .name("vt-worker", 0)
10 .start(() -> doWork());
11 virtual.join();
12
13 // Virtual thread, millions are practical
14 List<Thread> threads = new ArrayList<>();
15 for (int i = 0; i < 1_000_000; i++) {
16 threads.add(Thread.ofVirtual().start(() -> {
17 try { Thread.sleep(Duration.ofSeconds(1)); }
18 catch (InterruptedException e) { Thread.currentThread().interrupt(); }
19 }));
20 }
21 for (Thread t : threads) t.join();Runnable returns nothing. Callable returns a value (or throws). Submit to ExecutorService; receive a Future to await the result.
1 import java.util.concurrent.*;
2
3 class FetchTask implements Callable<String> {
4 private final String url;
5 FetchTask(String url) { this.url = url; }
6 @Override public String call() throws Exception {
7 return httpClient.get(url);
8 }
9 }
10
11 public class Main {
12 public static void main(String[] args) throws Exception {
13 ExecutorService executor = Executors.newFixedThreadPool(4);
14 Future<String> future = executor.submit(new FetchTask("https://example.com"));
15 String result = future.get(5, TimeUnit.SECONDS); // 5s timeout
16 executor.shutdown();
17 }
18 }Thread.State enum: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. interrupt() sets a flag and unblocks sleep/wait/join. The thread itself must check isInterrupted() and exit cooperatively.
1 Thread worker = new Thread(() -> {
2 while (!Thread.currentThread().isInterrupted()) {
3 try {
4 Thread.sleep(1000);
5 doWork();
6 } catch (InterruptedException e) {
7 // sleep was interrupted; re-set flag (sleep cleared it)
8 Thread.currentThread().interrupt();
9 break;
10 }
11 }
12 cleanup();
13 });
14 worker.start();
15
16 // Later, request stop
17 worker.interrupt();
18 worker.join();Key points
- •Platform thread = OS thread; Virtual thread = JVM-managed M:N (Java 21+)
- •Use Runnable for fire-and-forget; Callable for results; Thread.start to launch
- •Virtual threads work with all blocking JDK APIs, no library rewrite needed
- •Thread.join() waits for completion; thread.getState() gives runtime state
- •Avoid creating threads directly in production code; use ExecutorService
Follow-up questions
▸Platform thread vs virtual thread, when to choose?
▸Why is Thread.stop() deprecated?
▸How are virtual threads scheduled?
▸When does Runnable beat Callable?
Gotchas
- !Thread.stop() is deprecated and unsafe, never use
- !Thread.sleep() and Object.wait() CLEAR the interrupt flag, re-set with Thread.currentThread().interrupt()
- !Don't reuse Thread instances, start() throws on a started/terminated thread
- !Virtual threads pin to their carrier inside synchronized blocks, use ReentrantLock for I/O-heavy code on virtual threads
- !Daemon threads are killed at JVM exit, don't use for cleanup work