Skip to content

Virtual Threads Part 1: Cheap Threads, Not Magic Threads

Editorial illustration of many lightweight execution paths managed under explicit limits
Virtual threads are cheap threads, not a coupon for unlimited blocking.

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.

Practical map for using virtual threads with blocking IO, backpressure, diagnostics, and cleanup
Blocking code gets a second life, but only when timeout, cleanup, and resource limits come along with it.

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.

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.

Virtual Threads fit especially well here:

WorkloadWhy
JDBC transactionsKeep the blocking API while increasing concurrency
HTTP client callsPreserve one sequential flow per request
Batch partition processingSplit partitions into simple tasks
Legacy SDK wrappersAvoid forcing callback/reactive APIs around blocking SDKs

They need care here:

WorkloadWhy
CPU-bound loopsMore threads do not create more CPU cores
Unbounded fan-outVirtual Threads are cheap; downstream systems are not
Blocking inside global locksJava 21 can still hit pinning risks
ThreadLocal-heavy codeMany 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.tracePinnedThreads and JFR to observe pinning directly.
  • On Java 25, the synchronized burden is lower, but lock scopes should still stay small.
  • Observe native/FFM/JNI blocking boundaries separately.

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.

Kotlin Coroutines and Virtual Threads are not really rivals. They fit different starting points.

CoroutinesVirtual Threads
Natural when suspend APIs already existNatural when blocking APIs already exist
Structured concurrency is language/library centeredJava thread/tooling model remains familiar
Works well with async driversWorks well with JDBC, blocking SDKs, and legacy APIs
Cancellation propagation must be designedInterrupts, 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.

Before putting Virtual Threads in production, check at least this much.

ItemWhat to check
concurrency limitAre you limiting downstream resources, not thread count?
timeoutDo Future.get, DB queries, HTTP calls, and batch partitions have timeouts?
cancellationAre interrupts preserved rather than swallowed?
diagnosticsCan JFR/thread dumps/metrics show virtual-thread state?
lock scopeIs blocking I/O outside long lock scopes?
ThreadLocalAre 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.