CubeAPM
CubeAPM CubeAPM

Spring Boot Performance Tuning: JVM, Connection Pool, and GC Optimization

Spring Boot Performance Tuning: JVM, Connection Pool, and GC Optimization

Table of Contents

A Spring Boot service that handles 500 requests per minute in staging can start throwing 99th-percentile latency spikes of 800ms in production not because the code changed, but because nobody tuned the JVM heap, chose a garbage collector suited to the workload, or sized the HikariCP connection pool beyond its defaults. These three areas account for the majority of avoidable performance problems in production Spring Boot deployments.

This guide covers exactly how to address each one: heap sizing, GC algorithm selection with real configuration flags, connection pool tuning using the HikariCP formula, JIT optimization, and the monitoring layer that tells you whether your changes actually worked. According to the 2024 State of Java report by New Relic, Java 17 and 21 are now the most widely deployed LTS versions in production — both support the modern GC algorithms and container-aware JVM flags covered here.

What Is Spring Boot Performance Tuning

Spring Boot performance tuning is the practice of configuring the JVM, application server, database connection pool, and application-level settings so that a Spring Boot application uses resources efficiently and responds predictably under production load.

The “auto-configuration” that makes Spring Boot fast to develop on works against you at scale. Default settings are chosen for broad compatibility, not for throughput or latency optimization. A freshly scaffolded Spring Boot app ships with:

  • No explicit heap bounds, so the JVM starts small and resizes repeatedly under load
  • G1GC enabled but unconfigured, so GC pause targets are left at defaults
  • HikariCP maximumPoolSize of 10, which saturates instantly under moderate concurrency
  • Tomcat configured for 200 threads maximum, with no keep-alive or connection tuning

Each of these defaults is reasonable for a development machine. In production, each one becomes a ceiling that the application hits before your infrastructure does.

The goal of tuning is not to squeeze every CPU cycle out of the JVM. It is to set explicit boundaries, remove the JVM’s need to make expensive runtime decisions, and create headroom that matches your actual traffic pattern.

How JVM Memory Works in Spring Boot

Before changing any flags, you need a clear picture of where memory goes inside the JVM. The JVM splits memory into distinct regions, and each region has different tuning handles.

Heap memory: where your objects live

The heap is divided into the Young Generation and the Old Generation (also called Tenured space). Short-lived objects — request-scoped beans, temporary strings, HTTP response buffers — are allocated in the Young Generation and collected during Minor GC events, which are fast. Objects that survive enough Minor GC cycles get promoted to the Old Generation and are collected far less frequently during Major or Full GC events, which are slow and can pause application threads.

The key flags:

-Xms4g -Xmx4g

Setting -Xms equal to -Xmx eliminates heap resizing. When they differ, the JVM starts with a smaller heap and expands it as demand grows. Every expansion triggers a GC pause. In a containerized environment on Kubernetes, this also means your pod’s memory usage looks unpredictable to the scheduler. Fix both problems by setting them equal from startup.

Non-heap memory: Metaspace, code cache, and thread stacks

Non-heap memory is not covered by -Xmx. It includes:

  • Metaspace: Stores class metadata. Uncapped by default in Java 8+, which means a classloader leak can silently consume host memory outside your heap budget.
  • Code Cache: Stores JIT-compiled native code. If it fills, the JVM deoptimizes hot methods back to interpreted mode and performance degrades without any obvious error.
  • Thread stacks: Each thread gets its own stack, defaulting to 512KB to 1MB on 64-bit Linux.

Always set explicit bounds:

-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:ReservedCodeCacheSize=256m
-Xss512k

Container-aware heap sizing

Running Spring Boot in Docker or Kubernetes without container-aware flags causes a well-known problem: the JVM reads total host memory, not container memory limits. A container with a 2GB limit on a 32GB host will try to allocate a heap sized for 32GB and immediately get OOM-killed.

Java 11+ handles this automatically if you pass:

-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=75.0

This allocates 75% of the container’s memory limit to the heap and leaves 25% for non-heap regions. For a Kubernetes deployment with a 4GB memory limit, that yields a 3GB heap automatically, which is generally the right starting point.

# Kubernetes deployment example
env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:+UseContainerSupport
      -XX:MaxRAMPercentage=75.0
      -XX:InitialRAMPercentage=75.0
      -XX:+UseG1GC
      -XX:MaxGCPauseMillis=200

A practical rule: container memory limit should be 1.5x to 2x your intended heap size. If you want a 4GB heap, provision a 6–8GB container.

Garbage Collection Optimization

Garbage collection is the single largest source of unpredictable latency in Spring Boot services. The right GC choice depends on your workload profile: throughput-heavy batch jobs have different requirements than latency-sensitive REST APIs.

