Skip to main content

Week 9 Day 5 — Virtual Threads

Goal

Today I want virtual threads as cheap blocking, not as a free throughput button.

Main questions:

  1. What is a virtual thread vs a platform thread?
  2. What does “pinning” mean?
  3. Why does Hikari still matter?
  4. How does Spring Boot turn them on?
  5. 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
  • @Async if 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