DEV Community

Machine coding Master
Machine coding Master

Posted on

Why Your eBPF Profiler Lies to You About Java Virtual Threads

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.VirtualThread object 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;
}
Enter fullscreen mode Exit fullscreen mode

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)