Advanced · Optimize

Tune from an End-to-End Performance Budget

Measure database plans, HikariCP contention, thread pools, allocation, GC, and downstream latency in the right order.

Primary source: Spring Boot 3.5 Reference: DataSource and Connection Pools

55 min

Lesson 1 of 2

1d streak

The Architectural Question

Pool knobs get tuned before anyone measures; the real limiter is usually query plans or queueing, not thread counts.

What this guide proves

  • Profile before changing defaults
  • Bound every queue and pool
  • Optimize query count and plans before enlarging the connection pool

Database Connection Pool Sizing: The HikariCP Formula

A widespread intuition in backend development is that larger connection pools equal higher throughput. In reality, oversized database connection pools cause disk thrashing, context switching overhead, and resource starvation on the PostgreSQL server.

The PostgreSQL project and HikariCP documentation offer an empirical starting hypothesis for maximum throughput:

HikariCP Empirical Baseline
pool_size = ((core_count * 2) + effective_spindle_count)

Example for a 4-core database server:
pool_size = ((4 * 2) + 1) = 9 to 10 connections!

Historically, "effective_spindle_count" represented physical spinning disks that could seek concurrently without head contention. On modern high-IOPS NVMe SSDs or managed cloud instances (AWS RDS, Aurora), disk seek mechanics are replaced by CPU core saturation, lock contention, and PostgreSQL connection memory overhead (~2–10MB per backend process). Treat this formula as an empirical starting baseline (typically 10–20 connections per service instance) rather than an immutable law, and tune under realistic production load while monitoring connection acquire times.

src/main/resources/application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 15
      minimum-idle: 10
      connection-timeout: 3000
      max-lifetime: 1800000
      idle-timeout: 600000
      leak-detection-threshold: 2000

leak-detection-threshold Catching Connection Leaks

Setting leak-detection-threshold: 2000 instructs HikariCP to log a stack trace warning whenever a thread holds a connection for longer than 2 seconds without returning it to the pool, pinpointing unclosed transactions or slow external calls.

Tomcat Worker Threads vs Java 21 Virtual Threads

Traditional Spring MVC assigns one OS platform thread per incoming HTTP request. Default Tomcat allocates up to 200 worker threads (server.tomcat.threads.max=200). Each OS thread consumes ~1MB of stack memory, and 200 concurrent blocked requests exhaust the pool.

With Spring Boot 3.5 on Java 21 LTS, enabling spring.threads.virtual.enabled=true switches Tomcat to spawn a lightweight Virtual Thread for every incoming request. Virtual threads unmount from carrier threads during blocking I/O (database queries, REST calls, Redis reads), allowing tens of thousands of concurrent requests with negligible memory overhead.

Beware Thread Pinning with Synchronized Blocks

In Java 21, executing a blocking I/O operation inside a legacy synchronized block or native method "pins" the virtual thread to its underlying OS carrier thread, preventing it from unmounting. Modernize synchronized blocks to java.util.concurrent.locks.ReentrantLock.

Container Memory Sizing and Generational ZGC

Avoid uncalibrated static -Xmx heap values in containerized environments. If container limits in Kubernetes Pod manifests are modified without updating a static -Xmx, or if -Xmx is set too close to the cgroup memory limit, the Linux kernel terminates the container via OOMKilled (Exit Code 137).

A common mistake is assuming that -XX:MaxRAMPercentage=75.0 guarantees safe headroom. Total JVM memory footprint is the sum of Heap PLUS substantial Off-Heap allocations:

Off-Heap ComponentTypical Memory DemandSizing / Tuning Boundary
Thread Stacks (Xss)The configured stack reservation is not the same as committed memory; actual usage depends on thread count and stack behavior.Virtual threads can reduce per-task stack overhead for blocking workloads, but memory and pinning still need measurement.
Metaspace100MB–250MB for enterprise Spring Boot apps (class definitions, proxies, lambdas).Bound with -XX:MaxMetaspaceSize=384m to prevent runaway classloader memory leaks.
Direct Memory & BuffersNetty and NIO off-heap byte buffers for network I/O and HTTP request bodies.Defaults to max heap size unless capped via -XX:MaxDirectMemorySize=256m.
JVM Internals & Native Code100MB–200MB (JIT code cache, GC card tables, C-runtime glibc arena fragmentation).Native allocations exist outside JVM garbage collection; monitor via Linux cgroup usage.

A heap percentage is only a starting point. Measure heap, metaspace, native memory, direct buffers, thread stacks, and the container limit under the target workload; leave headroom for the application and runtime rather than applying a universal 50% or 75% rule.

Java 21 supports generational ZGC with the teaching command -XX:+UseZGC -XX:+ZGenerational; these flags select a collector, not a latency guarantee. Pause time and throughput depend on allocation rate, heap size, hardware, and workload, so benchmark the selected collector with the same traffic and memory limits used in deployment.

