Why Your eBPF Profiler Lies to You About Java Virtual Threads
In 2026, virtual threads are the default concurrency model in Java, but your production profiling is likely still blind to what is actually happening at the OS level. Traditional eBPF profilers see carrier threads (ForkJoinPool-1-worker-*), completely missing the ephemeral virtual threads (VirtualThread) mounted on them during system-level blocks.
Why Most Developers Get This Wrong
- Trusting legacy APM agents: Relying on standard JVM TI (Tooling Interface) agents that introduce massive safepoint overhead and fail under the sheer volume of millions of virtual threads.
- Ignoring the Carrier Thread abstraction: Assuming OS-level CPU usage maps 1:1 to your business logic, when in reality, the kernel only sees the carrier thread, hiding virtual thread pinning and starvation.
-
Failing to correlate thread IDs: Thinking
Thread.currentThread().threadId()matches the kernel TID, which breaks down entirely when virtual threads are multiplexed.
The Right Way
To achieve zero-overhead continuous profiling, you must stitch kernel-space eBPF stack traces with user-space Loom state by tracking virtual thread mounting and unmounting events in the JVM.
- Leverage JVM USDT (Userland Statically Defined Tracing) Probes: Tap into internal JVM transition events to capture when a virtual thread mounts or unmounts from a carrier thread.
-
Maintain a BPF Map for Context: Use a shared eBPF map keyed by the OS Thread ID (TID) to store the active
java.lang.VirtualThreadobject address or correlation ID. -
Stitch Stacks JIT-Side: Correlate the kernel stack (retrieved via
bpf_get_stackid) with the JVM frame pointer stack at the exact moment of the OS-level block (e.g.,sys_enter_epoll_wait).
Shameless plug: javalld.com has full LLD implementations with step-by-step execution traces — free to use while prepping.
Show Me The Code (or Example)
The following eBPF C snippet intercepts JVM virtual thread mount events to map the OS carrier thread to the active logical virtual thread ID:
// eBPF map tracking: Carrier TID -> Virtual Thread ID
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u32); // Carrier Thread TID
__type(value, u64); // Virtual Thread ID Address
__uint(max_entries, 32768);
} vthread_map SEC(".maps");
SEC("uprobe/libjvm/virtual_thread_mount")
int handle_vthread_mount(struct pt_regs *ctx) {
u32 carrier_tid = bpf_get_current_pid_tgid();
u64 vthread_id = PT_REGS_PARM1(ctx); // Read vthread object reference
bpf_map_update_elem(&vthread_map, &carrier_tid, &vthread_id, BPF_ANY);
return 0;
}
Key Takeaways
- Stop relying on old-school Thread Locals: Virtual threads hop across carrier threads; your profiling context must be dynamically mapped via eBPF.
- USDT is your bridge: Use JVM's internal tracing points to update eBPF maps in real-time with zero JVM-side overhead.
- Stitch, don't guess: True observability in 2026 requires merging physical kernel-level execution with logical virtual-thread lifecycles.
Top comments (0)