I spent years tuning thread pools for JMS listeners, so virtual threads on Java 25 and Spring Boot 4 got my full attention fast. Here's what actually changes when you swap platform threads for virtual ones in your message-driven services, and where the old assumptions stop holding up.
JMS (or Jakarta Messaging now, though half the industry still calls it JMS out of habit) isn't going anywhere. I still run into ActiveMQ Artemis, IBM MQ, and Solace on client programs, and the pattern is always the same: a listener wakes up, does some blocking work like a DB write or an HTTP call to a downstream service, then acks the message. That blocking work is the whole problem. Every listener thread you spin up to handle concurrency is a platform thread sitting in the OS scheduler, and those aren't free. Virtual threads were finalized in Java 21 under JEP 444, and they gave us a real answer to this without forcing anyone into reactive streams or Mono<T> soup. I've done reactive work before (Spring WebFlux, mostly on a hospital microservices program a couple of years back) and it's powerful, but it rewires how your whole team thinks about code. Virtual threads let you keep writing boring, sequential, blocking-style Java and still get the throughput benefits. By Java 25, which is the current LTS I'm building on across most of my active projects, the platform has matured enough that I no longer treat this as an experimental feature. It's the default for I/O-bound consumers. Why This Matters for JMS Specifically JMS listeners are basically the poster child for "I/O-bound work disguised as CPU work." A typical onMessage() implementation looks like this: @Override public void onMessage(Message message) { String payload = extractBody(message); ApplicationRecord record = repository.save(parse(payload)); // blocks on DB notificationClient.notify(record); // blocks on HTTP } Every one of those calls parks the thread. If you're using a default Spring DefaultMessageListenerContainer or a @JmsListener with a fixed concurrency of, say, 10 to 20 consumers, you're capping your throughput at whatever those platform threads can push through while spending most of their life blocked. Bump concurrency to compensate and you start paying for thread creation, larger stack allocations, and more context switching. I've seen teams throw absurd JVM heap settings at this problem when the real fix is architectural, not a tuning flag. Virtual threads change the math. They cost a few KB of memory each versus roughly a megabyte for a platform thread, and when they block on I/O the JVM unmounts them from the carrier thread instead of parking an OS thread. You can have thousands of virtual threads waiting on a database pool and it barely shows up on your OS thread count. Getting Virtual Threads Into the JMS Consumer Path There are two levels here: the framework-provided executor, and the raw Jakarta Messaging API if you're not on Spring. Spring Boot 4 If you're on Spring Boot 4 with Java 25 (the property has been around since 3.2, and it still works exactly the same way in 4), you get a single flag that does most of the work for you: spring: threads: virtual: enabled: true That flag switches the task executor used by @Async , the web server's request handling, and, this is the part that matters here, the executor backing DefaultJmsListenerContainerFactory if you wire it correctly. But don't just flip the flag and assume it propagated everywhere. I got burned by this exact assumption on a proof of concept: the flag was on, but I'd configured a custom ThreadPoolTaskExecutor for the JMS container factory, and Spring, quite reasonably, respected my explicit bean over the auto-configuration. Lesson learned. Check your bean definitions before you trust a global property. The explicit way, which I actually prefer because it's clearer to the next engineer reading the code: @Bean public DefaultJmsListenerContainerFactory jmsListenerContainerFactory( ConnectionFactory connectionFactory) { DefaultJmsListenerContainerFactory factory = new DefaultJmsListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setTaskExecutor(Executors.newVirtualThreadPerTaskExecutor()); factory.setConcurrency("20-50"); return factory; } That newVirtualThreadPerTaskExecutor() call is doing the heavy lifting. Each message dispatched to the listener gets its own virtual thread instead of pulling from a bounded platform-thread pool. The setConcurrency range still controls how many JMS sessions and consumers you open against the broker, which matters for a different reason I'll get to in a second. Plain Jakarta Messaging, no Spring If you're wiring this by hand (I did this on a small internal tool that talks to an Artemis broker without any Spring dependency at all), it's just as direct on Java 25: ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor(); try (JMSContext context = connectionFactory.createContext()) { JMSConsumer consumer = context.createConsumer(destination); consumer.setMessageListener(message -> { virtualExecutor.submit(() -> { try { process(message); message.acknowledge(); } catch (Exception e) { log.error("Failed processing message {}", message, e); } }); }); } Notice I'm submitting the actual processing to the executor from inside the listener callback rather than blocking the callback itself. That distinction trips people up. If your broker's listener thread is itself a platform thread (it usually is, since the JMS provider owns it), you still want to hand off work quickly so that thread can go get the next message. The Part Everyone Skips: Connection Pool Sizing Here's where I see people get excited about virtual threads, wire everything up, and then wonder why throughput barely moved or, worse, why the app started throwing connection timeout exceptions under load. Virtual threads remove the platform-thread bottleneck. They do nothing for your database connection pool, your HTTP client's connection limit, or the broker's own consumer and session limits. If you go from 20 concurrent listener threads to effectively unbounded virtual thread concurrency, and your HikariCP pool is still capped at maximumPoolSize: 20 , you haven't gained anything. You've just moved the queue from "waiting for a listener thread" to "waiting for a DB connection," and now your metrics look confusing because the bottleneck moved somewhere less obvious. What I actually do: Size the downstream resource pools (DB, HTTP client, cache) to whatever the resource itself can sustain, not to whatever concurrency level felt aggressive. Increase JMS consumer and session concurrency modestly, since each open session against the broker has its own overhead regardless of virtual threads. Watch the connection pool's wait time metric like it's the only metric that matters. Because, for this specific problem, it kind of is. Set the HikariCP pool size using the classic ((core_count * 2) + effective_spindle_count) formula as a starting point, then adjust based on actual load testing, not vibes. On a recent internal notification service rebuild I did for ITJobOpportunities (the piece that fires Telegram and WhatsApp alerts when a featured job gets promoted), I initially left the HikariCP pool at the Spring Boot default of 10. Virtual threads made the listener side basically infinite in capacity, so all that pressure just piled up waiting for a DB connection instead. Response times got worse, not better, and I spent an embarrassing hour looking in the wrong place before I checked the pool metrics. Bumped the pool to 30, and the whole thing evened out. Pinned Threads: The Gotcha That's Finally Getting Better This one used to matter more than people expected. Virtual threads get "pinned" to their carrier thread, meaning they can't unmount during a blocking call, in a couple of specific situations: Inside a synchronized block or method. During certain native method calls. If your onMessage() handler, or anything it calls into, used synchronized around a blocking I/O operation on Java 21 through 23, you lost most of the...