Yavdhesh opened a new pull request, #6855:
URL: https://github.com/apache/jmeter/pull/6855
## 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).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]