The usage and internals of Java virtual threads and Kotlin coroutines are already widely covered.

Rather than explaining each technology separately, this post compares how their different ways of implementing suspension change how code is written and operated.

It is written for JVM server developers who have adopted or are evaluating virtual threads, and it focuses on where the costs that virtual threads keep out of the code show up at runtime.

In a thread-per-request server, the number of threads is the concurrency limit.

With a pool of 200 threads, the 201st request waits until one of the 200 finishes.

Yet many of those 200 threads are not computing anything. They are simply blocked, waiting for a response from a database or an external API.

An expensive resource stays tied up while doing nothing, and that is not a small problem.

The fix is simple in principle: release the OS thread while waiting.

Doing this by hand, however, changes the structure of the code. The function has to end at the point where it would wait, and the work that follows the result has to move into a separate callback.

Reactive chains exist for the same reason.

Code split up this way may behave correctly, but it is hard to read and modify.

So there is one more requirement: release the thread, but keep the code readable from top to bottom.

Java virtual threads and Kotlin coroutines were both designed to solve this problem, but they are implemented differently.

Both follow the same procedure. They suspend execution and release the thread, and when the wait is over, they continue from the suspension point. What differs is how they keep the suspended state.

A virtual thread saves its stack and later restores it (stackful). A coroutine returns from the function and later calls the same function again (stackless).

For virtual threads, the runtime does this work. For coroutines, the compiler transforms the code to do it.

This difference carries over into the code: virtual threads hide suspension points from the code, while coroutines mark them in the code.

Hidden does not mean gone. Both approaches only lower the cost of waiting; neither increases the total amount of resources.

The resource bottleneck does not disappear. It moves to a less visible place, and with virtual threads, unlike coroutines, that place does not appear in the code.

1. Suspension Mechanics - Saving the Stack vs Calling the Function Again

Virtual Threads - Saving the Stack and Unmounting from the Carrier

Virtual threads, a final feature since Java 21, leave no marker in the code.

In the two snippets below, the business logic is identical. Only the code that creates the executor differs.

// Conventional: one OS thread per request - limited to 200 try (var executor = Executors.newFixedThreadPool(200)) { for (int i = 0; i < 10_000; i++) { executor.submit(() -> { String data = fetchFromDb(); // the thread blocks here process(data); }); } } // Virtual threads: same logic, each of the 10,000 tasks runs on its own virtual thread try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10_000; i++) { executor.submit(() -> { String data = fetchFromDb(); // identical to the code above process(data); }); } } 

The diagram below shows what happens while fetchFromDb() waits for a response.

A virtual thread saves its stack to the heap and unmounts from its carrier during a socket read, then is mounted again after the data arrives and continues from the line after read()

Suspension and resumption of a virtual thread

Virtual thread VT-1 is mounted on a carrier (the OS thread that actually runs virtual threads) (①) and runs user code (②).

When VT-1 requests data from a socket (③) and the data has not arrived yet (④), the runtime registers the socket with the kernel's epoll, saves VT-1's entire stack to the heap, and unmounts VT-1 from the carrier (⑤-⑦).

The released carrier immediately runs another virtual thread, VT-2 (⑧).

When the kernel reports that the socket is readable (⑨), the runtime remounts VT-1 (⑩), restores its stack from the heap, and continues from the line after read() (⑪, ⑫).

Between ⑦ and ⑪, read() never returns. The call stack stays intact; it is only off the OS thread for a while.

The carrier's activation bar runs unbroken from ① to the end, while VT-1's bar stops at ⑦ and resumes at ⑪.

The point of this diagram is that the waiting is now done by a cheap virtual thread instead of an expensive OS thread.

Aspect Carrier (= platform thread) Virtual thread
What it is OS thread Object on the JVM heap
Count Number of CPU cores Hundreds of thousands to millions
Stack Fixed size (about 1 MB) Stored on the heap, only as large as used
Creation/switch cost High Very low

Coroutines - Returning from the Function and Keeping the Continuation

Coroutines make suspension points explicit in the source code.

A function that can suspend must be declared with suspend, and a suspend function can only be called from another suspend function or inside a coroutine builder.

