MindIQ Academy

10 - Multithreading and Virtual Threads

A complete beginner-to-advanced guide to Java concurrency, aligned with the Oracle Certified Professional: Java SE 21 Developer (1Z0-830) exam objectives. Thorough coverage of Java 21 Virtual Threads.


Table of Contents

  1. Concurrency Basics
  2. Creating Threads
  3. Thread Lifecycle
  4. Thread Methods
  5. Race Conditions
  6. Synchronization
  7. Locks
  8. Deadlocks
  9. The Executor Framework
  10. Runnable vs Callable
  11. Future
  12. CompletableFuture
  13. Concurrent Collections
  14. Atomic Classes
  15. Virtual Threads (Java 21)
  16. Platform vs Virtual Threads
  17. Certification Traps
  18. Common Mistakes
  19. Interview Questions
  20. Quick Revision Notes
  21. One-Page Cheat Sheet

1. Concurrency Basics

Concurrency means dealing with many tasks at once. A thread is the smallest unit of execution. Multiple threads in one program run (seemingly) simultaneously.

TermMeaning
ProcessAn independent running program with its own memory.
ThreadA lightweight unit of execution inside a process.
ConcurrencyManaging multiple tasks (may interleave on one core).
ParallelismTasks literally running at the same time (multiple cores).
Main threadThe thread that runs main().
   Process (JVM)
   +-------------------------------+
   |  Thread 1   Thread 2  Thread 3|  <- share heap memory
   |   (main)    (worker)  (worker)|
   +-------------------------------+

Benefit: responsiveness and throughput. Cost: complexity — shared data must be coordinated.


2. Creating Threads

Way 1: Extend Thread

class MyThread extends Thread {
    public void run() {
        System.out.println("Running: " + Thread.currentThread().getName());
    }
}
new MyThread().start();         // start() spawns a new thread

Way 2: Implement Runnable (preferred)

class MyTask implements Runnable {
    public void run() {
        System.out.println("Task running");
    }
}
new Thread(new MyTask()).start();

// With a lambda (Runnable is a functional interface):
new Thread(() -> System.out.println("Lambda task")).start();

start() vs run() (Critical Trap)

Thread t = new Thread(() -> System.out.println("hi"));
t.run();     // runs in the CURRENT thread (no new thread!)
t.start();   // runs in a NEW thread

start() creates a new thread and calls run() on it. Calling run() directly executes in the current thread — no concurrency. Calling start() twice throws IllegalThreadStateException.


3. Thread Lifecycle

A thread moves through well-defined states (Thread.State enum).

         start()              scheduler picks
  NEW --------------> RUNNABLE <-------------> RUNNING
                       ^   |  \
       notify()/       |   |   \  sleep()/wait()/join()/lock
       lock acquired   |   |    \
                       |   |     v
              BLOCKED/WAITING/TIMED_WAITING
                       |
                  run() completes
                       v
                  TERMINATED
StateMeaning
NEWCreated but start() not yet called.
RUNNABLEReady to run or running (scheduler decides).
BLOCKEDWaiting to acquire a monitor lock.
WAITINGWaiting indefinitely (wait(), join()).
TIMED_WAITINGWaiting with a timeout (sleep(ms), wait(ms)).
TERMINATEDFinished execution.
Thread t = new Thread(() -> {});
System.out.println(t.getState());   // NEW
t.start();
// ... RUNNABLE -> TERMINATED

4. Thread Methods

