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.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Virtual Threads Eliminated Thread Pools — Until MySQL Connections Ran Out

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: true

A 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: true

Mindset 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

ConcurrencySemaphoreVirtual ThreadsHikariCPResource LimitingIO-boundJava 25Spring Boot 4
LuTiao Programming
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.