// Markers such as 'suspend' and 'launch' are required suspend fun handleRequest() { val data = fetchFromDb() // this function must be suspend as well process(data) } runBlocking { repeat(10_000) { launch { handleRequest() } } } 

The diagram below shows how a coroutine suspends and resumes.

A suspend function saves its local variables and the next label to a Continuation and returns COROUTINE_SUSPENDED, then is called again when the I/O completes and jumps to label 1 to continue

Suspension and resumption of a coroutine

When a dispatcher thread calls the function, execution starts at the label 0 block (①②).

At the suspension point, the function saves its local variables and the next label value to a Continuation object on the heap (③), then starts the asynchronous I/O (④).

The function then returns COROUTINE_SUSPENDED and actually exits (⑤).

The stack unwinds, and the released dispatcher thread runs other coroutines (⑥).

When the I/O completes, the result is delivered through resumeWith() (⑦), the resumption is dispatched (⑧), and the same function is called again from the top (⑨).

This time it jumps to the saved label 1, restores its local variables, and continues after the suspension point (⑩).

Here, the dispatcher plays the role of the carrier described earlier.

A coroutine also runs on ordinary OS threads managed by a dispatcher and releases the thread when it suspends. Unlike with carriers, however, the developer chooses which thread pool to run on.

Dispatcher Default threads Use
Dispatchers.Default Number of CPU cores (at least 2) CPU-bound work. Used when no dispatcher is specified
Dispatchers.IO 64 or the number of cores, whichever is larger Blocking I/O only. Many threads, since most of them wait
Dispatchers.Main 1 (UI thread) UI frameworks such as Android

The Point of Divergence

Putting ⑦ of the virtual thread next to ⑤ of the coroutine makes the difference clear.

At ⑦, the stack is saved to the heap and set aside for a while. At ⑤, the function actually returns and its stack frames are gone.

Only the Continuation object saved at ③ remains. Resumption (⑨) does not pick up the old execution either: it calls the same function again and jumps (goto) to the saved label.

A Continuation is an object that holds what to do once the current computation finishes.

Normally that information exists only implicitly in the call stack and cannot be accessed. Passed as an argument, it becomes a value, so it can be stored on the heap and picked up later on any thread.

Passing "what to do next" as an argument is called continuation-passing style (CPS), and it is the basis on which coroutines implement suspension without a stack.

2. Suspension Markers - Transparency vs Function Coloring

With virtual threads, the JDK is what actually performs the suspension.

When a blocking primitive such as a socket read detects that it is running on a virtual thread, it registers with the kernel and suspends itself.

This works because the entire stack is saved.

Since the whole call stack at the moment of suspension is saved with it, the library frames in between do not have to do anything, and the suspension is not visible to those frames.

Because the JDK reworked the blocking primitives at the bottom (sockets, locks, sleep, and so on), most blocking libraries built on top of them scale without modification.

In coroutines, suspension has to propagate up the call chain, so every function in the chain must be marked suspend.

A suspend function can only be called from a suspend context, so the code splits into a suspend world and a regular world. This is called function coloring.

In the same call chain, a virtual thread suspends at socket.read() without any marker, while in a coroutine the whole chain is marked suspend and suspension propagates upward

Function coloring - markers in the same call chain

This is the practical difference between the two, and it is where the earlier statement holds: virtual threads hide suspension from the code, while coroutines make it explicit.

Neither is superior; it is a difference in design philosophy.

What is hidden, however, does not go away. Removing the markers from the code has a cost, and that cost shows up elsewhere.

3. Hidden Costs

(1) The Shifting Bottleneck

Virtual threads can raise the number of concurrent requests, but the size of the DB connection pool stays the same.

If the HikariCP pool size is 20, only 20 requests use the database at a time, even with a million virtual threads. What determines throughput, the bottleneck, is this 20, not the number of threads.

The remaining requests wait for a connection.

In the thread pool model, the number of requests waiting like this has an upper bound.

If the wait queue is bounded, requests that arrive after it fills up are rejected. Clients get an error response immediately, and the error rate jumps at that moment.

Virtual threads have no upper bound on the number of threads.

No request is rejected; every request is accepted and waits for a connection.

With no errors, the error rate stays at its usual level. Only the response time changes.

