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 (Uni/Multi)

Every step in the pipeline already has a genuinely non-blocking API (a reactive SQL/NoSQL client, another Uni-returning call) — the pipeline never needs to leave the event loop at all, which is strictly cheaper than any thread hand-off, virtual or platform.

Virtual threads (@RunOnVirtualThread)

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 Uni chain) but the workload is I/O-bound with high concurrency — many requests each spending most of their time waiting, not computing.

Classic worker thread pool (plain return type / @Blocking)

The blocking code performs synchronized locking or holds native resources in ways that pin virtual threads (see below), the workload is CPU-bound rather than I/O-bound (virtual threads bring no benefit for pure computation — there’s no waiting to make cheap), or the project is still on Java 17 without a JDK 21 baseline.

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 synchronized usage, 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:

flowchart TD subgraph React["Reactive: Uni/Multi on the event loop"] direction TB R1[Event loop thread\nreceives request] --> R2[Non-blocking client call\nissued, no wait] R2 --> R3[Event loop immediately free\nfor other requests] R3 --> R4[Callback resumes pipeline\nwhen the response arrives] end subgraph Virtual["Virtual threads: @RunOnVirtualThread"] direction TB V1[Virtual thread created,\nmounted on a carrier thread] --> V2[Blocking call made;\nvirtual thread unmounts\nfrom the carrier while waiting] V2 --> V3[Carrier thread free to run\nother virtual threads meanwhile] V3 --> V4[Virtual thread remounts\nwhen the blocking call returns] end subgraph Pooled["Classic worker thread pool"] direction TB P1[Pooled platform thread\ndispatched from a bounded pool] --> P2[Blocking call made;\nthread stays occupied\nfor the full wait] P2 --> P3[Thread returned to the pool\nonly once the call completes] P3 --> P4["Pool exhaustion queues\nfurther requests once\nall N threads are busy"] end

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.