G1GC: the right default for most Spring Boot services

G1GC (Garbage-First Garbage Collector) has been the JVM default since Java 9 and is the correct starting point for most Spring Boot applications. It divides the heap into equal-sized regions and prioritizes collection of regions with the most garbage first, which makes pause times predictable.

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=45
-XX:G1ReservePercent=10

What each flag does:

  • MaxGCPauseMillis=200: G1 targets 200ms pause times. It does not guarantee this, but it uses it as a planning target.
  • G1HeapRegionSize=16m: Larger region sizes reduce fragmentation for heaps above 4GB. Auto-calculated if omitted, but explicit is more predictable.
  • InitiatingHeapOccupancyPercent=45: Triggers concurrent marking when the heap is 45% full. Lowering this (to 25–35%) can prevent promotion failures on apps with heavy allocation rates.
  • G1ReservePercent=10: Keeps 10% of heap in reserve to handle sudden promotion demand.

A signal that G1GC is under stress: watch for To-space exhausted messages in GC logs. This means G1 ran out of space to evacuate objects to and triggered a Full GC, which stops the world completely.

ZGC: for latency-critical services on Java 17+

ZGC achieves sub-millisecond pause times by doing most of its work concurrently with application threads. Pause times stay under 1ms regardless of heap size. On Java 21, Generational ZGC adds a young/old generation split that improves throughput significantly:

-XX:+UseZGC
-XX:+ZGenerational
-XX:SoftMaxHeapSize=4g
-Xms6g -Xmx6g

The tradeoff: ZGC requires 15–25% memory headroom above SoftMaxHeapSize to operate its concurrent phases. For a 4GB working heap, allocate a 6GB container. ZGC also has slightly lower throughput than G1GC due to concurrent marking overhead. For a REST API where P99 latency SLOs are tight, ZGC is worth the tradeoff. For a batch processing job where throughput matters more, G1GC is a better fit.

Shenandoah GC: an alternative low-pause option

Shenandoah is OpenJDK’s other low-pause collector. It uses a different concurrent compaction approach from ZGC and performs similarly in sub-millisecond pause territory:

-XX:+UseShenandoahGC
-XX:ShenandoahGCHeuristics=adaptive
-Xms4g -Xmx4g

The adaptive heuristic lets Shenandoah choose its own collection timing based on observed allocation rates. This is the safest starting mode. Use Shenandoah when ZGC is not available on your OpenJDK distribution or when you want to A/B test low-pause collectors on the same heap budget.

GC selector: choosing by workload

WorkloadRecommended GCKey reason
General REST APIs, microservicesG1GCBalanced throughput and latency
Latency-sensitive APIs, P99 < 50msZGC (Generational on Java 21)Sub-millisecond pauses
High-throughput batch, ETLG1GC with large heapThroughput priority, pauses acceptable
Memory-constrained containersShenandoahCompetitive pause times, lower overhead

Enable GC logging — always

GC logs are the only way to know whether your tuning is working. Without them, you are guessing:

-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m

This rotates through 5 log files at 50MB each. Feed these logs into GCEasy or GCViewer to get pause time distributions, throughput percentages, and promotion failure counts. Most GC problems become obvious within minutes of reading a GC log with these tools.

HikariCP Connection Pool Tuning

The connection pool is the most commonly mis-configured component in Spring Boot services that interact with a relational database. HikariCP is the default in Spring Boot 2.x and 3.x, and its defaults are conservative by design.

The pool sizing formula

HikariCP’s documentation and the widely-cited work by database consultant Brent Ozar point to the same core principle: more connections is not better. Database connections are expensive server-side resources, and too many simultaneously active connections cause lock contention that is worse than queuing at the pool level.

The formula for sizing a connection pool:

connections = (core_count * 2) + effective_spindle_count

For a service running on a 4-core host talking to an SSD-backed PostgreSQL instance (effective spindle count ≈ 1):

connections = (4 * 2) + 1 = 9

Round up to 10 for safety. For a 16-core host: (16 * 2) + 1 = 33. In most Spring Boot microservice deployments where pods have 2–4 vCPUs, a pool size of 10–20 is appropriate. Going beyond 20 on a 4-core pod almost always degrades rather than improves throughput.

Full HikariCP configuration

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000       # 30 seconds to acquire a connection
      idle-timeout: 600000            # 10 minutes before idle connection is closed
      max-lifetime: 1800000           # 30 minutes max connection age
      leak-detection-threshold: 60000 # warn if connection held > 60 seconds
      auto-commit: true
      pool-name: AppServicePool
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        prepStmtCacheSqlLimit: 2048
        useServerPrepStmts: true