Measuring Pool Saturation and p99 Latency

By default, Spring Boot Actuator Micrometer timers export only count, total time, and maximum latency. To measure percentiles like p95 and p99 for HikariCP connection acquisition, explicitly enable percentile histograms in configuration:

src/main/resources/application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    distribution:
      percentiles:
        hikaricp.connections.acquire: 0.5, 0.95, 0.99
      percentiles-histogram:
        hikaricp.connections.acquire: true

Once enabled, drive concurrent load and measure pool saturation and connection acquisition latency:

snippet.bash
# 1. Drive 5,000 total requests using 200 concurrent worker connections
hey -n 5000 -c 200 http://localhost:8080/api/catalog/products

# 2. Inspect active vs idle connection count
curl -s http://localhost:8080/actuator/metrics/hikaricp.connections.active
# Verify active connections <= maximum-pool-size without thread starvation

# 3. Inspect the diagnostic meter; use an external metrics backend for percentile queries
curl -s http://localhost:8080/actuator/metrics/hikaricp.connections.acquire
# Inspect measurements and availableTags; do not invent a percentile tag

Active Recall Checkpoint

Why should p99 be queried from the metrics backend rather than assumed from the Actuator metrics endpoint?

Hint: Use the endpoint to inspect the meter, then use the external backend for percentile aggregation.

Show answer & explanation

The Actuator metrics endpoint is diagnostic and its tag filters select real meter tags; phi is not a universal percentile tag. Enable the required histogram or percentile settings, then query the exported metric in Prometheus or another metrics backend using the backend query language.

Runtime Architecture & Flow

Spring MVC · runtime visualization

Tune from an End-to-End Performance Budget

Dispatcher

Profile before changing defaults

Binding

Bound every queue and pool

Use case

Optimize query count and plans before enlarging the connection pool

Response

Load-test checkout, identify the limiting resource, change one variable, and compare evidence.

Working Implementation

Code Walkthrough

Load-test checkout, identify the limiting resource, change one variable, and compare evidence.

ResourcePoolsLab.java
package com.example.perf;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import javax.sql.DataSource;

public final class ResourcePoolsLab {

    private ResourcePoolsLab() {}

    // PostgreSQL sizing formula: (core_count * 2) + effective_spindle_count
    public static int calculateOptimalPoolSize(int cpuCores, boolean isSsd) {
        int spindleCount = isSsd ? 1 : 2;
        return (cpuCores * 2) + spindleCount;
    }

    public static DataSource buildHikariDataSource(String url, String user, String pass, int poolSize) {
        var config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setUsername(user);
        config.setPassword(pass);
        config.setMaximumPoolSize(poolSize);
        config.setMinimumIdle(poolSize);
        config.setConnectionTimeout(3000); // 3 second timeout prevents thread stalls
        config.setLeakDetectionThreshold(2000); // 2 second leak detection logs warning stack trace
        config.setMaxLifetime(1800000); // 30 minutes prevents stale connection drops
        return new HikariDataSource(config);
    }
}
Empirical connection pool formula prevents database disk thrashing and CPU context switching.
leakDetectionThreshold logs the exact stack trace of unclosed connections to identify connection leaks.
Java 21 virtual threads unmount during blocking queries, scaling concurrent requests smoothly.

Synthesis & Production Verification

Production Acceptance Criteria

  • Profile before changing any default
  • Bound every queue and pool explicitly
  • Improve a query plan or index before enlarging the connection pool

Architectural & Technical Review

Core Interview Questions

Q01What is the empirical HikariCP connection pool sizing formula, and why are larger pools slower?Show answer

The formula is pool_size = ((core_count * 2) + effective_spindle_count). Allocating more connections than available CPU cores forces the database to perform costly context switching and disk thrashing, degrading throughput.

Q02How does HikariCP leakDetectionThreshold diagnose connection starvation in production?Show answer

When a connection remains checked out longer than the threshold (e.g. 2000ms), HikariCP logs a warning stack trace showing where the connection was acquired, pinpointing unclosed transactions or slow external calls.

Q03What is thread pinning in Java 21 Virtual Threads and how do you avoid it?Show answer

Thread pinning occurs when a virtual thread performs blocking I/O inside a legacy synchronized block or native method, preventing it from unmounting from its carrier thread. Replace synchronized blocks with ReentrantLock.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Setting database connection pool sizes to 100+ on small database instances — Overwhelms database CPU cores with context switching and disk thrashing, increasing p99 query latency.
  • Pitfall: Using static -Xmx flags in containerized deployments without MaxRAMPercentage — Fails to adapt to Kubernetes memory limit changes, causing silent container OOMKilled evictions.

Primary Documentation & Specifications