How to Monitor Java Virtual Threads Effectively
This article explains the internal mechanics of Java virtual threads, the role of Continuation, pinned threads, and carrier threads, and provides concrete monitoring techniques using JVM flags, JFR events, and framework-specific considerations for Helidon and Quarkus.
Introduction
Virtual threads (VTs) introduce a new concurrency model that lets developers write straightforward code without the complexity of reactive programming, while avoiding the high cost of blocking physical threads (PTs). The article builds on a previous discussion of VTs and their use in reactive micro‑services such as Helidon 4.
How Virtual Threads Work
Inside the JVM, all VT activity revolves around the internal Continuation class, which developers do not interact with directly. Continuation exposes two key methods: run – starts or restarts a task. yield – pauses a running task.
A VT does not have its own execution capability; when it runs it is mounted on a physical thread (PT). The PT used for VTs is a specially created carrier thread (CT) that belongs to a ForkJoinPool. When a VT blocks (e.g., waiting for I/O), it calls yield, is unloaded from its CT, and the CT becomes free to run other VTs. Once the I/O completes, the JVM’s poller invokes Continuation.run, re‑mounting the VT onto (potentially a different) CT.
Because the VT’s stack is saved on the Java heap, using VTs increases heap usage and puts additional pressure on the garbage collector (GC). Full GC pauses therefore have a larger impact on VT‑based applications.
Monitoring Essentials
Given the extra heap and GC pressure, monitoring the following is essential:
JVM startup flags: -Xlog:gc or -XX:NativeMemoryTracking.
Java Flight Recorder (JFR) events.
Tracking Pinned (Carrier) Threads
Although CTs are usually non‑blocking, they can become “pinned” when a VT cannot be unloaded. This happens when the Java stack address is referenced by non‑Java code (e.g., synchronized blocks, native/JNI calls). Pinned threads may degrade performance, especially in hot paths such as JDBC drivers.
To detect pinned threads:
Use the JVM option -Djdk.tracePinnedThreads to log where pinning occurs.
Enable the JFR event jdk.VirtualThreadPinned, which flags threads pinned longer than a configurable threshold (default 20 ms). Example configuration with a 5 ms threshold:
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">5 ms</setting>
</event>Framework‑Specific VT Usage
Different Java frameworks adopt different strategies for VTs:
Helidon 4 runs business code systematically inside VTs while still using PTs for connection management.
Quarkus employs a mixed model with two specialized PT types: an I/O thread for non‑blocking code and a worker thread for blocking code. Developers can annotate blocking code to run on VTs, enabling a gradual migration.
Choosing between the aggressive Helidon approach and the mixed Quarkus approach depends on the workload: the former is simple but less suitable for CPU‑intensive tasks, while the latter introduces context‑switch overhead between I/O and VT worker threads.
Monitoring ForkJoinPool
The ForkJoinPool that backs VTs is critical; an undersized pool slows scheduling and hurts performance. Its size can be tuned via system properties: jdk.virtualThreadScheduler.parallelism – number of carrier threads (default: number of CPU cores). jdk.virtualThreadScheduler.maxPoolSize – maximum pool size (default: 256). The scheduler does not increase parallelism to compensate for pinned threads. jdk.virtualThreadScheduler.minRunnable – minimum number of runnable threads kept in the pool.
In most cases the defaults are sufficient, and there is currently no dedicated metric for VT scheduling latency.
Conclusion
Monitor heap size and GC activity, which become more critical with VTs.
Track pinned (carrier) threads, especially those that may affect performance.
Observe VT creation and termination events ( jdk.VirtualThreadStart and jdk.VirtualThreadEnd) via JFR.
Adjust the ForkJoinPool scheduler size only if you notice scheduling bottlenecks.
Recommended JVM launch options for comprehensive VT monitoring:
java \
-Djdk.tracePinnedThreads=short \
-XX:NativeMemoryTracking=summary \
-Xlog:gc:/path/to/gc-log \
-XX:StartFlightRecording,settings=...,name=...,filename=... \
-jar /path/to/app-jar-fileFuture articles will dive deeper into concrete VT usage patterns in Helidon and Quarkus.
JakartaEE China Community
JakartaEE China Community, official website: jakarta.ee/zh/community/china; gitee.com/jakarta-ee-china; space.bilibili.com/518946941; reply "Join group" to get QR code
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