MethodEffect
start()Begin execution in a new thread.
run()The task body (don't call directly).
sleep(ms)Pause current thread (keeps locks).
join()Wait for another thread to finish.
interrupt()Request a thread to stop.
setDaemon(true)Make it a background (daemon) thread.
setPriority(n)Hint scheduler priority (1–10).
isAlive()Whether the thread is still running.
Thread worker = new Thread(() -> {
    try { Thread.sleep(1000); } catch (InterruptedException e) { }
    System.out.println("done");
});
worker.start();
worker.join();          // main waits here until worker finishes
System.out.println("after join");

Daemon Threads

Thread daemon = new Thread(() -> { while (true) {} });
daemon.setDaemon(true);   // JVM won't wait for daemon threads to exit
daemon.start();

Trap: sleep() holds any locks it has; wait() releases the lock. setDaemon() must be called before start().


5. Race Conditions

A race condition occurs when multiple threads access shared mutable data concurrently and the result depends on timing.

class Counter {
    int count = 0;
    void increment() { count++; }   // NOT atomic: read, add, write
}

Counter c = new Counter();
Runnable task = () -> { for (int i = 0; i < 10000; i++) c.increment(); };

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join();  t2.join();
System.out.println(c.count);   // often LESS than 20000 — lost updates!

Why? count++ Is Three Steps

   Thread A reads count = 5
   Thread B reads count = 5     <- both read the same value
   Thread A writes 6
   Thread B writes 6            <- one increment LOST

The fix is synchronization or atomic operations (below).


6. Synchronization

synchronized ensures only one thread at a time executes a critical section, using an object's intrinsic monitor lock.

Synchronized Method

class Counter {
    private int count = 0;
    public synchronized void increment() { count++; }   // thread-safe now
    public synchronized int get() { return count; }
}

Synchronized Block (finer-grained)

class Counter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {       // lock only this section
            count++;
        }
    }
}

Key Concepts

ConceptDetail
Monitor lockEach object has one; synchronized acquires it.
Static synchronizedLocks on the class object (Counter.class).
volatileGuarantees visibility (not atomicity) of a field.
happens-beforeMemory model rule ensuring writes are visible to other threads.

volatile

class Flag {
    private volatile boolean running = true;   // visible across threads
    void stop() { running = false; }
    void loop() { while (running) { /* ... */ } }   // sees updates
}

Trap: volatile ensures visibility but not atomicity — volatile int x; x++ is still a race condition. Use synchronized or atomics for compound actions.

wait / notify

synchronized (lock) {
    while (!condition) lock.wait();   // releases lock, waits
    // ...
    lock.notifyAll();                 // wakes waiting threads
}

wait, notify, notifyAll must be called inside a synchronized block on the same object, or you get IllegalMonitorStateException.


7. Locks

The java.util.concurrent.locks package offers more flexible locking than synchronized.

ReentrantLock

import java.util.concurrent.locks.*;

class Counter {
    private final Lock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();        // ALWAYS unlock in finally
        }
    }
}

Lock vs synchronized

FeaturesynchronizedReentrantLock
Acquire/releaseAutomaticManual (lock/unlock)
Try without blockingtryLock()
InterruptiblelockInterruptibly()
Fairness optionnew ReentrantLock(true)
Multiple conditionsnewCondition()

ReadWriteLock

ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock();    // many readers allowed concurrently
// ...
rwLock.readLock().unlock();

rwLock.writeLock().lock();   // exclusive for writers
// ...
rwLock.writeLock().unlock();

Trap: Always release a lock in a finally block, or an exception can leave it permanently locked.


8. Deadlocks

A deadlock occurs when two or more threads each hold a lock the other needs, so none can proceed.

// Thread 1: locks A then B
synchronized (A) { synchronized (B) { } }

// Thread 2: locks B then A   <- opposite order = deadlock risk
synchronized (B) { synchronized (A) { } }
   Thread 1 holds A, wants B  ----+
                                  |  circular wait = DEADLOCK
   Thread 2 holds B, wants A  ----+

The Four Coffman Conditions (all required)

ConditionMeaning
Mutual exclusionResource held by one thread.
Hold and waitHolds one, waits for another.
No preemptionLocks can't be forcibly taken.
Circular waitA cycle of waiting threads.

Prevention

StrategyDetail
Lock orderingAlways acquire locks in the same global order.
tryLock with timeoutBack off if you can't get all locks.
Reduce lock scopeHold locks for the shortest time.

Related: livelock (threads keep reacting, no progress) and starvation (a thread never gets CPU/lock time).


9. The Executor Framework

Manually creating threads is expensive and hard to manage. The Executor Framework provides thread pools that reuse threads.

import java.util.concurrent.*;

ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> System.out.println("Task running"));
executor.shutdown();          // graceful: finish submitted tasks, no new ones

