Skip to content

Virtual Threads Part 2: Rules That Matter in Practice

Editorial illustration of practical virtual thread rules becoming checklist items
The useful rules are the ones that survive contact with JDBC pools, locks, and shutdown paths.

This is Part 2 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.

Part 1 covered the big picture. This post is about rules that show up immediately in code. The examples come from bluetape4k-workshop/virtualthreads/rules. They do not stop at “use Virtual Threads.” They also show how Virtual Threads can fail to deliver if used with the wrong habits.

Practical virtual thread rules from bluetape4k-workshop
Turning on Virtual Threads is not the finish line. Pooling, semaphores, context, and locks decide whether they work well.

Rule 1. Keep Blocking Synchronous Code When the Work Is I/O Waiting

Section titled “Rule 1. Keep Blocking Synchronous Code When the Work Is I/O Waiting”

Rule2WriteBlockingSynchronousCode shows the same flow in three forms: CompletableFuture, Virtual Threads, and Coroutines. The nice part of the Virtual Thread version is that the control flow still reads in a straight line.

Executors.newVirtualThreadPerTaskExecutor().use { executor ->
val price = executor.submit<Int> { readPriceInEur() }
val rate = executor.submit<Float> { readExchangeRateEurToUsd() }
val netAmount = price.get() * rate.get()
val tax = executor.submit<Float> { readTax(netAmount) }
val grossAmount = netAmount * (1.0F + tax.get())
grossAmount.toInt() shouldBeEqualTo 108
}

This code does not try to look non-blocking. It splits blocking functions into tasks and joins them with get() where the values are needed. For request-response work that mostly waits on I/O, that simplicity is valuable.

Do not apply the same idea blindly to CPU-bound work. Virtual Threads do not create extra CPU cores.

Rule 2. Do Not Put Virtual Threads in a Pool

Section titled “Rule 2. Do Not Put Virtual Threads in a Pool”

The message of Rule3DoNotPoolVirtualThreads is direct: Virtual Threads are not something to reuse through pooling.

// Not recommended: putting a virtual-thread factory inside a ThreadPoolExecutor shape.
Executors.newCachedThreadPool(Thread.ofVirtual().factory()).use { executor ->
executor.submit { Thread.sleep(1000) }
}

That uses virtual threads by name, but the mindset is still pooling. The default should be a thread-per-task executor.

// Recommended: create a new virtual thread per task.
Executors.newVirtualThreadPerTaskExecutor().use { executor ->
executor.submit { Thread.sleep(1000) }
executor.submit { Thread.sleep(1000) }
}

Creating Virtual Threads is cheap. The things that must be limited are downstream resources.

Rule 3. Use Semaphores, Not FixedThreadPool, to Limit Concurrency

Section titled “Rule 3. Use Semaphores, Not FixedThreadPool, to Limit Concurrency”

Rule4UseSemaphoreInsteadOfFixedThreadPools shows the most important operational rule. Do not limit concurrency by sizing a thread pool. Limit it in front of the resource you are protecting.

private val semaphore = Semaphore(8)
private fun useSemaphoreToLimitConcurrency(): String {
semaphore.acquire()
return try {
sharedResource()
} finally {
semaphore.release()
}
}

The executor can still be virtual-thread-per-task.

Executors.newVirtualThreadPerTaskExecutor().use { executor ->
val futures = List(100) {
executor.submit {
useSemaphoreToLimitConcurrency()
}
}
futures.forEach { it.get() }
}

The reason is simple. You can create many Virtual Threads, but you cannot create unlimited DB connections, and external API quotas are not infinite. A semaphore makes the protected resource explicit.

Rule 4. Prefer ScopedValue Before ThreadLocal for Scoped Context

Section titled “Rule 4. Prefer ScopedValue Before ThreadLocal for Scoped Context”

Rule5UseThreadLocalVariablesCarefully compares InheritableThreadLocal and ScopedValue. ThreadLocal helps migration, but as Virtual Thread counts grow, context management cost and leak risk grow too.

Scoped Values disappear when the scope ends.

private val scopedValue = ScopedValue.newInstance<String>()
ScopedValue.where(scopedValue, "zero").run {
scopedValue.get() shouldBeEqualTo "zero"
ScopedValue.where(scopedValue, "one").run {
scopedValue.get() shouldBeEqualTo "one"
}
scopedValue.get() shouldBeEqualTo "zero"
}

With structured task scope, child tasks can read the scoped context.

structuredTaskScopeAll { scope ->
scope.fork {
scopedValue.get() shouldBeEqualTo "zero"
-1
}
scope.join().throwIfFailed()
}

There is still a boundary to understand. ScopedValue is not a magic context propagation system for every thread creation style. Check whether the propagation boundary is structured.

Rule 5. Keep Locks Small, and Remember Java 21 Pinning

Section titled “Rule 5. Keep Locks Small, and Remember Java 21 Pinning”

Rule6UseSynchronizedBlocksAndMethodsCarefully uses ReentrantLock to avoid Java 21 synchronized pinning risk around blocking sections.

private val lock = ReentrantLock()
virtualFuture {
lock.withLock {
exclusiveResource()
}
}.await()

Java 25 reduces the synchronized pinning burden through JEP 491. The rule still matters. Reduced pinning does not make long critical sections cheap. Blocking inside a long lock still turns into a bottleneck and makes profiling harder.

The virtualthreads/rules examples can be summarized like this.

Do notDo this instead
Pool Virtual ThreadsUse newVirtualThreadPerTaskExecutor()
Use FixedThreadPool for concurrency limitsUse Semaphore around downstream resources
Store large context in ThreadLocalUse ScopedValue or explicit context
Block inside long locksKeep lock scopes small; use ReentrantLock when it fits
Push CPU-bound work into Virtual ThreadsDesign CPU-bound execution around core count

Virtual Threads let existing blocking code work with minimal changes. That does not mean limits, context, and locks can be treated casually. The easier model is also easier to misuse. Part 3 applies these rules to a database benchmark.

Comments

Leave a note or reaction with your GitHub account.