Skip to content

feat(core): run user threads on virtual threads (opt-in, Java 21+) - #6855

Open
Yavdhesh wants to merge 1 commit into
apache:masterfrom
Yavdhesh:feat/virtual-threads-for-user-threads
Open

Yavdhesh wants to merge 1 commit into
apache:masterfrom
Yavdhesh:feat/virtual-threads-for-user-threads

Conversation

@Yavdhesh

Copy link
Copy Markdown

Problem

JMeter creates one platform (OS) thread per concurrent virtual user. Platform threads are
expensive: each carries a native stack and scheduler cost, so a load generator's user count is
bounded by thread memory and context switching long before the target system's limits are
reached — especially for the common case where user threads spend most of their time blocked
(waiting for HTTP responses, timers, JDBC).

Change

Opt-in support for running JMeter user threads on virtual threads (Java 21+), behind a new
property, off by default:

jmeterthread.use_virtual_threads=false   # default; set to true to enable
  • New org.apache.jmeter.threads.JMeterThreadFactory — the single place that creates the
    Thread for a JMeterThread. Returns an unstarted virtual thread when the property is on
    and the JVM supports it, otherwise exactly today's new Thread(runnable, name).
  • ThreadGroup — the only two user-thread creation sites (startNewThread(...) and the
    delayed-start ThreadStarter) now go through the factory. The delayed-start path skips
    setDaemon(false) for virtual threads (they are always daemon; see caveats).
  • bin/jmeter.properties — documents the property.

Nothing else changes: no executor, no thread pooling, no changes to JMeterThread, the engine,
samplers, or the GUI.

Compatibility (Java 17)

JMeter is compiled with --release 17, so the Java 21 API (Thread.ofVirtual(),
Thread.isVirtual()) is accessed reflectively, resolved once. Behaviour matrix:

Runtime Property off (default) Property on
Java 17 platform threads (unchanged) platform threads + one-time warning
Java 21+ platform threads (unchanged) virtual threads

If reflective creation fails (e.g. a preview runtime without --enable-preview), the factory
falls back to a platform thread with a warning. Default behaviour is byte-for-byte the current
behaviour.

Design notes / caveats

  • ThreadLocal semantics preserved. One dedicated thread per JMeterThread, exactly as
    today — threads are never pooled or shared between users, so JMeterContextService's
    ThreadLocal<JMeterContext> behaves identically. (A shared-executor design was considered
    and rejected precisely because it would break this.)
  • Completion/stop semantics preserved. Threads are tracked in
    ThreadGroup.allThreads (ConcurrentHashMap<JMeterThread, Thread>); threadFinished(),
    waitThreadsStopped() (join/isAlive) and stopThread() (interrupt()) all work
    unchanged on virtual threads — covered by a dedicated unit test.
  • Daemon threads. Virtual threads are always daemon, and setDaemon(false) throws for
    them, so the delayed-start path only applies it to platform threads. The engine explicitly
    waits for user threads, and GUI mode has AWT threads, so nothing relied on non-daemon user
    threads to keep the JVM alive.
  • Pinning (Java 21). A virtual thread that holds a monitor (synchronized) while doing
    blocking I/O pins its carrier thread (addressed for synchronized in Java 24, JEP 491).
    Samplers with such code may see reduced benefit; watch JFR jdk.VirtualThreadPinned.
    This is a reason the feature is opt-in.
  • CPU-bound samplers gain nothing — virtual threads help blocking workloads.

Tests

New TestJMeterThreadFactory (core test module):

  • property off (default) → platform thread, name preserved, start/join works;
  • property explicitly false → platform thread;
  • property true on Java 21 → the factory returns a virtual thread, the runnable is observed
    executing on a virtual thread, and the thread reports isDaemon() == true (the engine
    contract above); on Java < 21 the case is skipped via JUnit Assumptions;
  • a virtual thread blocked in Thread.sleep responds to interrupt() and can be join()ed
    (the stopThread contract).