Factory Methods

FactoryPool Type
newFixedThreadPool(n)Fixed number of threads.
newCachedThreadPool()Grows/shrinks as needed.
newSingleThreadExecutor()One worker, sequential tasks.
newScheduledThreadPool(n)For delayed/periodic tasks.
newVirtualThreadPerTaskExecutor()One virtual thread per task (Java 21).

Shutdown Methods

MethodBehavior
shutdown()Stops accepting tasks; finishes existing ones.
shutdownNow()Attempts to stop all immediately.
awaitTermination(t, unit)Blocks until done or timeout.

ScheduledExecutorService

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

// Run once after a delay:
scheduler.schedule(() -> System.out.println("delayed"), 2, TimeUnit.SECONDS);

// Run repeatedly:
scheduler.scheduleAtFixedRate(() -> System.out.println("tick"),
        0, 1, TimeUnit.SECONDS);

Trap: If you never call shutdown(), the JVM may not exit (non-daemon pool threads keep it alive).


10. Runnable vs Callable

FeatureRunnableCallable<V>
Methodrun()call()
Returns a value❌ No (void)✅ Yes (V)
Throws checked exception❌ No✅ Yes
Used withThread, execute, submitsubmit, invokeAll
SinceJava 1.0Java 5
Runnable runnable = () -> System.out.println("no result");

Callable<Integer> callable = () -> {
    Thread.sleep(100);
    return 42;                    // returns a value, can throw
};

ExecutorService ex = Executors.newSingleThreadExecutor();
Future<Integer> future = ex.submit(callable);
System.out.println(future.get()); // 42
ex.shutdown();

11. Future

A Future<V> represents the result of an asynchronous computation that may not be ready yet.

ExecutorService ex = Executors.newFixedThreadPool(2);

Future<Integer> future = ex.submit(() -> {
    Thread.sleep(1000);
    return 100;
});

System.out.println(future.isDone());   // false (probably)
Integer result = future.get();         // BLOCKS until ready -> 100
ex.shutdown();

Future Methods

MethodPurpose
get()Block until result (throws on timeout/cancel).
get(timeout, unit)Block with a timeout.
isDone()Whether computation finished.
cancel(boolean)Attempt to cancel.
isCancelled()Whether it was cancelled.
// invokeAll runs many Callables and returns a list of Futures:
List<Callable<Integer>> tasks = List.of(() -> 1, () -> 2, () -> 3);
List<Future<Integer>> results = ex.invokeAll(tasks);

Trap: future.get() blocks the calling thread until the result is available. Wrap in a timeout to avoid hanging forever.


12. CompletableFuture

CompletableFuture (Java 8+) supports non-blocking, chainable async pipelines — far more powerful than Future.

CompletableFuture.supplyAsync(() -> 10)
    .thenApply(n -> n * 2)              // transform: 20
    .thenApply(n -> n + 5)             // transform: 25
    .thenAccept(System.out::println);  // consume: prints 25

Key Methods

MethodPurpose
supplyAsync(Supplier)Start async task returning a value.
runAsync(Runnable)Start async task with no result.
thenApply(fn)Transform the result.
thenAccept(consumer)Consume the result.
thenCompose(fn)Chain another future (flatMap).
thenCombine(other, fn)Combine two futures.
exceptionally(fn)Handle errors.
join() / get()Wait for the result.
// Combining two async tasks:
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> 20);

a.thenCombine(b, Integer::sum)
 .thenAccept(sum -> System.out.println("Sum: " + sum));   // 30

// Error handling:
CompletableFuture.supplyAsync(() -> { throw new RuntimeException("fail"); })
    .exceptionally(ex -> -1)
    .thenAccept(System.out::println);    // -1

thenApply vs thenCompose: thenApply maps to a value; thenCompose flattens a returned CompletableFuture (avoids nesting).


13. Concurrent Collections

Standard collections (ArrayList, HashMap) are not thread-safe. The java.util.concurrent package provides safe alternatives.

