Virtual Threads Do Not Increase Database Capacity: The JDBC Pool Boundary

Virtual Threads Do Not Increase Database Capacity: The JDBC Pool Boundary

Why Java virtual-thread concurrency and concurrent JDBC capacity are different quantities, with HikariCP, backpressure, and queueing latency as the operational boundary.

Virtual threads make blocked I/O-heavy Java workloads much cheaper to express, but application concurrency is not the same as downstream capacity. A database connection remains a constrained resource with costs in sessions, memory, cursors, locks, I/O, and CPU.

The useful distinction is:

many virtual threads
        !=
the same number of JDBC connections
        !=
the same number of efficient concurrent SQL operations

The pool is an admission-control boundary

A request can wait cheaply on a virtual thread. Giving every request its own JDBC connection is different: it expands the amount of work admitted into the database at the same time. A pool such as HikariCP therefore acts not only as a reuse mechanism but also as an admission-control boundary.

When the pool is full, waiting virtual threads can park without consuming a platform thread. That is a reason to keep waiting inexpensive, not a reason to enlarge the scarce resource without measurement.

Boundary Observed in Practice

In long-running Java services I have used a virtual-thread-per-task execution model to organize blocking I/O without paying the same thread-management cost as a large platform-thread population. This can make waiting work cheaper, but it does not increase the number of database operations the database can safely sustain at once. The HikariCP/JDBC pool remains a separate scarce resource.

I therefore treat executor concurrency and database concurrency as different budgets. Even when the application can create many virtual threads, database access is still constrained by pool admission, transaction duration, and measured saturation behavior. Where needed, a semaphore or another explicit admission-control mechanism should protect the scarce resource; shrinking the virtual-thread population is not a substitute for that boundary.

Pool size does not follow thread count

There is no rule such as maximumPoolSize = virtualThreadCount. Sustainable pool capacity depends on database service time, SQL mix, transaction duration, I/O behavior, and latency objectives.

An oversized pool can manifest as:

  • more active database sessions,
  • longer CPU run queues,
  • storage or network queues,
  • lock contention,
  • cache pressure,
  • acceptable averages but degraded p95/p99 latency.

Throughput alone is therefore not enough. Connection acquisition time, active/idle/pending connections, transaction duration, database wait events, and tail latency should be observed together.

Place backpressure before the scarce resource

Virtual threads do not imply unbounded admission. If the database is the scarce resource, backpressure should be visible before it. The JDBC pool naturally creates such a waiting point, but the queue itself should not be allowed to grow without policy. Timeouts, bounded admission, or upstream rate limits are needed so overload can be rejected instead of converted into unbounded latency.

This also explains why increasing a pool can hurt rather than help. More queries may enter the database at once, but once the database is saturated each query can take longer. Queueing and tail latency then grow without a proportional increase in completed work.

JVM-version boundary

Virtual threads became a permanent Java feature in JDK 21. Later JVM work, including improvements around pinning associated with synchronized, changed application-level scheduling costs. Those changes do not remove the database's physical capacity boundary.

Measure instead of deriving a magic number

There is no deployment-independent optimal pool size. Short OLTP work and long analytical work have different service-time distributions and may justify separate admission policies or even separate pools.

Finding the capacity boundary experimentally

A controlled load ramp is more informative than one high-concurrency run when choosing pool size. Keep the SQL mix and data distribution stable, increase concurrency in steps, and record completed work per second, connection-acquisition p95/p99, transaction p95/p99, database CPU, and dominant wait classes at every step. The objective is not the largest concurrency number but the knee where throughput stops increasing materially while tail latency begins to deteriorate rapidly.

Two queues should be observed separately: requests waiting in front of the pool and sessions waiting inside the database for CPU, locks, or I/O. If the first grows while internal waits remain controlled, the admission boundary may be doing useful work. If enlarging the pool merely shrinks the application queue while sharply increasing database waits, load has only been moved to a more expensive queue.

Timeout budgets should also be coherent across layers. When request deadlines, connection-acquisition timeout, and statement or transaction timeout are chosen independently, the database can continue executing work that is no longer useful to the caller. Capacity is therefore not just a maximumPoolSize value; it is a contract covering admission, timeout, and cancellation behavior together.

Related material: High-Performance Java Data Systems, Connection Pool, Virtual Thread, Backpressure, Tail Latency.

References

QR code for this page