The settings that matter most in production:

`connection-timeout: 30000`: If a connection cannot be obtained from the pool within 30 seconds, HikariCP throws a SQLTimeoutException. Without this, threads queue indefinitely and your service appears to hang with no obvious error.

`max-lifetime: 1800000`: Forces connections to be recycled every 30 minutes. This prevents stale connections caused by database-side timeouts, firewall idle-connection drops, and AWS RDS maintenance events. Set this to less than your database’s wait_timeout setting.

`leak-detection-threshold: 60000`: HikariCP logs a warning if a connection is held for more than 60 seconds without being returned to the pool. This catches missing try-with-resources blocks and N+1 query problems that hold connections through multiple round-trips.

`cachePrepStmts: true`: Enables prepared statement caching on the MySQL or PostgreSQL JDBC driver side. This eliminates repeated parse-and-plan cycles for the same SQL and can cut query execution time by 20–40% for OLTP workloads with repetitive queries.

Common pool misconfiguration that causes production incidents

A pattern that appears repeatedly in production postmortems: minimum-idle is set equal to maximum-pool-size, which disables HikariCP’s idle connection pruning entirely. The pool holds the maximum number of connections open permanently, even at 3am when traffic is zero. On shared database servers with connection limits, this starves other services. Keep minimum-idle at 20–25% of maximum-pool-size.

Another common mistake: setting maximum-pool-size to a very high number (100+) because threads are timing out. Timeouts caused by a slow database query do not get better with more connections — they get worse because now 100 threads are all hitting the same slow query simultaneously. Diagnose the query before touching the pool size.

JVM and Application-Level Best Practices

JIT compiler tuning

The Just-In-Time compiler converts frequently executed bytecode into native machine code. Tiered compilation (enabled by default in Java 8+) moves methods through interpretation to profiling to full optimization automatically:

-XX:+TieredCompilation
-XX:TieredStopAtLevel=4
-XX:ReservedCodeCacheSize=256m
-XX:InitialCodeCacheSize=64m
-XX:+UseCodeCacheFlushing

For services that have a noticeable warmup period before reaching full throughput, Class Data Sharing can reduce startup time by 30–60%:

# Step 1: generate the archive during a warmup run
java -XX:ArchiveClassesAtExit=app-cds.jsa -jar application.jar
# Step 2: use the archive in production
java -Xshare:on -XX:SharedArchiveFile=app-cds.jsa -jar application.jar

This is particularly valuable for serverless or auto-scaling scenarios where pods start and stop frequently. Lambda-based deployments face a related cold-start problem — teams doing AWS Lambda monitoring with Spring Boot often find that CDS or GraalVM native images are worth the build complexity.

Tomcat thread pool and connector tuning

server:
  tomcat:
    threads:
      max: 200
      min-spare: 20
    max-connections: 10000
    accept-count: 100
    connection-timeout: 20000
    keep-alive-timeout: 30000
    max-keep-alive-requests: 100

`max: 200`: The upper thread limit. Above 200 threads, context-switching overhead typically dominates any throughput gain. For I/O-bound services talking to slow external APIs, increasing to 400 can help — but profile first.

`accept-count: 100`: Requests that arrive when all threads are busy wait in this queue. Once the queue fills, Tomcat refuses new connections with a connection refused error. If you hit this limit in production, the solution is usually async processing or a circuit breaker, not a larger queue.

`keep-alive-timeout: 30000`: Keeps HTTP/1.1 connections alive for 30 seconds between requests. For services behind a load balancer that sends keep-alive traffic, this reduces TCP handshake overhead significantly.

Lazy initialization and startup optimization

Spring Boot’s default eager initialization creates every singleton bean at startup. For large applications with dozens of datasources, caches, and third-party SDK clients, this adds 2–5 seconds of startup time that hurts rolling deployments and pod readiness.

Enable application-wide lazy initialization:

spring.main.lazy-initialization=true

The tradeoff: the first request to hit an uninitialized bean will be slower. For most microservices, this is acceptable. For services where the first request must be fast (think session handlers or authentication services), use bean-level @Lazy annotations selectively instead of the global flag.

Async processing with a configured thread pool

Do not use Spring’s default SimpleAsyncTaskExecutor for @Async methods. It creates a new thread for every invocation, which defeats the purpose of async processing under any real load:

@Configuration
public class AsyncConfig {
    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        int cores = Runtime.getRuntime().availableProcessors();
        executor.setCorePoolSize(cores * 2);      // I/O-bound default
        executor.setMaxPoolSize(cores * 4);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
        executor.initialize();
        return executor;
    }
}