CollectionThread-Safe Alternative ToNotes
ConcurrentHashMapHashMapSegment/bucket-level locking; high throughput.
CopyOnWriteArrayListArrayListCopies on write; great for many reads, few writes.
ConcurrentLinkedQueueLinkedList queueNon-blocking FIFO.
BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue)Producer-consumer; blocks when full/empty.
ConcurrentSkipListMapTreeMapSorted, concurrent.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
map.merge("a", 10, Integer::sum);          // atomic update -> 11

// Producer-consumer with BlockingQueue:
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
queue.put(1);          // blocks if full
int item = queue.take(); // blocks if empty

Trap: ConcurrentHashMap does not allow null keys or values (unlike HashMap). Use Collections.synchronizedMap only for simple needs — it locks the whole map.


14. Atomic Classes

Atomic classes in java.util.concurrent.atomic provide lock-free, thread-safe operations on single variables using CPU compare-and-swap (CAS).

import java.util.concurrent.atomic.*;

AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();        // atomic ++ -> 1
counter.addAndGet(5);             // atomic += 5 -> 6
counter.compareAndSet(6, 10);     // if 6, set to 10
System.out.println(counter.get()); // 10
ClassUse
AtomicIntegerThread-safe int.
AtomicLongThread-safe long.
AtomicBooleanThread-safe boolean.
AtomicReference<T>Thread-safe object reference.

Fixing the Race Condition

class Counter {
    private final AtomicInteger count = new AtomicInteger(0);
    public void increment() { count.incrementAndGet(); }   // no lock needed
    public int get() { return count.get(); }
}
// Now two threads x 10000 -> exactly 20000

Why atomics? They avoid lock overhead for simple counters/flags using hardware CAS — faster than synchronized for single-variable updates.


15. Virtual Threads (Java 21)

Virtual threads (Project Loom, finalized in Java 21) are lightweight threads managed by the JVM, not the OS. You can run millions of them cheaply.

The Problem They Solve

Traditional platform threads map 1:1 to OS threads (~1 MB stack each). Blocking thousands of them (e.g., waiting on I/O) is expensive. Virtual threads are cheap and unmount from their carrier thread when they block.

Creating Virtual Threads

// Way 1: directly
Thread vt = Thread.ofVirtual().start(() -> System.out.println("virtual!"));
vt.join();

// Way 2: unstarted
Thread t = Thread.ofVirtual().unstarted(() -> {});
t.start();

// Way 3: factory / executor (recommended)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> System.out.println("task"));
}   // try-with-resources auto-closes (waits for tasks)

// Way 4: builder with name
Thread.ofVirtual().name("worker-", 0).start(() -> {});

Millions of Threads

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            Thread.sleep(1000);     // cheap to block!
            return null;
        });
    }
}   // all 1,000,000 virtual threads run efficiently

How They Work

   Many VIRTUAL threads
   v1 v2 v3 v4 v5 ... v1000000
        |  mounted on
        v
   Few CARRIER (platform) threads (ForkJoinPool)
   p1 p2 p3 p4    <- = number of CPU cores

   When a virtual thread BLOCKS (I/O, sleep),
   it UNMOUNTS, freeing the carrier for another virtual thread.

Pinning (Trap)

A virtual thread is pinned (cannot unmount) when it blocks inside a synchronized block or a native call. This reduces scalability.

// AVOID: synchronized pins the carrier thread
synchronized (lock) {
    Thread.sleep(1000);     // pins! carrier is blocked
}

// PREFER: ReentrantLock does NOT pin
lock.lock();
try { Thread.sleep(1000); } finally { lock.unlock(); }

Key: Prefer ReentrantLock over synchronized in code that runs on virtual threads to avoid pinning.


16. Platform vs Virtual Threads

FeaturePlatform ThreadVirtual Thread
Managed byOSJVM
Mapping1:1 with OS threadMany:few (multiplexed)
Stack sizeLarge (~1 MB)Small, growable (heap)
Count feasibleThousandsMillions
Creation costExpensiveVery cheap
Best forCPU-bound workI/O-bound, high-concurrency
PoolingRecommendedNot needed (create per task)
Blocking costHigh (OS thread idle)Low (unmounts carrier)
Thread.ofVirtual()n/aCreates one
Thread platform = Thread.ofPlatform().start(() -> {});   // OS thread
Thread virtual  = Thread.ofVirtual().start(() -> {});    // JVM thread

