Virtual Threads Eliminated Thread Pools — Until MySQL Connections Ran Out
After upgrading to Java 25 and Spring Boot 4, the author enabled virtual threads and removed custom thread pools for IO-heavy aggregation endpoints. While code simplified dramatically, the bottleneck shifted from thread exhaustion to HikariCP connection pool exhaustion, revealing that virtual threads don't increase downstream capacity. The solution: limit scarce resources (DB connections, HTTP clients) directly rather than limiting threads.
After upgrading a project to Java 25 and Spring Boot 4, the author enabled virtual threads with a single configuration line:
spring:
threads:
virtual:
enabled: trueA test endpoint confirmed requests now ran on virtual threads ( thread.isVirtual() == true).
From CompletableFuture to Simple Synchronous Code
The order detail endpoint originally executed five independent queries sequentially (order, stock, member, coupon, logistics), each taking 30–50 ms, leading to 200–300 ms total latency. To parallelize, the team introduced CompletableFuture.supplyAsync with a dedicated ThreadPoolTaskExecutor (core 20, max 100, queue 500). Over time, multiple such executors proliferated (order, product, homepage, reporting, messaging), each with tuned parameters.
With virtual threads, the author replaced the CompletableFuture boilerplate with a per‑task virtual‑thread executor:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future order = executor.submit(() -> orderService.getOrder(orderId));
Future stock = executor.submit(() -> stockService.getStock(orderId));
Future member = executor.submit(() -> memberService.getMember(userId));
Future coupon = executor.submit(() -> couponService.getCoupon(orderId));
Future logistics = executor.submit(() -> logisticsService.getLogistics(orderId));
return buildResult(order.get(), stock.get(), member.get(), coupon.get(), logistics.get());
}The code became readable again — synchronous‑looking, no thenApply / allOf / join chains — and blocking on JDBC or HTTP no longer wastes a platform thread.
The Hidden Bottleneck: Database Connection Pool
Under load testing, Tomcat threads no longer piled up, but HikariCP started queuing. The connection pool was still configured at maximum-pool-size: 20. Previously, the thread pool (max 100) implicitly limited concurrent database access; now thousands of virtual threads could be created instantly, all hitting orderRepository.findById(...) and waiting for one of 20 connections.
The author illustrates the shift:
Before: 1000 requests → only ~200 platform threads could run → natural throttle.
After: 1000 virtual threads all reach the DB layer → 20 connections serve them → 980 threads park waiting for a connection.
Virtual threads solve “threads are expensive”; they do not create more MySQL connections, Redis connections, HTTP sockets, or downstream service capacity.
Limiting the Right Resource
Instead of a global thread pool, the author now applies back‑pressure at each scarce resource:
Third‑party logistics API — wrapped with a Semaphore(100) in a gateway class. Blocking on semaphore.acquire() is cheap with virtual threads.
Database — tune HikariCP maximumPoolSize to match DB capacity.
CPU‑bound work — explicitly limit to core count (e.g., 8 concurrent tasks on 8 cores).
Example logistics gateway:
@Component
public class LogisticsGateway {
private final Semaphore semaphore = new Semaphore(100);
private final LogisticsClient client;
public LogisticsInfo query(Long orderId) {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
return client.query(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("物流查询被中断", e);
} finally {
if (acquired) semaphore.release();
}
}
}When Not to Use Virtual Threads
A pure CPU‑bound loop (e.g., summing 10 billion iterations) gains nothing from virtual threads — the same CPU cores do the work. Virtual threads shine for JDBC, HTTP, Redis, file I/O, RPC, lock waiting — tasks that spend most time parked.
Daemon Thread Caveat
Virtual threads are daemon threads. If the application relies on @Scheduled tasks that run only on virtual threads, the JVM may exit prematurely. The fix is to set:
spring:
main:
keep-alive: trueMindset Shift
Old mental model:
threads are scarce → pool them → parameters leak into business architecture.
New model:
tasks need a thread → give each one → limit the actual scarce resource (DB connections, HTTP quotas, CPU cores) at its source.
The author concludes that the real impact of virtual threads isn’t higher QPS but the ability to delete complex thread‑pool machinery and reason about concurrency in terms of physical resource limits.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
