Virtual Threads Part 1: Cheap Threads, Not Magic Threads

This is Part 1 of the Virtual Threads series. The full series continues with Part 1: introduction and cautions, Part 2: workshop rules, Part 3: JDBC + Virtual Threads benchmark, and Part 4: Java 21/25 SPI design.
Virtual Threads sound almost too generous the first time you hear about them. “You can keep writing blocking code” is the kind of sentence that usually comes with conditions. This one does too. Virtual Threads are not magic. They make thread-per-request style programming practical again, but at a much lower cost.

The Problem Virtual Threads Solve
Section titled “The Problem Virtual Threads Solve”Traditional servlet/JDBC code is easy to read. It goes from top to bottom, without suddenly opening a callback maze in the middle.
fun handle(request: Request): Response { val user = userRepository.findById(request.userId) val orders = orderRepository.findRecent(user.id) return Response(user, orders)}The problem is that this simplicity is paid for with platform threads. While a request waits on DB, HTTP, or filesystem I/O, an OS thread is tied up too. As request count grows, the application can run out of threads before it runs out of CPU.
JEP 444 made Virtual Threads final in Java 21. A Virtual Thread
is still a java.lang.Thread, but it is not tied 1:1 to an OS thread. When it blocks on I/O, it
can unmount from a carrier thread and resume later. That makes the “synchronous code shape with
high concurrency” combination practical.
Executors.newVirtualThreadPerTaskExecutor().use { executor -> val futures = urls.map { url -> executor.submit<String> { URL(url).readText() } }
val pages = futures.map { it.get() }}The model is simple: run tasks on virtual threads, but place limits around downstream resources, not around the thread objects themselves.
One important note: a Virtual Thread is not a faster thread. It does not make CPU-bound work compute faster. It helps when many tasks spend most of their lifetime waiting.
Do Not Pool Virtual Threads
Section titled “Do Not Pool Virtual Threads”The first instinct many JVM developers have is to create a pool. We see ExecutorService and
start looking for a pool size.
// Be careful: this treats virtual threads as if the thread count itself were the limit.val executor = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory())Virtual Threads are not the expensive resource. The things behind them are: DB connections, remote API concurrency, file descriptors, and queue depth.
private val permits = Semaphore(32)
fun <T> withRemoteLimit(block: () -> T): T { permits.acquire() return try { block() } finally { permits.release() }}Creating many Virtual Threads is fine. Creating unlimited DB connections is not. If you miss that distinction, Virtual Threads do not remove the bottleneck; they move it downstream.
Where Blocking Code Gets Better
Section titled “Where Blocking Code Gets Better”Virtual Threads fit especially well here:
| Workload | Why |
|---|---|
| JDBC transactions | Keep the blocking API while increasing concurrency |
| HTTP client calls | Preserve one sequential flow per request |
| Batch partition processing | Split partitions into simple tasks |
| Legacy SDK wrappers | Avoid forcing callback/reactive APIs around blocking SDKs |
They need care here:
| Workload | Why |
|---|---|
| CPU-bound loops | More threads do not create more CPU cores |
| Unbounded fan-out | Virtual Threads are cheap; downstream systems are not |
| Blocking inside global locks | Java 21 can still hit pinning risks |
| ThreadLocal-heavy code | Many threads can turn large ThreadLocal values into memory pressure |
Java 21 Pinning and What Changes in Java 25
Section titled “Java 21 Pinning and What Changes in Java 25”In Java 21, blocking inside synchronized while running on a Virtual Thread could pin the
carrier thread. Early guidance therefore said: keep synchronized regions small, and consider
ReentrantLock for blocking paths.
JEP 491 changed synchronized so it no longer pins Virtual
Threads in Java 24, and the improvement is included in Java 25. That softens the blanket advice
to avoid synchronized.
But it does not mean “do any I/O inside any lock.” JEP 491 still leaves pinning around native code and some JVM boundaries, and long lock scopes are bottlenecks regardless of Virtual Threads.
Practical rules:
- On Java 21, use
jdk.tracePinnedThreadsand JFR to observe pinning directly. - On Java 25, the
synchronizedburden is lower, but lock scopes should still stay small. - Observe native/FFM/JNI blocking boundaries separately.
ThreadLocal Works, But It Is Not Free
Section titled “ThreadLocal Works, But It Is Not Free”Java 21 Virtual Threads support ThreadLocal. That helps migration. But “supported” does not mean “use it without thought.”
If one request owns one Virtual Thread, request-scoped ThreadLocal values disappear when the thread ends. That can be safer than platform thread pools. But if many Virtual Threads carry large ThreadLocal objects, the memory cost is still real.
Java 25 finalizes Scoped Values through JEP 506. For values such as request context that should flow read-only from a parent scope and disappear when the scope ends, Scoped Values are often a better fit than ThreadLocal.
How It Feels in Kotlin
Section titled “How It Feels in Kotlin”Kotlin Coroutines and Virtual Threads are not really rivals. They fit different starting points.
| Coroutines | Virtual Threads |
|---|---|
Natural when suspend APIs already exist | Natural when blocking APIs already exist |
| Structured concurrency is language/library centered | Java thread/tooling model remains familiar |
| Works well with async drivers | Works well with JDBC, blocking SDKs, and legacy APIs |
| Cancellation propagation must be designed | Interrupts, timeouts, and lifecycle must be designed |
bluetape4k uses both. If a new API is suspend-first, Coroutines fit well. If a proven blocking
API already exists and the bottleneck is waiting, Virtual Threads can be simpler.
Production Checklist
Section titled “Production Checklist”Before putting Virtual Threads in production, check at least this much.
| Item | What to check |
|---|---|
| concurrency limit | Are you limiting downstream resources, not thread count? |
| timeout | Do Future.get, DB queries, HTTP calls, and batch partitions have timeouts? |
| cancellation | Are interrupts preserved rather than swallowed? |
| diagnostics | Can JFR/thread dumps/metrics show virtual-thread state? |
| lock scope | Is blocking I/O outside long lock scopes? |
| ThreadLocal | Are large objects or connections kept out of thread-local state? |
The conclusion for Part 1 is this: Virtual Threads do not mean “you never need to learn async.” They mean “you can reuse blocking code with much smaller changes.” That makes the basics more important, not less: resource limits, timeouts, cleanup, and observability.
Part 2 uses bluetape4k-workshop/virtualthreads/rules to make pooling, semaphores,
ScopedValue, and locking rules more concrete.
Comments
Leave a note or reaction with your GitHub account.