System.out.println(virtual.isVirtual());   // true (Java 21)

When to Use Which

Use Platform ThreadsUse Virtual Threads
CPU-intensive computationMany blocking I/O operations
Limited, long-running tasksThousands of concurrent requests
Need thread affinityServer handling many connections

Trap: Do not pool virtual threads — they're cheap, so create one per task (newVirtualThreadPerTaskExecutor). Pooling them defeats their purpose.


17. Certification Traps

#Trap
1run() runs in the current thread; only start() spawns a new one.
2Calling start() twice → IllegalThreadStateException.
3volatile gives visibility, not atomicity; x++ is still a race.
4sleep() keeps locks; wait() releases the lock.
5wait/notify must be inside synchronized or → IllegalMonitorStateException.
6setDaemon() must be called before start().
7Always unlock() in a finally block.
8Callable returns a value and can throw checked exceptions; Runnable cannot.
9Future.get() blocks the caller.
10ConcurrentHashMap forbids null keys/values.
11Forgetting shutdown() can keep the JVM alive.
12thenApply maps to a value; thenCompose flattens a nested future.
13Virtual threads should not be pooled — one per task.
14synchronized pins a virtual thread; prefer ReentrantLock.
15Deadlock needs all four Coffman conditions; fix via lock ordering.

18. Common Mistakes

MistakeFix
Calling run() instead of start()Use start() for concurrency.
Using volatile for countersUse AtomicInteger or synchronized.
Forgetting to unlock()Always unlock in finally.
Not shutting down an executorCall shutdown() (or use try-with-resources).
Inconsistent lock orderingAcquire locks in a global order.
Pooling virtual threadsCreate one virtual thread per task.
synchronized on virtual threadsUse ReentrantLock to avoid pinning.
Sharing non-thread-safe collectionsUse concurrent collections.

19. Interview Questions

Q1. Difference between a process and a thread? A process is an independent program with its own memory; a thread is a lightweight execution unit inside a process sharing its memory.

Q2. Difference between start() and run()? start() creates a new thread and invokes run() on it; calling run() directly executes in the current thread with no new thread.

Q3. What is a race condition? A bug where concurrent threads access shared mutable state and the outcome depends on timing, causing inconsistent results.

Q4. What does synchronized do? It allows only one thread at a time into a critical section by acquiring an object's monitor lock, providing mutual exclusion and visibility.

Q5. Difference between volatile and synchronized? volatile guarantees visibility of a single variable; synchronized guarantees both mutual exclusion (atomicity) and visibility for a block.

Q6. What is a deadlock and how do you prevent it? Two+ threads each waiting on locks held by the other. Prevent it with consistent lock ordering, tryLock with timeouts, or reducing lock scope.

Q7. Difference between Runnable and Callable? Callable returns a value and can throw checked exceptions; Runnable returns nothing and cannot throw checked exceptions.

Q8. What is the difference between Future and CompletableFuture? Future only lets you block and get a result; CompletableFuture supports non-blocking chaining, combining, and error handling.

Q9. Why use atomic classes? For lock-free, thread-safe updates to single variables using CPU compare-and-swap, which is faster than locking for simple operations.

Q10. What are virtual threads? Lightweight JVM-managed threads (Java 21) that unmount from carrier threads when blocked, enabling millions of concurrent tasks cheaply.

Q11. Difference between platform and virtual threads? Platform threads map 1:1 to costly OS threads; virtual threads are cheap, JVM-multiplexed onto a few carriers, ideal for I/O-bound concurrency.

Q12. What is thread pinning? When a virtual thread cannot unmount from its carrier (e.g., inside synchronized or native code), reducing scalability — prefer ReentrantLock.