Errors appear only once wait times start exceeding the timeout, and by then a large number of requests are already waiting.

The bottleneck has moved.

The thread pool model rejects requests that overflow the wait queue in front of the threads, while virtual threads wait without limit in front of the connection pool

The shifting bottleneck - where waiting accumulates

The bottleneck moved from acquiring a thread to acquiring a connection. It did not disappear.

What disappeared is not the bottleneck but the rejections that used to show up in the error rate.

A connection pool is a counting semaphore that limits concurrent use to 20.

A request waits until one of the connections in use is returned, and connectionTimeout is the upper bound on that wait. It corresponds to Semaphore.tryAcquire(timeout).

So a timeout does not mean the setting is too small; it is a signal that the load has exceeded what the database can handle.

Increasing the pool size removes the signal, but the extra load goes straight to the database. That is why simply enlarging the pool is usually the wrong response.

Use timeouts as the basis for rejecting requests that cannot be served early (load shedding), and size the pool to what the database can actually handle, not to a rule of thumb such as cores x 2.

Coroutines face the same connection pool limit, and the cap of 20 concurrent connections does not appear in the code either.

However, blocking calls have to be wrapped in withContext(Dispatchers.IO), so the places that call the database can be found in the code.

(2) Changes to the ThreadLocal Premises

ThreadLocal is storage that keeps a separate value for each thread. Reading the same variable gives a different value on each thread.

Even if developers never use it directly, server code already relies on ThreadLocal.

Logging MDC, Spring Security's SecurityContextHolder, and transaction synchronization all store per-request values this way.

This approach rests on two premises.

First, one request holds one thread until it ends. Second, a thread is reused across many requests.

With virtual threads, the second premise no longer holds, because a new thread is created for each request and discarded when the request ends.

In a thread pool, one thread handles several requests; virtual threads create a thread per request; coroutines can move a request to a different thread at each suspension point

Two ThreadLocal premises - how threads map to requests

As a result, of the two uses of ThreadLocal, one loses its effect and the other keeps it.

The one that loses its effect is using ThreadLocal as a cache.

It keeps one expensive object per thread and reuses it, so it falls apart when threads are not reused.

With a pool of 200, only 200 objects are created, and they are reused across a million requests, so the hit rate is effectively 100%.

With virtual threads, the same million requests create a million objects, each used once and thrown away. The benefit of the cache is gone, and only the creation cost remains.

Not a single line of code changes, and the increased number of allocations shows up only at runtime.

The use that keeps its effect is ThreadLocal as context.

In this case, however, the cost appears in memory.

The MDC (Mapped Diagnostic Context) used for logging is a typical example.

Put the request ID in once at the entry point with MDC.put("requestId", id), and every log line written while handling that request includes the ID. The functions in between do not take the request ID as a parameter.

This use needs only the first premise. If one thread handles only that request until it ends, looking up a value by thread is the same as looking it up by request.

Whether the thread is discarded or reused for the next request afterwards does not matter here.

On pooled threads, a missing MDC.clear() lets values from the previous request leak into the next request's logs.

A virtual thread is discarded when its request ends, so nothing is left to clear and no such leak occurs.

Without any code change, MDC becomes more accurate.

In exchange, a map is created for every virtual thread, and with InheritableThreadLocal the parent's values are copied as well, so memory usage grows with the number of threads.

This, too, does not appear in the code; it appears only in the heap.

ScopedValue was introduced as an alternative.

A scoped value is discarded when its scope ends, so nothing is left to clear, and it is shared with child threads without copying, so no extra maps are created. It is a final feature as of JDK 25.

Use Thread pool Virtual thread
Cache (reusing an expensive object per thread) Effective (200 objects reused across a million requests) Ineffective (a new object per request)
Context (per-request values such as MDC) Effective (leaks into the next request if MDC.clear() is missed) Effective (no leaks, memory grows with the thread count)
Premises that hold Premises 1 and 2 Premise 1

A coroutine can resume on a different thread at each suspension point. The first premise, which still holds with virtual threads, does not hold here.

Values stored in a ThreadLocal do not survive a suspension.

Per-request values therefore go into the CoroutineContext, and using MDC requires explicitly adding an element such as MDCContext.