For CPU-bound tasks, use cores + 1 for corePoolSize. For I/O-bound tasks (external API calls, file I/O, email sending), use cores * 2 or higher, because threads spend most of their time waiting and can share the CPU effectively.

Dependency and auto-configuration cleanup

Spring Boot’s classpath scanner reads every .class file and JAR in your application to find @Component, @Service, @Repository, and @Controller annotations. Every unnecessary dependency you keep in your pom.xml or build.gradle adds to this scan. Run mvn dependency:analyze to identify declared dependencies that are not actually used at compile time.

Disable specific auto-configurations you know you do not need:

@SpringBootApplication(exclude = {
    DataSourceAutoConfiguration.class,
    SecurityAutoConfiguration.class
})
public class MyApplication {}

Or via application.properties:

spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

Tools and Implementation

Performance tuning without measurement is guesswork. The flags and configurations above will change your application’s behavior but whether they improve the metrics that matter to your users requires a monitoring layer that captures JVM internals, request traces, and database query performance together.

JVM diagnostic tools

jcmd: The Swiss Army knife of JVM diagnostics. Use it to trigger GC runs, capture heap histograms, and dump thread states without restarting:

jcmd <pid> GC.run
jcmd <pid> VM.native_memory
jcmd <pid> Thread.print

jstat: Streams GC statistics in real time. Useful for watching heap utilization during load tests:

jstat -gcutil <pid> 1000   # print GC stats every 1 second

jmap: Captures heap dumps for offline analysis with Eclipse MAT or VisualVM:

jmap -dump:live,format=b,file=heapdump.hprof <pid>

JVisualVM / JConsole: GUI tools for watching heap usage, GC activity, thread counts, and loaded class counts in real time. Useful for development-time profiling against a representative load test.

Actuator endpoints for production visibility

Spring Boot Actuator exposes JVM metrics through a set of HTTP endpoints. Enable the relevant ones in application.yml:

management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
  metrics:
    tags:
      application: ${spring.application.name}
    enable:
      jvm: true
      hikaricp: true

The /actuator/metrics/jvm.gc.pause endpoint shows GC pause duration distributions. The /actuator/metrics/hikaricp.connections.active endpoint shows live pool utilization. These two alone give you most of what you need to validate tuning changes.

APM platforms for continuous production monitoring

Command-line tools and Actuator endpoints are diagnostic tools — you pull data from them when you suspect a problem. Production monitoring requires a continuous signal that captures baselines, detects regressions, and correlates JVM behavior with request-level performance.

CubeAPM surfaces JVM runtime metrics — heap usage, GC pause frequency and duration, thread counts, and HikariCP pool metrics — alongside distributed traces and logs in a single view. Because it runs inside your own cloud (on-prem or BYOC), telemetry never leaves your infrastructure, which matters for teams with data residency requirements. It connects via OpenTelemetry, so no proprietary agent is required. Pricing is usage-based at $0.15/GB ingested with no per-seat charges — for a mid-size team ingesting 30TB/month across logs, traces, and metrics, that works out to approximately $4,500/month with unlimited retention and no egress fees.

Teams that have moved from SaaS APM tools to CubeAPM report substantial cost reductions alongside faster incident resolution. Delhivery documented 75% savings after replacing three separate monitoring tools, and redBus (part of NASDAQ-listed MakeMyTrip) reported 4x faster dashboards and 50% faster MTTR. If you are evaluating whether a self-hosted, usage-based APM fits your current stack, the comparison of CubeAPM as a Datadog alternative breaks down exactly where each model wins.

For teams currently on New Relic evaluating whether the seat-based pricing justifies the cost, the CubeAPM vs New Relic analysis covers per-seat cost modeling against ingestion-based pricing at different team sizes.

Prometheus + Grafana: The open source alternative. Prometheus scrapes Actuator’s /actuator/prometheus endpoint, and Grafana provides dashboards. Effective for JVM and infrastructure metrics but requires more setup to correlate metrics with traces, and the operational burden of managing the monitoring stack yourself is real at production scale.

GCEasy and GCViewer: Offline GC log analyzers. Upload your GC log file and get pause time histograms, throughput calculations, and specific recommendations. Useful for post-incident analysis or validating a GC configuration change after a load test.

Pricing based on publicly available information as of June 2026. Verify current rates directly with each vendor before purchasing.

A Practical Tuning Sequence

One insight that rarely appears in Spring Boot tuning guides: most teams try to tune everything at once, then cannot tell which change caused which improvement. A sequenced approach isolates variables and produces measurable before-and-after comparisons.