Targeted results: TestJMeterThreadFactory 4/4, existing TestJMeterThread 3/3, plus
neighbouring TestJMeterContext, TestTestCompiler, TestUnmodifiableJMeterVariables,
TestTreeCloner 20/20 — 27/27 pass, no regressions in the touched packages.
Maintainers: ./gradlew :src:core:test --tests "org.apache.jmeter.threads.*" and
./gradlew --quiet classes style are the canonical checks (the contributor's sandbox could
not run the Gradle daemon — its loopback-TCP channel is blocked by the sandbox network
policy — so the modules were compiled and the tests executed directly with
javac/kotlinc + the JUnit console at the BOM-pinned dependency versions; see the linked
benchmark notes).

Benchmark (measured, same build/plan/machine — only the property differs)

Workload: each sample blocks 300 ms (the blocking-wait profile of an HTTP request against a
300 ms-latency server); 10,000 samples per run; ramp-up 10 s; -Xms256m -Xmx1g;
Temurin 21.0.13+9; small shared VM (4 vCPUs visible, ~1.5 CPU shared quota, ~7.7 GiB RAM).
Peak RSS / OS threads sampled from /proc/<pid>/status during the run.
In the contributor's sandbox, Java TCP clients are policy-blocked (even on loopback), so the
executed plan uses a Java Request sampler that sleeps 300 ms; an equivalent HTTP-sampler
plan + delay server is included in the benchmark harness for unrestricted machines.

Users Mode Samples Errors Wall elapsed Wall throughput Summariser throughput Peak RSS Peak OS threads
500 platform (baseline) 10,000 0 (0.00%) 38 s 263.2/s 621.2/s 723 MB 525
500 virtual 10,000 0 (0.00%) 36 s 277.8/s 623.6/s 685 MB 30
2000 platform (baseline) 10,000 0 (0.00%) 33 s 303.0/s 865.6/s 784 MB 2025
2000 virtual 10,000 0 (0.00%) 32 s 312.5/s 865.1/s 694 MB 30

Deltas at 2000 users: OS threads 2025 → 30 (−98.5%), peak RSS 784 → 694 MB (−11.5%),
throughput essentially unchanged (865.6 vs 865.1 samples/s in the summariser window — this
workload is latency-bound, as expected). At 500 users: threads 525 → 30 (−94.3%), RSS
723 → 685 MB (−5.3%). The win is resource scaling: OS-thread count stays flat instead of
growing 1:1 with users. Numbers are indicative from a small shared VM; on dedicated
load-generator hardware the headroom gap would be larger, since platform-thread cost grows
linearly with user count. Full methodology, raw outputs and honesty notes: BENCHMARK.md in
the contributor's workspace (harness: DelayServer/GenPlan + run-benchmark.sh).

Add JMeterThreadFactory, used by ThreadGroup at the two places where
threads for JMeterThreads are created. With the new property
jmeterthread.use_virtual_threads=true and a Java 21+ runtime, each
virtual user runs on its own virtual thread instead of a platform
thread, reducing per-user thread memory and context switching for
blocking workloads (HTTP waits, timers, JDBC).

The feature is opt-in and off by default. JMeter is compiled with
--release 17, so the virtual-thread API is used reflectively and the
factory falls back to platform threads on Java 17, or if creation
fails, preserving current behaviour. Threads are still dedicated,
never pooled, so ThreadLocal-based JMeterContext semantics are
unchanged. The delayed-start path no longer calls setDaemon(false)
on virtual threads (they are always daemon; the engine waits for
user threads explicitly via waitThreadsStopped).

Adds TestJMeterThreadFactory covering: platform threads by default
and when the property is false, virtual threads when enabled on
Java 21, and interrupt/join behaviour on virtual threads.
@orimarko

Copy link
Copy Markdown
Contributor

I tested this PR on Windows 11 using Microsoft OpenJDK 21.0.13.0.2. The targeted core thread tests passed.

For an initial HTTP comparison, I used 500 users, a 10-second ramp-up, and 20 loops per user against a local Java HTTP server with a 300 ms delay.

Mode | Requests | Errors | Throughput | Avg. response | JMeter OS threads observed -- | -- | -- | -- | -- | -- Platform | 10,000 | 0 | 619.8/sec | 306 ms | 540 Virtual | 10,000 | 0 | 619.2/sec | 309 ms | 62

The thread counts are Task Manager snapshots, not measured peaks. The delay server ran on the same machine, so these figures shouldn’t be interpreted as whole-machine resource usage. Both runs completed without errors, and observed throughput was similar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants