Virtual Threads in Quarkus
|
This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against. Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change. This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
JDK 21 virtual threads give Quarkus a third concurrency model alongside the fully reactive
Uni/Multi style and the classic pooled
worker-thread style: code that reads exactly like ordinary blocking, imperative Java, but that runs on a
cheap, JVM-managed thread instead of a limited platform thread. This page covers @RunOnVirtualThread, when it
is the right tool, and its real pitfalls — pinning chief among them.
@RunOnVirtualThread in REST, gRPC and messaging
io.smallrye.common.annotation.RunOnVirtualThread is a single annotation Quarkus recognizes across its
synchronous extension points; it tells Quarkus to invoke the annotated method on a new JDK virtual thread
instead of the platform worker-thread pool (for REST) or the event loop (for anything reactive-shaped) — it is
not applicable to a method returning Uni/Multi, which already runs non-blocking on the event loop by design.
REST
package com.example.book;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import io.smallrye.common.annotation.RunOnVirtualThread;
@Path("/books")
public class BookResource {
@GET
@Path("/{id}")
@RunOnVirtualThread
public Book findById(@PathParam("id") Long id) {
// a blocking JDBC/Panache call here parks the virtual thread cheaply
// instead of occupying a bounded platform worker thread
return BookRepository.blockingFindById(id);
}
}
This is covered from the REST-dispatch angle (alongside @Blocking/@NonBlocking) on
the REST services page; this page focuses on the virtual-thread
mechanism itself rather than repeating the full dispatch decision table there.
gRPC
A handler implemented against gRPC’s blocking base class is annotated the same way, as already shown on the gRPC page:
package org.acme.grpc;
import io.quarkus.grpc.GrpcService;
import io.smallrye.common.annotation.RunOnVirtualThread;
import io.grpc.stub.StreamObserver;
@GrpcService
public class GreeterService extends GreeterGrpc.GreeterImplBase {
@Override
@RunOnVirtualThread
public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {
String message = BlockingGreetingLookup.find(request.getName()); // blocking, parks cheaply
responseObserver.onNext(HelloReply.newBuilder().setMessage(message).build());
responseObserver.onCompleted();
}
}
Messaging
A blocking @Incoming consumer (covered in depth on the messaging page) can
run on a virtual thread instead of being marked @Blocking for the classic worker-pool dispatch:
package com.example.book;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import io.smallrye.common.annotation.RunOnVirtualThread;
@ApplicationScoped
public class OrderConsumer {
@Incoming("orders-in")
@RunOnVirtualThread
public void consume(String order) {
BlockingOrderProcessor.process(order); // blocking call, parks the virtual thread
}
}
Virtual threads vs. reactive vs. a worker pool
| Model | Right answer when… |
|---|---|
Reactive ( |
Every step in the pipeline already has a genuinely non-blocking API (a reactive SQL/NoSQL client, another
|
Virtual threads ( |
The code is fundamentally blocking (a JDBC driver with no reactive counterpart, a legacy synchronous client,
straight-line imperative business logic that’s easier to read and maintain than an equivalent |
Classic worker thread pool (plain return type / |
The blocking code performs |
Pinning and synchronized
A virtual thread’s entire benefit is that it can unmount from its underlying platform (carrier) thread while blocked, freeing that carrier thread to run other virtual threads. Pinning is what happens when a virtual thread cannot unmount — it blocks the carrier thread underneath it instead, which defeats the whole point and, at scale, can exhaust the small carrier-thread pool exactly the way a blocking call on an event loop thread does.
The most common cause is a synchronized block or method that also does I/O or otherwise blocks while holding
the monitor:
package com.example.book;
public class PinningExample {
private final Object lock = new Object();
// WRONG on a virtual thread: synchronized pins the carrier thread for the
// whole duration of the blocking call inside it.
public String pinning() {
synchronized (lock) {
return BlockingHttpClient.call(); // pins the carrier thread while blocked here
}
}
// Better: java.util.concurrent.locks.ReentrantLock does not pin --
// a virtual thread parked on it still unmounts from its carrier.
private final java.util.concurrent.locks.ReentrantLock reentrantLock =
new java.util.concurrent.locks.ReentrantLock();
public String notPinning() {
reentrantLock.lock();
try {
return BlockingHttpClient.call(); // does not pin the carrier thread
} finally {
reentrantLock.unlock();
}
}
}
Native synchronized calls into JNI code and certain legacy blocking constructs can pin as well; the JDK Flight
Recorder event jdk.VirtualThreadPinned (enabled with
-XX:StartFlightRecording=filename=recording.jfr,settings=profile plus
-Djdk.tracePinnedThreads=full for a lighter-weight console dump) is the way to find pinning sites in an
otherwise virtual-thread-heavy application rather than guessing from symptoms alone.
Thread-local cost
Virtual threads support ThreadLocal just like platform threads, but because the JVM can create millions of
short-lived virtual threads over an application’s lifetime, a ThreadLocal holding an expensive object (a large
buffer, a per-thread cache) multiplies that cost far more than it would across a small, bounded platform-thread
pool — the same object that was reused across a handful of pooled worker threads is now allocated fresh, in
effect, per logical unit of work. ScopedValue (JEP 446, preview in JDK 21/25) is the JDK’s proposed lighter-weight
alternative for this specific case, though it remains a preview feature at the time of writing and is not yet a
drop-in ThreadLocal replacement.
Monitoring
Micrometer (covered on the observability page) does not yet expose a
dedicated virtual-thread pool metric the way it does for the bounded platform worker-thread pool, since virtual
threads are not pooled — there is no fixed-size executor to report utilization for. The practical signals to
watch instead are: JFR’s jdk.VirtualThreadPinned events (pinning frequency and duration), the carrier-thread
pool’s own utilization (jdk.virtualThreadScheduler.parallelism, defaulting to the number of CPU cores), and
ordinary request-latency percentiles — a rising tail latency under load with low CPU utilization is a symptom
worth investigating for pinning even without a dedicated counter for it.
Native-image support
Virtual threads are supported under GraalVM native image as of recent Mandrel/GraalVM releases bundled with current Quarkus versions, with the same JDK-level semantics as on the JVM; consult GraalVM’s own virtual-threads-under-native-image documentation for the current list of any still-unsupported edge cases (some JFR events and diagnostic tooling around virtual threads have historically lagged their JVM-mode equivalents) before relying on a particular diagnostic feature in a native executable specifically.
The Quarkus 4 / Java 21 baseline implication
This page’s baseline is Quarkus 3.39.x/3.33.x LTS on Java 17+, where virtual threads are an opt-in feature available because Quarkus itself already requires (and ships against) JDK 21’s virtual-thread APIs even while the minimum supported application baseline remains Java 17. Quarkus 4 raises that minimum baseline to Java 21 itself (see the disclaimer above) — at that point, virtual threads stop being "a JDK 21+ feature used from a Java 17+ baseline" and become simply part of the platform every Quarkus 4 application already runs on.
Benchmarking honestly
Virtual threads help I/O-bound, highly concurrent workloads by letting far more logical units of work be in-flight than a bounded platform-thread pool could hold, at low per-thread memory cost — they do not make any individual blocking call faster, and they bring no benefit to CPU-bound work, where the bottleneck is compute, not waiting. A fair comparison controls for workload shape rather than reporting a single throughput number in isolation:
-
I/O-bound, high concurrency (many requests each mostly waiting on a database or downstream HTTP call) is where virtual threads and reactive code both outperform a small pooled worker-thread pool, since both avoid tying up a scarce thread for the duration of a wait — reactive avoids the wait entirely on the event loop, virtual threads make the wait cheap to hold.
-
CPU-bound work shows no meaningful difference between the three models, because none of them changes how much CPU time the work actually needs — if anything, a classic worker pool sized to the CPU core count avoids any scheduling overhead the other two models add for no benefit here.
-
A workload that pins frequently (heavy
synchronizedusage, JNI calls) can perform worse under virtual threads than under a classic worker pool, since pinning defeats the mechanism that makes virtual threads cheap while still paying the cost of creating many of them.
Rather than quoting a specific number here — Quarkus, GraalVM, and the JDK’s virtual-thread implementation are all still evolving release over release, and a number pinned to one Quarkus/JDK combination on one machine goes stale quickly — measure the three models against the project’s own actual workload shape (its true I/O-wait vs. CPU-bound mix, its actual concurrency level) using a real load-testing tool, following the methodology on the JMeter performance-testing page, before choosing a model based on a specific throughput figure.
The diagram below compares the three concurrency models on the same representative workload — a request that does a small amount of CPU work, then waits on a blocking downstream call:
Language-level details of virtual threads themselves — Thread.ofVirtual(), StructuredTaskScope, and the JDK
API surface — are covered on the Java language reference and are not
repeated here; this page focuses on how Quarkus integrates and dispatches to them. For the equivalent comparison
on the Spring side (platform threads, virtual threads, and Reactor), see
Concurrency Alternatives.