A Practical Guide to Using Java Virtual Threads With JMS Listeners
Build scalable Spring JMS listeners with Java virtual threads, focusing on concurrency, transactions, idempotency, and safe blocking workloads.
Join the DZone community and get the full member experience.
Join For FreeScaling JMS Listeners With Java Virtual Threads
Event-driven architecture is widely used in enterprise systems to decouple services, absorb traffic spikes, and move work out of request paths. Java Message Service (JMS), now standardized as Jakarta Messaging, remains common in systems built around ActiveMQ, IBM MQ, Solace, TIBCO EMS, and similar brokers.
Java 21 virtual threads give these systems another scaling option. A JMS listener often spends more time waiting on a database, HTTP service, cache, or file system than it spends using the CPU. Moving that blocking work to virtual threads can reduce platform-thread pressure without forcing the application into a reactive programming model.
However, virtual threads do not make the broker, database, or downstream services unlimited. They also do not change acknowledgment, transaction, redelivery, or ordering semantics. A safe design combines virtual threads with bounded JMS consumer concurrency, explicit resource limits, idempotency, and production metrics.
This article explains what virtual threads change for Spring JMS listeners, how to configure them explicitly, and how to avoid moving the bottleneck from the JVM into the rest of the system.
The Traditional JMS Listener Model
A typical queue-based flow moves messages from the broker through a Spring listener container and into a handler that calls downstream systems. Figure 1 compares how that handler work occupies platform threads with how it runs when the container's consumer-invoker tasks use virtual threads.

Figure 1. Platform threads compared with virtual-thread consumer invokers in a Spring JMS listener.
The container manages JMS connections, sessions, consumers, acknowledgments, and listener invocation. The handler contains the business logic:
@JmsListener(
destination = "orders.created",
containerFactory = "jmsListenerContainerFactory"
)
public void handle(OrderCreatedEvent event) {
Customer customer =
customerClient.getCustomer(event.customerId());
inventoryService.reserve(event.orderId(), customer);
orderRepository.markAsProcessing(event.orderId());
}
This code is easy to read, but each downstream operation may block. With platform threads, an operating-system-backed thread remains occupied while a query or network call is waiting.
When enough listener threads are blocked, new messages wait even if the CPU is not saturated. The application has become thread-bound rather than CPU-bound.
Before virtual threads, teams usually increased the listener thread pool, scaled out more service instances, or rewrote the flow around asynchronous or reactive APIs. Those options remain valid, but each has a cost. Larger platform-thread pools use more memory and add scheduling overhead. More instances increase infrastructure and operational work. Reactive code can scale efficiently, but it changes libraries, control flow, debugging, and error handling.
What Virtual Threads Change
A virtual thread is still a java.lang.Thread, but it is scheduled by the JVM rather than being permanently tied to one operating-system thread. The platform thread that temporarily runs a virtual thread is called its carrier.
When a virtual thread blocks on supported I/O, the JVM can unmount it from the carrier. The carrier is then free to run another virtual thread. This lets an application maintain straightforward, sequential code while supporting many concurrent blocking operations.
As Figure 1 shows, virtual threads that are waiting on supported I/O can unmount from their carriers, leaving those carriers available to execute other ready work.
Virtual threads can improve throughput when platform-thread scarcity is the limiting factor. They do not make an individual database call or HTTP request faster, and they do not add CPU capacity.
Good candidates include handlers dominated by:
- JDBC calls
- Blocking REST or gRPC clients
- Cache lookups
- File or object-storage operations
- Legacy synchronous SDKs
- Synchronous orchestration across downstream systems
Weak candidates include handlers dominated by:
- CPU-heavy transformations
- Encryption or compression
- Image or video processing
- Machine learning inference
- Large in-memory aggregation
The JDK guidance is to create a virtual thread per task rather than pool virtual threads. Limited resources should be protected with explicit mechanisms such as semaphores, rate limiters, connection pools, and framework concurrency settings.
The JMS Detail That Changes the Design
For Spring's DefaultMessageListenerContainer, a listener thread normally belongs to a consumer invoker. That invoker owns or reuses a JMS Session and MessageConsumer and may process many messages during its lifetime.
Therefore, enabling virtual threads does not necessarily create one new virtual thread for every message. It places the container's consumer tasks on virtual threads. The distinction matters because raising concurrency also raises the number of active JMS consumers and sessions. Those broker-side resources are not as cheap as virtual threads.
The right side of Figure 1 models this relationship explicitly: a configured consumer-invoker task runs on a virtual thread and may process multiple messages during its lifetime.
This architecture is still useful. A consumer can unmount from its carrier while its handler waits on downstream I/O. But the listener container's concurrency remains the primary control over how many messages can be processed at once.
Configure the JMS Executor Explicitly
Spring Boot can enable virtual threads for several Boot-managed execution paths with spring.threads.virtual.enabled=true. Do not assume that this property alone proves that a JMS listener container uses virtual threads. Configure the JMS container's executor explicitly and verify it at runtime.
Figure 2 separates the application wiring from the runtime flow. The explicit connection between the virtual-thread-enabled TaskExecutor and the JMS listener factory is the important step; the container's concurrency setting continues to bound active consumers and sessions.