20. Quick Revision Notes

  • Thread = unit of execution; start() spawns, run() doesn't.
  • Lifecycle: NEW → RUNNABLE → (BLOCKED/WAITING/TIMED_WAITING) → TERMINATED.
  • start() twice → IllegalThreadStateException; setDaemon before start.
  • Race condition: unsynchronized shared mutable state; count++ not atomic.
  • synchronized = mutual exclusion + visibility; volatile = visibility only.
  • wait/notify inside synchronized; sleep keeps locks, wait releases.
  • ReentrantLock: manual lock/unlock (finally!), tryLock, fairness, conditions.
  • Deadlock = 4 Coffman conditions; prevent with lock ordering.
  • Executors: fixed/cached/single/scheduled/virtual-per-task; always shutdown().
  • Runnable (void, no checked) vs Callable (value + checked).
  • Future.get() blocks; CompletableFuture chains (thenApply/thenCompose/thenCombine/exceptionally).
  • Concurrent collections: ConcurrentHashMap (no nulls), CopyOnWriteArrayList, BlockingQueue.
  • Atomics: lock-free single-variable ops via CAS.
  • Virtual threads (21): millions cheap; Thread.ofVirtual(), newVirtualThreadPerTaskExecutor(); don't pool.
  • synchronized pins virtual threads → prefer ReentrantLock.

21. One-Page Cheat Sheet

==================== MULTITHREADING & VIRTUAL THREADS CHEAT SHEET ====================

CREATE THREADS
  extend Thread / implement Runnable (preferred) / lambda
  start() = NEW thread ; run() = current thread ; start() twice -> ITSException

LIFECYCLE
  NEW -start()-> RUNNABLE <-> RUNNING -> TERMINATED
  BLOCKED (lock) / WAITING (wait,join) / TIMED_WAITING (sleep,wait(ms))

METHODS
  sleep(ms) keeps locks | wait() releases lock | join() wait for finish
  setDaemon(true) BEFORE start ; interrupt() request stop

RACE CONDITION
  count++ = read-modify-write (not atomic) -> lost updates
  fix: synchronized / Lock / Atomic

SYNCHRONIZATION
  synchronized method/block -> monitor lock (1 thread)
  static synchronized -> locks Class object
  volatile = VISIBILITY only (not atomic)
  wait/notify/notifyAll inside synchronized (else IllegalMonitorState)

LOCKS
  ReentrantLock: lock(); try{...} finally{ unlock(); }
  tryLock(), lockInterruptibly(), fairness, newCondition()
  ReadWriteLock: many readers OR one writer

DEADLOCK (4 conditions: mutual excl, hold&wait, no preempt, circular wait)
  prevent: consistent LOCK ORDERING / tryLock timeout

EXECUTOR FRAMEWORK
  Executors.newFixedThreadPool(n)/Cached/SingleThread/ScheduledThreadPool
  submit(Runnable/Callable) -> Future ; invokeAll(tasks)
  shutdown() / shutdownNow() / awaitTermination()
  ScheduledExecutorService: schedule / scheduleAtFixedRate

RUNNABLE vs CALLABLE
  Runnable: run(), void, no checked ex
  Callable<V>: call(), returns V, throws checked

FUTURE / COMPLETABLEFUTURE
  Future: get()[blocks], isDone, cancel
  CompletableFuture.supplyAsync(...).thenApply().thenCompose()
     .thenCombine(other,fn).exceptionally(fn).join()

CONCURRENT COLLECTIONS
  ConcurrentHashMap (NO null keys/values), CopyOnWriteArrayList
  BlockingQueue (put/take block), ConcurrentLinkedQueue

ATOMIC (lock-free, CAS)
  AtomicInteger/Long/Boolean/Reference
  incrementAndGet, addAndGet, compareAndSet

VIRTUAL THREADS (Java 21)
  Thread.ofVirtual().start(r) ; Executors.newVirtualThreadPerTaskExecutor()
  millions cheap ; unmount on block ; DON'T pool ; isVirtual()
  PINNING: synchronized/native pins carrier -> use ReentrantLock

PLATFORM vs VIRTUAL
  platform: OS, 1:1, ~1MB, thousands, CPU-bound, pool
  virtual: JVM, many:few, tiny, millions, I/O-bound, no pool
=====================================================================================

End of 10 - Multithreading and Virtual Threads.