Repository navigation
Conversation
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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
org.apache.jmeter.threads.JMeterThreadFactory— the single place that creates theThreadfor aJMeterThread. Returns an unstarted virtual thread when the property is onand the JVM supports it, otherwise exactly today's
new Thread(runnable, name).ThreadGroup— the only two user-thread creation sites (startNewThread(...)and thedelayed-start
ThreadStarter) now go through the factory. The delayed-start path skipssetDaemon(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:If reflective creation fails (e.g. a preview runtime without
--enable-preview), the factoryfalls back to a platform thread with a warning. Default behaviour is byte-for-byte the current
behaviour.
Design notes / caveats
JMeterThread, exactly astoday — threads are never pooled or shared between users, so
JMeterContextService'sThreadLocal<JMeterContext>behaves identically. (A shared-executor design was consideredand rejected precisely because it would break this.)
ThreadGroup.allThreads(ConcurrentHashMap<JMeterThread, Thread>);threadFinished(),waitThreadsStopped()(join/isAlive) andstopThread()(interrupt()) all workunchanged on virtual threads — covered by a dedicated unit test.
setDaemon(false)throws forthem, 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.
synchronized) while doingblocking I/O pins its carrier thread (addressed for
synchronizedin 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.
Tests
New
TestJMeterThreadFactory(core test module):false→ platform thread;trueon Java 21 → the factory returns a virtual thread, the runnable is observedexecuting on a virtual thread, and the thread reports
isDaemon() == true(the enginecontract above); on Java < 21 the case is skipped via JUnit
Assumptions;Thread.sleepresponds tointerrupt()and can bejoin()ed(the
stopThreadcontract).Targeted results:
TestJMeterThreadFactory4/4, existingTestJMeterThread3/3, plusneighbouring
TestJMeterContext,TestTestCompiler,TestUnmodifiableJMeterVariables,TestTreeCloner20/20 — 27/27 pass, no regressions in the touched packages.Maintainers:
./gradlew :src:core:test --tests "org.apache.jmeter.threads.*"and./gradlew --quiet classes styleare the canonical checks (the contributor's sandbox couldnot 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>/statusduring 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.
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).