Week 1 — Establish a baseline. Before touching any flag, run a representative load test and capture: P50/P95/P99 latency, GC pause frequency and duration (from GC logs), HikariCP active connections at peak, and thread pool queue depth from Actuator.

Week 2 — Fix heap sizing and GC. Set -Xms equal to -Xmx, add UseContainerSupport if containerized, choose a GC algorithm, and enable GC logging. Run the same load test and compare pause distributions.

Week 3 — Tune the connection pool. Apply the (cores * 2) + spindle_count formula, set max-lifetime and leak-detection-threshold, enable prepared statement caching. Run the load test again and check P99 latency and database-side connection counts.

Week 4 — Application-level changes. Enable lazy initialization selectively, configure async thread pools, remove unused dependencies. Re-run and compare the full baseline.

This sequence makes it obvious which layer had the most impact for your specific workload, which is the only answer that matters.

This tuning sequence is illustrative. Timelines depend on team bandwidth, application complexity, and how easily you can run representative load tests against production-equivalent infrastructure. Adjust accordingly.

Conclusion

Spring Boot performance tuning comes down to three levers that compound each other: a correctly sized JVM heap that avoids runtime resizing, a GC algorithm chosen for your latency and throughput profile, and a connection pool sized to match your database’s actual capacity rather than an arbitrary large number. Most production performance problems trace back to default settings that were never challenged, not to architectural flaws that require rewrites. Apply the configurations in this guide, measure before and after with GC logs and Actuator metrics, and keep a continuous monitoring layer running so you catch regressions before they become incidents.

Disclaimer: The information in this article reflects the latest details available at the time of publication and may change as technologies and products evolve. Features, pricing, and plan limits can change over time. Always verify the latest information directly with the vendor before making purchasing or deployment decisions.

Frequently Asked Questions

What is the most impactful JVM flag to set for a Spring Boot service in production?

Setting `-Xms` equal to `-Xmx` is the single highest-impact change for most services. When they differ, the JVM resizes the heap dynamically under load, triggering GC pauses at the exact moment your application needs throughput most. Fixing heap bounds eliminates an entire category of latency spikes with one flag.

How do I choose between G1GC and ZGC for my Spring Boot application?

Use G1GC as your default. It provides predictable pause times under 200ms and balances throughput and latency well for general REST APIs. Switch to ZGC (Generational ZGC on Java 21) if your service has strict P99 latency SLOs below 50ms, or if you observe G1GC pause durations exceeding your SLA thresholds under production load.

What is the right HikariCP pool size for a Spring Boot microservice?

Start with the formula: `(vCPU_count * 2) + 1`. For a pod with 2 vCPUs, that gives a pool size of 5. For 4 vCPUs, 9 to 10. Increasing pool size beyond this formula typically degrades performance because database-side lock contention grows faster than throughput. Diagnose slow queries before adjusting pool size.

Why does my Spring Boot service have high latency only during business hours?

This is usually a GC promotion failure caused by heap exhaustion during peak allocation. Check GC logs for `G1 Evacuation Pause` events or `To-space exhausted` messages clustering around your peak traffic window. Reduce `InitiatingHeapOccupancyPercent` from the default 45 to 25 to trigger concurrent marking earlier, giving G1 more headroom before evacuation is forced.

How does lazy initialization affect production performance?

Enabling `spring.main.lazy-initialization=true` reduces startup time by deferring bean creation until the first access. The cost is that the first request to a given code path after startup will be slower because it initializes beans on demand. For rolling deployments where pods come online under load balancer traffic, this can cause the first few requests to the new pod to be noticeably slower than steady-state. Use bean-level `@Lazy` annotations on heavy clients rather than the global flag if this is a concern.

Should I use GraalVM native image for Spring Boot performance?

GraalVM native images reduce startup time from seconds to milliseconds and cut memory consumption significantly, which makes them attractive for serverless and auto-scaling workloads. The tradeoff is that native compilation removes JIT optimization, so peak throughput for long-running services is typically lower than a tuned JVM. Native images also require all reflection and dynamic class loading to be declared at build time, which adds complexity. For always-on services, a well-tuned JVM usually outperforms a native image at steady-state throughput.

How do I monitor HikariCP pool utilization in production?

Enable Spring Boot Actuator and expose the Prometheus endpoint. HikariCP publishes pool metrics automatically when Actuator is on the classpath. Query `hikaricp.connections.active`, `hikaricp.connections.pending`, and `hikaricp.connections.timeout.total`. If active connections regularly approach your pool maximum, either increase pool size (after verifying the database can handle it) or investigate whether a slow query is holding connections longer than expected using `leak-detection-threshold`.

×
×