With virtual threads, things work without code changes and the costs appear only at runtime. With coroutines, the code has to change; otherwise the request ID disappears from logs written after a suspension.

(3) Blocking Without Suspension

In both approaches, suspension does not happen if the layer underneath does not support it.

With virtual threads, suspension does not happen when calling native code through JNI, or when blocking inside a synchronized block on older Java versions (before JDK 24). At that moment the virtual thread cannot release its carrier thread (pinning).

For that interval, it effectively behaves like the old thread pool model.

There are only as many carriers as cores, so even one unreleased carrier reduces total capacity by that much. The scheduler does not automatically add a thread to replace a carrier that is not released.

One of four carriers is held by a pinned virtual thread, leaving only three carriers to run virtual threads

Pinning - one carrier held

With coroutines, suspension does not happen when a coroutine makes a blocking call (plain JDBC, Thread.sleep(), and so on). The compiler transforms only suspend functions, so the real thread underneath simply blocks.

To actually release the thread, the entire I/O stack must be suspend-based. If a blocking call is unavoidable, move it into withContext(Dispatchers.IO) so that it runs on threads reserved for blocking.

The two problems surface in different places.

With coroutines, the blocking call stays visible in the code, so it can be caught while writing or reviewing.

With virtual threads, code that pins still compiles and passes functional tests. The cause gets traced only after throughput falls short of expectations under load.

Pinning caused by synchronized was resolved in JDK 24, so upgrading removes that cause.

Pinning caused by JNI remains regardless of version, so it has to be checked at runtime.

A few other cases besides JNI still cause pinning, so they are worth checking as well.

4. Measurement and Choice

Side-by-Side Comparison

Virtual Thread Kotlin Coroutine
Category Stackful (handled by the runtime) Stackless (transformed by the compiler)
Suspension marker None (transparent) suspend (explicit)
Function coloring No Yes
Where it can suspend Anywhere in the call stack Only at marked points
Saved on suspension The entire stack A Continuation object
How it resumes Restores the stack and continues Calls the function again and jumps to the label
Existing blocking code Scales as is, without changes Must be rewritten on a suspend basis
Cancellation Thread.interrupt() CancellationException (first-class)
Scope Thread abstraction Extends to Flow / Channel / Multiplatform
Introduced Java 21 (2023) Stable since Kotlin 1.3 (2018)

It is not strictly either/or. A coroutine dispatcher can also run on virtual threads.

val dispatcher = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher() 

Monitoring Targets

Check two JFR (Java Flight Recorder) events.

jdk.VirtualThreadPinned records remaining pinning. jdk.VirtualThreadSubmitFailed records cases where a request to start or resume a virtual thread could not be submitted to the scheduler (for example, due to resource exhaustion).

Version Choice

Version Released Relevant changes
Java 21 (LTS) 2023-09 Virtual threads final. synchronized pinning remains
Java 24 2025-03 JEP 491 - synchronized pinning resolved
Java 25 (LTS) 2025-09 Current LTS. Structured concurrency API reworked (preview)
Java 26 2026-03 JEP 525 - structured concurrency, sixth preview (minor API changes)
Java 27 2026-09 Latest release. Structured concurrency, seventh preview

If you plan to use virtual threads seriously, JDK 25 LTS is a better choice than JDK 21 LTS, because it is the first LTS release with the synchronized pinning issue resolved.

Structured concurrency (StructuredTaskScope) is still in preview as of JDK 27, so it is too early to use it in production code.

Selection Criteria

If the goal on the JVM is purely I/O concurrency, virtual threads are now often enough. Being able to keep existing blocking code as is is a major advantage.

On the other hand, if you need expressive asynchronous streams such as Flow, Channel-based designs, fine-grained cancellation, or Kotlin Multiplatform, coroutines are still the better fit.

There is no reason to migrate code already written with coroutines to virtual threads.

Whichever you choose, the conclusion of this post applies equally.

Both approaches only lower the cost of waiting; neither increases the total amount of resources.

Virtual threads hide that fact from the code, and coroutines expose it.

So when evaluating adoption, first measure the resources that requests wait on: connection pools, external API quotas, and disks.

References

This post is an English version of my original post in Korean.