Week 9 Day 5 — Virtual Threads
Goal
Today I want virtual threads as cheap blocking, not as a free throughput button.
Main questions:
- What is a virtual thread vs a platform thread?
- What does “pinning” mean?
- Why does Hikari still matter?
- How does Spring Boot turn them on?
- What does structured concurrency add (and is it preview)?
1. Cheap to block
A platform thread is an OS thread: expensive stack, limited count (hundreds to low thousands).
A virtual thread (Java 21) is a Java-scheduled task that mounts on a carrier (a platform thread) while it runs, and unmounts when it blocks on most Java I/O. I can have millions of virtual threads waiting on the network without millions of OS threads.
I write thread-per-request style again: blocking JDBC, blocking HTTP client, sequential code. I do not need reactive (Flux) only to free Tomcat workers.
They do not make CPU-bound work faster. A tight loop still uses a carrier core. Parallel CPU work still wants a bounded pool of platform threads (or ForkJoin for that job).
Memory sentence:
Virtual threads make blocking cheap. They do not create extra database connections.
2. Pinning
If a virtual thread cannot unmount while blocked, it pins the carrier: that OS thread is stuck too.
Historically (Java 21): a virtual thread inside a synchronized block that then blocked on I/O pinned the carrier. Long synchronized around JDBC was a problem. ReentrantLock did not pin in the same way.
Later JDKs reduced synchronized pinning. I still:
- keep critical sections tiny (Week 8)
- keep JDBC drivers current
- do not treat pinning as “so VTs are useless”
I mention pinning so I do not sound like a blog title.
3. Scarce resources stay bounded
Ten thousand virtual threads all calling DataSource.getConnection() on a Hikari pool of 10 will queue or timeout. Virtual threads did not grow the pool.
I still bound:
- Hikari
maximum-pool-size - HTTP client pools
- CPU-bound executors
@Asyncif it does heavy CPU
Virtual threads are right for lots of blocking I/O waits. They are wrong as “unlimited work against a tiny pool.”
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> jdbc.query(...));
}
newVirtualThreadPerTaskExecutor() is unbounded task count. The database is the real limit.
4. Spring Boot
Boot 3.2+:
spring:
threads:
virtual:
enabled: true
That can run Tomcat (or Jetty) request handling on virtual threads. I still configure Hikari. I test under load: timeouts, pool exhaustion, thread dumps (jcmd Thread.dump_to_file understands virtual threads).
@Async: I may use a virtual-thread executor for blocking mail/HTTP. I do not use virtual threads to parallelize a CPU-heavy map on a singleton without a bound.
ThreadLocal still works and still leaks (Week 8). Millions of virtual threads each with a fat ThreadLocal cost memory. Scoped values (preview/later) are the intended replacement for immutable per-request context. I name them as the direction, not as something I fake in production on 21 unless the team adopted them.
5. Structured concurrency (preview)
The idea: a parent task owns child tasks; they fail and cancel together; I join a scope, not a pile of detached CompletableFutures.
On many 21/25 builds this is still preview (--enable-preview) or newly finalized — I check the JDK I run before I claim it is production-default. In an interview:
I want structured concurrency so async work has a lifetime tied to the request. I would not enable preview in production just to look current.
CompletableFuture (Week 8) remains the stable composition API.
6. Common traps
Trap 1: “Virtual threads mean I can ignore the Hikari pool size.”
Trap 2: Using VTs to speed a CPU-heavy loop.
Trap 3: Mixing synchronized + blocking I/O on 21 and ignoring pinning.
Trap 4: newVirtualThreadPerTaskExecutor() as the only concurrency control.
Trap 5: Claiming structured concurrency is required for Boot 3. It is not.
Practice Questions and Answers
Question 1
Virtual threads — what changes for Spring?
Answer:
They make blocking cheap, so thread-per-request Tomcat can scale without a platform thread per request. Boot 3.2+ can enable them. They do not speed CPU work and they do not grow Hikari. I still bound pools for connections and CPU. Pinning used to hurt with long synchronized + I/O; I keep locks short and drivers current.
Question 2
Why can virtual threads make a small connection pool fail faster?
Answer:
I can now have 10_000 requests in Java, all blocking on getConnection(). The pool of 10 is exhausted immediately; timeouts show up under load that platform-thread limits used to hide. The fix is still pool size, timeouts, and load shedding — not “more virtual threads.”
Question 3
Platform thread vs virtual thread?
Answer:
A platform thread is an OS thread. A virtual thread is scheduled by the JVM onto a carrier. Blocking Java I/O typically unmounts the virtual thread so the carrier can run someone else. CPU-bound work still occupies a carrier.
Question 4
Should @Transactional + virtual threads worry me?
Answer:
The persistence context is still thread-bound (Week 8). A virtual thread is still one thread for that request, so a normal @Transactional on the request thread is fine. Hopping to another virtual thread (@Async, parallel(), a new executor task) still loses the session unless that work has its own transaction.
Question 5
Structured concurrency vs CompletableFuture?
Answer:
Structured concurrency ties child tasks to a scope (cancel and errors together). It has been preview on 21. CompletableFuture is stable. I compose with futures today; I adopt structured concurrency when it is final on our LTS and the team agrees.
Memory sentences
Virtual threads make blocking cheap; they do not create extra database connections.
Bound scarce resources (Hikari, CPU pools) even when tasks are virtual.
One virtual request thread still carries
@Transactional. A second task does not.
Next: Week 9 Review