How ActiveMQ v6.3.0 boosts topic throughput up to 93%
Java’s Virtual Threads are the real deal
The ActiveMQ Topic-Send Fix: What 6.3.0 Actually Buys You (and What Virtual Threads Add on Top)
Apache ActiveMQ 6.3.0 shipped a fix for a long-standing scalability limit in topic message sending. The change is small — it swaps one synchronized method for a ReentrantLock and moves subscriber dispatch out from under the lock — but the effect under load is not small. This post walks through how we measured it, what we found, and one virtual-thread bug we tripped over on the way.
TL;DR — On a persistent topic with 10 durable subscribers and 50 concurrent producers, upgrading from 6.2.8 to 6.3.0 raised send throughput by ~68%. Turning on the broker’s virtual-thread task runner (JDK 25+) pushed that to ~89%. Asynchronous producers get a bump to ~93%. When there’s no contention — one subscriber, or one producer — the change is a wash, exactly as designed. The benefit scales with how much dispatch work the broker does per send.
The changes under test
In ActiveMQ, every message published to a topic flows through Topic.doMessageSend(…). Historically that method was synchronized the broker assigned the message a sequence id, persisted it, and dispatched it to every subscriber — all while holding the topic’s monitor. With many concurrent producers and/or many subscribers, that single lock became the bottleneck. Producers queued up behind each other even though most of the work (dispatch to consumers, waiting on the store) didn’t actually need to be serialized.
The topic send fix released in v6.3.0 (authored by Jean-Louis) narrows the critical section. A ReentrantLock is now held only across the ordering-sensitive part — assigning the broker sequence id and enqueuing the persistence write. Dispatch to subscribers and the wait for persistence to complete happen outside the lock. That’s safe because the JMS specification (and virtually every messaging protocol) only guarantees message ordering per producer’s session, and a JMS session is single-threaded — so two different producers’ messages can be dispatched concurrently without violating any ordering contract.
Separately, my work to add Virtual Thread support in ActiveMQ 6.2.0+ is now ready for general purpose testing in ActiveMQ 6.3.0, as JDK 25 support was part of the release.
IMPORTANT! JDK 25 solves the thread pinning issue with Virtual Threads, and anyone testing ActiveMQ with Virtual Threads should only use a JDK 25+ when running ActiveMQ with Virtual Threads.
Instead of a fixed platform-thread pool, the broker can run its task-runner work on virtual threads. The two features compose — a narrower lock means more concurrency to exploit, and virtual threads make that concurrency cheaper to schedule.
Test methodology
I wanted to build a test user’s could ready related and reproduce:
- 6.2.8 — the last release before the topic-send fix (baseline).
- 6.3.0 — the fix, as shipped.
- 6.3.0 + virtual threads — the fix with the broker virtual thread runner enabled.
I chose the release comparison over isolating the commits as to not hide any impact that other release changes may have had on the throughput numbers.
Test harness
The benchmark is a JMH throughput test that publishes persistent messages to a virtual topic. Two knobs make it representative of the code path we care about:
numSubscribersdurable topic subscribers: Durable subscribers force real persistence (the broker can’t optimize the store away) and create real dispatch work — the exact work the patch moves out of the lock.numSubscribersvirtual-topic queue consumers: Adding realistic broker-side routing using ActiveMQ’s Virtual Topics.
To make persistence cost visible without dragging a real data store into every run (which complicates the tuning for the test), the store is an in-memory adapter wrapped with a 200µs busy-wait per persist (MEMORY_BUSYWAIT) — this keeps the topic lock-hold time long enough to matter, but not dirty up results with persistent store performance variations between test runs.
Parameter matrix
Parameter Values
───────────────── ─────────────────────────────────
numSubscribers 1, 5, 10
producer threads 1, 10, 50
useAsyncSend false, true
store MEMORY_BUSYWAIT (200 µs simulated I/O)
Reference: JMH settings: -f 5 -wi 3 -w 3s -i 5 -r 3s (5 forks, 3×3s warmup, 5×3s measurement per fork), throughput mode.
Environment
- Apple M3 Pro (11 cores), 36 GB RAM, macOS 26.5.2
- Eclipse Temurin JDK 25 (25+36 LTS)
- ActiveMQ 6.2.8 and 6.3.0
Results
The unit is ops/s — completed producer.send(message) calls per second (one persistent message published to the topic and dispatched to its subscribers), aggregated across all producer threads. Higher is better. The percentage is the delta versus the 6.2.8 baseline; the error is the JMH 99.9% confidence half-width.
Test runs for both synchronous and asynchronous send modes.
Synchronous send
| subs | threads | 6.2.8 (ops/s) | 6.3.0 | 6.3.0 + vthreads |
|---|---|---|---|---|
| 1 | 1 | 4522 ± 10 | 4553 (+1%) | 4547 (+1%) |
| 1 | 10 | 4768 ± 8 | 4803 (+1%) | 4793 (+1%) |
| 1 | 50 | 4753 ± 4 | 4772 (+0%) | 4739 (−0%) |
| 5 | 1 | 3932 ± 41 | 3975 (+1%) | 4058 (+3%) |
| 5 | 10 | 4377 ± 30 | 4781 (+9%) | 4759 (+9%) |
| 5 | 50 | 4178 ± 12 | 4491 (+7%) | 4579 (+10%) |
| 10 | 1 | 2781 ± 54 | 2774 (−0%) | 3089 (+11%) |
| 10 | 10 | 3080 ± 29 | 3890 (+26%) | 4338 (+41%) |
| 10 | 50 | 2270 ± 60 | 3825 (+68%) | 4289 (+89%) |
Default produce mode useAsyncSend=false
Synchronous send · ops/s, higher is better · % vs ActiveMQ 6.2.8 · error is JMH 99.9% CI half-width
Asynchronous send
| subs | threads | 6.2.8 (ops/s) | 6.3.0 | 6.3.0 + vthreads |
|---|---|---|---|---|
| 1 | 1 | 4884 ± 4 | 4907 (+0%) | 4903 (+0%) |
| 1 | 10 | 4876 ± 30 | 4887 (+0%) | 4842 (−1%) |
| 1 | 50 | 5028 ± 565 | 4882 (−3%) | 4788 (−5%) |
| 5 | 1 | 4204 ± 31 | 4263 (+1%) | 4743 (+13%) |
| 5 | 10 | 4294 ± 19 | 4787 (+11%) | 4765 (+11%) |
| 5 | 50 | 4124 ± 33 | 4475 (+9%) | 4551 (+10%) |
| 10 | 1 | 2776 ± 50 | 2817 (+1%) | 3565 (+28%) |
| 10 | 10 | 3083 ± 26 | 3579 (+16%) | 4299 (+39%) |
| 10 | 50 | 2227 ± 39 | 3694 (+66%) | 4290 (+93%) |
Throughput optimized produce mode useAsyncSend=true
Asynchronous send · ops/s, higher is better · % vs ActiveMQ 6.2.8 · error is JMH 99.9% CI half-width
Grokking the results: it’s all about dispatch work
The single most informative axis is subscriber count, because it maps directly to how much work the patch relocated:
- 1 subscriber → ~0% everywhere: With one durable sub there’s almost no dispatch work inside the lock, so moving dispatch out changes nothing. This is the control case, and it behaves exactly as the we’d hope.
- 5 subscribers → modest gains: (+7–11%), appearing once you have concurrent producers.
- 10 subscribers → large gains that grow with producer count: +26% at 10 threads, 68% at 50 threads for the fix alone.
The benefit scales with subscribers by concurrent producers. Dispatch to n subscribers, contended by n producers, no longer serialized mechanism the patch implements. That’s the verfication that tells us the release-to-release improvement really is this change and not something incidental.
There’s a second, win. Look at the 10-subscriber baseline as producers go from 10 to 50: 6.2.8 throughput drops (3080 → 2270 ops/s). That’s the lock convoy — more producers make things worse. On 6.3.0 with virtual threads, the same load holds steady (4338 → 4289). The upgrade doesn’t just raise the ceiling; it removes the concurrent throughput cliff.
Virtual threads add a consistent throughput improvement on top of the lock fix, largest exactly where contention is highest — up to 89% (sync) and 93% (async) over baseline at 50 producers / 10 subscribers. At 1 producer /10 subscribers, virtual threads help (+11–28%) even though the lock fix doesn’t, suggesting the cheaper scheduling pays off in the dispatch fan-out independent of producer contention.
Reproduce it yourself
Build each release’s benchmark and run the matrix:
% mvn -pl activemq-unit-tests -am install -DskipTests -Dmaven.compiler.proc=full
Run the 10 subscriber config
CP="activemq-unit-tests/target/test-classes:activemq-unit-tests/target/classes:$(mvn -q -pl activemq-unit-tests dependency:build-classpath -Dmdep.outputFile=/dev/stdout)"
java -cp "$CP" org.openjdk.jmh.Main \
'TopicSendLockBenchmark.send_01_thread|TopicSendLockBenchmark.send_10_threads|TopicSendLockBenchmark.send_50_threads' \
-p storeType=MEMORY_BUSYWAIT -p numSubscribers=10 -p useAsyncSend=false \
-f 5 -wi 3 -i 5 -rf json -rff result.json
Bottom line
If you run topics with multiple durable subscribers and concurrent producers, upgrading to ActiveMQ 6.3.0 is a real, measurable throughput win — roughly 25–70% in the contended cases we tested — and it eliminates a throughput regression that got worse under load in 6.2.8. Enabling the broker’s virtual-thread task runner on JDK 25+ adds a further lift, up to ~90% over the old release at high fan-out. And if you have a single subscriber or a single producer, you lose nothing: the change is invisible exactly where it should be.
Originally published on Medium: How ActiveMQ v6.3.0 boosts topic throughput up to 93%
Matt Pavlovich, the Chief Technology Officer and Technical Practice Lead at HYTE Technologies, directs the HYTE Product Development Team. With a wealth of experience in the Open Source Software community, Matt is also a Committer on the Apache ActiveMQ project. Known for his technical prowess and leadership skills, Matt has successfully led numerous large-scale ActiveMQ implementations worldwide. Under his guidance, HYTE's services and tools enable accelerated Enterprise application development and enhance the supportability of middleware solutions.