Figure 2. Explicit Spring JMS virtual-thread wiring and runtime message flow.
The following example uses Java 21 or later and Spring Framework 6.1 or later. It supplies a virtual-thread-enabled SimpleAsyncTaskExecutor to the listener container factory:
import java.util.concurrent.Executor;
import jakarta.jms.ConnectionFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.jms.config.DefaultJmsListenerContainerFactory;
@Configuration(proxyBeanMethods = false)
class JmsConfiguration {
@Bean("jmsVirtualThreadExecutor")
SimpleAsyncTaskExecutor jmsVirtualThreadExecutor() {
SimpleAsyncTaskExecutor executor =
new SimpleAsyncTaskExecutor("jms-vt-");
executor.setVirtualThreads(true);
return executor;
}
@Bean
DefaultJmsListenerContainerFactory jmsListenerContainerFactory(
ConnectionFactory connectionFactory,
@Qualifier("jmsVirtualThreadExecutor") Executor executor
) {
DefaultJmsListenerContainerFactory factory =
new DefaultJmsListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setTaskExecutor(executor);
// Example limits only. Derive these from load tests and
// the safe capacity of the broker and downstream systems.
factory.setConcurrency("10-100");
// Prefer transactional JMS acknowledgment when redelivery
// on listener failure is required.
factory.setSessionTransacted(true);
return factory;
}
}
SimpleAsyncTaskExecutor.setVirtualThreads(true) requires Java 21. Spring Framework 6.2 also added DefaultMessageListenerContainer.setVirtualThreads(true) for applications that construct the listener container directly and use its internal default executor.
If a Spring Boot application uses Boot's DefaultJmsListenerContainerFactoryConfigurer, apply it before the explicit executor, concurrency, and transaction overrides so that other Boot JMS properties are retained.
Virtual threads are daemon threads. In a non-web worker where no other non-daemon thread keeps the JVM alive, use Spring Boot's spring.main.keep-alive=true or an equivalent application-lifecycle mechanism. Do not rely on incidental threads created by a broker client to keep the process running.
A small startup test can confirm the execution mode:
if (!Thread.currentThread().isVirtual()) {
throw new IllegalStateException(
"The JMS listener is not running on a virtual thread"
);
}
Use this as a test or temporary diagnostic rather than performing it for every production message. Also confirm the active container factory when an application defines more than one.
Bound Concurrency Around Real Capacity
Virtual threads reduce thread scarcity. They do not remove resource scarcity.
A listener can still be limited by:
- JMS sessions and consumers
- Broker prefetch, consumer windows, or credit
- Database connections
- HTTP client connections
- Downstream rate limits
- Memory used by in-flight payloads
- Transaction locks
- CPU
A useful first estimate comes from Little's Law:
required concurrency ~= target throughput x average processing time
If the target is 200 messages per second and the average handler time is 250 milliseconds, the initial estimate is:
200 messages/second x 0.25 seconds = 50 concurrent handlers
That value is only a starting point. It must be capped by the safe capacity of every dependency. If each message holds a database connection and the usable pool capacity is 30, setting listener concurrency to 100 may only create 70 additional waiters. If a payment API permits 40 concurrent requests, protect that call separately with a semaphore or rate limiter.
The concurrency range 10-100 in the example means that the container can maintain a baseline and scale to a maximum. It does not guarantee that 100 is safe, and a maximum of 100 may be much too high for some brokers or workloads.
Broker flow-control settings matter as well. Excessive prefetch can move a large backlog from the broker into consumers, increase the number of unacknowledged messages, and make recovery less predictable. Keep enough prefetched work to feed consumers, but avoid using prefetch as an unbounded application queue.
Acknowledgment and Transactions Must Be Deliberate
Virtual threads do not change message-delivery guarantees.
This is especially important with Spring's DefaultMessageListenerContainer. In its default AUTO_ACKNOWLEDGE mode, the container acknowledges before listener execution, so a listener exception does not cause redelivery. If the application requires rollback and redelivery after a handler failure, use a transacted JMS session or an appropriately configured external transaction manager.
A local JMS transaction covers JMS receipt and JMS sends performed through the same session. It does not automatically include a database transaction. A database commit can succeed, and the JMS commit can fail, causing the message to be delivered again.
There are three common strategies:
- Use idempotent handlers and local transactions.
- Use an inbox/outbox design to make database effects repeatable and outbound publication reliable.
- Use JTA/XA when atomic coordination across JMS and another transactional resource is required, and its operational cost is justified.
Figure 3 shows the inbox/outbox lifecycle, including the duplicate path, the separate JMS acknowledgment boundary, broker-managed redelivery, and dead-letter handling.

Figure 3. Idempotent JMS processing, acknowledgment, retry, and dead-letter lifecycle.
Do not treat @Transactional on a database service as proof that the JMS acknowledgment participates in the same transaction. Verify which transaction manager is active and which resources it coordinates.
Make the Consumer Idempotent
Redelivery can occur after broker failover, transaction rollback, application restart, timeout, or a failure between two resource commits. Higher concurrency also makes race conditions in duplicate detection easier to expose.
An inbox table is a common solution. As shown in Figure 3, the application atomically inserts the message ID and applies the business changes in the same database transaction. A duplicate key follows a safe no-op path instead of repeating the business effect.
The database must enforce a unique constraint on the message ID. A separate exists() check is not enough because two concurrent deliveries can both observe that the row is absent.
@Transactional
public void process(OrderCreatedEvent event) {
boolean firstDelivery =
processedMessageRepository.tryInsert(event.messageId());
if (!firstDelivery) {
return;
}
orderService.apply(event);
}
tryInsert should use an atomic insert-if-absent operation protected by a unique key and report a duplicate without committing a separate transaction. Avoid catching a generic constraint exception if the persistence provider marks the whole transaction rollback-only. If the business update fails, the transaction should roll back both the inbox insert and the business changes.
External side effects need their own idempotency strategy. For example, send an idempotency key to a payment API or persist an operation state before invoking a service that cannot participate in the local transaction.
Keep Transactions and Retries Short
Avoid holding a database or JMS transaction open while a slow external service retries for minutes. The risky pattern begins a transaction, calls an external API, waits and retries, and only then updates the database and commits.
This can hold locks, database connections, JMS sessions, and unacknowledged messages. A virtual thread makes the waiting thread cheaper, but it does not release those resources.
A safer design, illustrated in Figure 3, commits the business update and outbox record as local intent and continues asynchronously through an outbox publisher.
The database update and outbox insert occur in one local transaction. A separate publisher sends pending outbox records and marks them complete. If the inbound JMS message is redelivered after the database commit, the inbox key prevents the business update and outbox insert from being repeated.
Long retry delays should normally be handled with broker redelivery delay, a retry queue, or a scheduler. Sleeping a virtual thread is cheap from a carrier-thread perspective, but the listener may still hold a JMS consumer, session, transaction, and message during the delay.
Classify errors before retrying:
| Failure type | Typical response |
|---|---|
| Transient network or dependency failure | Retry with exponential backoff and jitter |
| Rate limit | Honor the server's delay and reduce concurrency |
| Invalid message schema | Send to a dead-letter queue |
| Missing required business data | Dead-letter or route for correction |
| Repeated unknown failure | Stop after a bounded attempt count and alert |
Every production listener should define a maximum redelivery count, dead-letter destination, replay procedure, and owner for investigating poison messages.
Do Not Detach Work From the Listener Carelessly
A tempting design is to let the JMS listener receive a message, submit the real work to another executor, and return immediately. This can create more parallelism, but it can also acknowledge the message before the work finishes.
It may also cross thread boundaries with a JMS Session, which is single-threaded by contract. Transaction context, error propagation, and redelivery behavior can all be lost.
Let the listener container own the handler's execution unless the application deliberately implements a handoff protocol. A safe handoff usually means persisting the message or command durably before the listener returns, not merely placing a Runnable in an in-memory executor.
Preserve Ordering Where It Matters
Higher concurrency changes ordering behavior. Once a queue has multiple active consumers, messages can complete in a different order from the order in which the broker delivered them.
Choose the ordering scope explicitly:
- Keep concurrency at one for strict global ordering.
- Partition or route messages by a business key.
- Serialize processing for the same key.
- Add sequence checks when events can arrive out of order.
- Design state transitions to reject stale events.
Virtual threads are easiest to adopt when messages are independent or when ordering is limited to a partition or business key.
For topics, do not increase consumer concurrency as if the destination were a queue. Depending on subscription configuration, additional topic consumers can receive additional copies of each message. Review durable and shared subscription semantics for the broker and container.
Test the Bottleneck, Not Just the Thread Count
An illustrative order-processing workload may perform one database read, two HTTP calls, one database update, and one outbound event for each message.
Compare platform threads and virtual threads with:
- The same message corpus and payload distribution
- The same acknowledgment and transaction settings
- The same database and HTTP pool limits
- The same broker prefetch or credit
- The same retry and dead-letter policy
- A controlled concurrency ramp
Measure more than throughput:
| metric | what it reveals |
|---|---|
| Queue depth and oldest-message age | Backlog and user-visible delay |
| Consume rate | Sustainable throughput |
| Handler p50, p95, and p99 latency | Normal and tail behavior |
| Scheduled and active JMS consumers | Actual container concurrency |
| Platform and virtual thread counts | Whether thread pressure moved |
| Carrier CPU and pinned-thread events | Scheduler or compatibility problems |
| Database pool utilization and wait time | Database saturation |
| HTTP pool utilization and timeouts | Outbound connection pressure |
| Downstream throttling | Rate-limit pressure |
| Redelivery and DLQ counts | Failure amplification |
| Heap and garbage collection | Cost of in-flight work |
Virtual threads are successful when the system sustains the required throughput with lower platform-thread pressure and without increasing timeouts, throttling, redelivery, or tail latency.
If throughput rises while downstream errors rise faster, the system is not healthier. It is only delivering overload more efficiently.
Diagnose Pinning and Provider Compatibility
On Java 21, a virtual thread can pin its carrier when it blocks while executing certain synchronized or native code. Occasional short pinning is usually harmless. Frequent long pinning can reduce scalability.
Use Java Flight Recorder's jdk.VirtualThreadPinned event or run a load test with:
-Djdk.tracePinnedThreads=full
Do this with the actual JMS provider, JDBC driver, HTTP client, monitoring agents, and security libraries used in production. Compatibility cannot be inferred from a synthetic Thread.sleep benchmark.
JDK 24's JEP 491 removes nearly all pinning caused by synchronized methods and blocks, but native or foreign-function interactions and third-party behavior still deserve testing.
Decision Matrix
| scenario | virtual-thread fit |
|---|---|
| Blocking JDBC calls | Strong |
| Blocking REST or gRPC calls | Strong |
| Legacy synchronous SDKs | Strong |
| High-volume, I/O-bound queue listeners | Strong with bounded consumers |
| CPU-heavy transformation | Weak |
| Strict global ordering | Limited |
| Small downstream capacity | Useful only with strict limits |
| Weak acknowledgment or retry design | Fix delivery semantics first |
| No observability | Add measurements first |
Production Checklist
Before enabling virtual threads for JMS listeners, confirm that:
- The application runs on Java 21 or later.
- The JMS executor is explicitly configured and verified as virtual.
- Listener concurrency is capped by measured downstream capacity.
- Broker prefetch, consumer window, or credit is tuned.
- Acknowledgment and transaction behavior is documented and tested.
- Duplicate processing is prevented with an atomic idempotency mechanism.
- Retries are bounded, delayed, and classified.
- A dead-letter queue and replay process exist.
- Ordering requirements are explicit.
- Load tests use real drivers and representative dependencies.
- Queue age, tail latency, pool saturation, redelivery, and pinned-thread events are monitored.
Conclusion
Virtual threads are a strong fit for JMS listeners that spend much of their time waiting on blocking I/O. They let teams preserve simple, imperative Java code while reducing the platform-thread cost of concurrent message processing.
The safe adoption pattern is not “turn on virtual threads and remove the limits.” It is:
- Put the listener container's consumer tasks on virtual threads.
- Bound consumer concurrency using broker and downstream capacity.
- Make acknowledgment, transactions, and idempotency explicit.
- Test with the real provider and dependencies.
- Measure where the bottleneck moves.
When those controls are in place, virtual threads can modernize an established JMS application without requiring a reactive rewrite. They make waiting cheaper. The architecture still has to decide how much work the system can safely accept.
References
- JEP 444: Virtual Threads
- Oracle Java 21 Virtual Threads Guide
- Spring Framework: DefaultMessageListenerContainer
- Spring Framework: Processing JMS Messages Within Transactions
- Spring Boot 3.2 Release Notes: Virtual Thread Support
- Jakarta Messaging 3.1 Specification
- JEP 491: Synchronize Virtual Threads Without Pinning
Opinions expressed by DZone contributors are their own.
Comments