Azul Statement on the eMudhra Intermediate CA Certificates Compromise
java-performance-bg

JVM Performance Tuning

Smart Summary

JVM performance tuning follows a practical workflow: measure first, then tune heap size, garbage collector, JIT behavior, and threading. This guide covers flags for G1GC, ZGC, and Shenandoah, code cache and warmup considerations, virtual threads in Java 21, and framework-specific guidance for Spring Boot, Quarkus, and Micronaut, along with a table of common problems and fixes. It also explains when to go beyond manual tuning with Azul Prime’s self-tuning approach.

Java JVM performance tuning, or performance tuning JVM, covers everything from foundational concepts to production-ready configurations. JVM performance tuning involves configuring heap size, GC algorithm, JIT settings, and thread behavior to match your workload’s latency and throughput requirements. Done well, tuning transforms an application that misses SLAs into one that runs reliably at scale; done poorly, it introduces unpredictable regressions that are hard to diagnose.

JVM performance tuning requires a practical workflow: measure, then tune heap, then GC, then JIT, then threads. We cover flags for G1GC, ZGC, and Shenandoah; JIT and code cache tuning; virtual threads in Java 21; framework-specific guidance for Spring Boot, Quarkus, and Micronaut; and a table of common JVM performance problems with diagnostic steps and fixes. For teams beyond manual tuning, we cover Azul Prime’s self-tuning approach.

What Affects JVM Performance?

JVM performance is determined by six primary factors, each with different tuning levers:

  • Heap sizing: Too small causes frequent GC; too large causes long GC pauses and OS memory pressure. Heap sizing sets the ceiling for GC frequency and duration
  • GC algorithm: The choice of G1GC, ZGC, Shenandoah, or Serial/Parallel GC determines the pause time profile, throughput overhead, and heap scalability characteristics
  • JIT compilation: The JVM’s Just-In-Time compiler converts bytecode to native code at runtime. JIT decisions — which methods to compile, at what tier, with what optimizations — affect both startup latency and steady-state throughput
  • Thread contention: Lock contention, false sharing on CPU cache lines, and thread pool sizing all limit throughput under concurrent load. In Java 21+, virtual threads change the calculus for I/O-bound workloads
  • I/O and off-heap operations: NIO, network I/O, and direct memory (ByteBuffer.allocateDirect) all interact with GC. Off-heap objects escape GC scope but add complexity to memory management
  • Class loading: Large dependency trees, OSGi, or dynamic classloading can cause metaspace pressure and class loader contention that manifests as CPU overhead during application startup and redeployment

The critical principle: don’t tune without first measuring. Many JVM performance problems are not where engineers assume they are. GC overhead is often blamed when the real bottleneck is thread contention or I/O wait time.

Step 1: Measure Before You Tune

Establish a performance baseline before changing any JVM flags. The right tools for this are:

GC Logs: -Xlog:gc*:file=gc.log:time,uptime,level,tags — captures every GC event with timestamps and durations. Essential for diagnosing pause frequency and duration.

Java Flight Recorder (JFR): -XX:StartFlightRecording=duration=60s,filename=recording.jfr — continuous profiling with minimal overhead (<2%). Captures GC, JIT, thread state, allocation hotspots, and lock contention. Analyze with JDK Mission Control.

jstat: jstat -gcutil <pid> 1000 — live GC utilization metrics every second. Quick sanity check without a full JFR recording.

jcmd: jcmd <pid> VM.native_memory — native memory breakdown including heap, metaspace, code cache, thread stacks, and GC internal structures.

Key metrics to capture in your baseline: p50/p95/p99/p999 GC pause times, GC overhead (% CPU time), allocation rate (MB/s), promotion rate (object promotion from young to old gen), and metaspace usage. These numbers drive every subsequent tuning decision.

Step 2: Heap Sizing

Heap sizing is the most impactful single tuning decision. Set -Xms and -Xmx to the same value to prevent heap resizing (resizing incurs a full GC in older JVMs and wastes CPU in all). Size the heap to accommodate your application’s working set plus GC headroom.

A practical rule of thumb: the live data set (objects that survive a full GC cycle) should occupy no more than 40–50% of the heap. For example, if your application’s live set is 4GB after a full GC, set -Xmx to at least 8–10GB. This provides sufficient headroom for allocation between GC cycles without triggering excessive collection frequency.

Common heap sizing flags:

  • -Xms<size> / -Xmx<size> — initial and maximum heap (set equal; e.g., -Xms8g -Xmx8g)
  • -XX:MetaspaceSize=256m — initial metaspace size; controls how soon metaspace GC triggers
  • -XX:MaxMetaspaceSize=512m — cap metaspace to prevent unbounded growth from class leaks
  • -XX:+HeapDumpOnOutOfMemoryError — dump heap on OOM for post-mortem analysis (recommended for production)

Avoid the common mistake of setting heap too large. A 64GB heap on an 8-core machine gives G1GC a massive old generation to scan, leading to hours-long full GC pauses in worst cases. Match heap to available memory after OS and other JVM overhead (code cache, metaspace, off-heap, thread stacks).

Step 3: Choose and Configure Your GC

GC algorithm selection should follow your latency profile. Here are the three primary options for modern JVMs:

G1GC (recommended default) — Use for general-purpose workloads where pause times of 50–200ms are acceptable. G1GC is the default since Java 9 and offers a strong throughput/latency balance for most applications.

  • -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m
  • -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=40 for stable allocation rates
  • -XX:ConcGCThreads=4 (set to ~1/4 of available cores)

ZGC (low latency) — Use when pause times must be consistently below 10ms. Production-ready since Java 15; Generational ZGC available in Java 21.

  • -XX:+UseZGC (Java 15+); add -XX:+ZGenerational on Java 21+
  • -Xlog:gc*:file=gc.log — monitor for allocation stalls under load
  • Size heap with 20-25% extra headroom vs. G1GC for the same workload

Shenandoah (Red Hat) — Concurrent low-latency GC available in Red Hat OpenJDK builds. Similar pause profile to ZGC. Use with -XX:+UseShenandoahGC.

  • -XX:+UseShenandoahGC -XX:ShenandoahGCMode=adaptive (default, recommended)
  • -XX:ShenandoahPacingMaxDelay=10 — max pacing delay in ms for allocation throttling

Avoid mixing GC algorithm flags (e.g., -XX:+UseG1GC with ZGC options). Validate the active GC by checking startup logs or running: jcmd <pid> VM.flags | grep GC

Step 4: JIT and Code Cache Tuning

The JVM’s JIT compiler operates in tiers: interpreted mode (tier 0), C1 compiled (tiers 1–3), and C2 compiled (tier 4). Tiered compilation (-XX:+TieredCompilation, on by default since Java 8) allows methods to be quickly compiled at C1 then recompiled at C2 once enough profiling data exists. This provides fast startup with good steady-state performance.

Code Cache: The code cache stores compiled native code. If it fills up, the JVM stops JIT compilation and falls back to interpreted mode — a severe throughput regression. Default is 240–256MB in Java 11+, which is usually sufficient, but memory-intensive applications with large numbers of compiled methods may need more.

  • -XX:ReservedCodeCacheSize=512m — increase code cache (Java 11+ default is 240MB)
  • -XX:InitialCodeCacheSize=64m — initial code cache allocation
  • -XX:+UseCodeCacheFlushing — enable eviction of old compiled code when cache is full

Warmup Considerations: C2 compilation is triggered after a method has been invoked enough times (controlled by -XX:CompileThreshold, default 10,000 for C2). During warmup, the application runs at reduced throughput. Options include:

  • Preloading requests via a health check warm-up phase in deployment pipelines
  • Java 17+ AOT compilation with Class Data Sharing (AppCDS): java -Xshare:dump
  • GraalVM native image for ahead-of-time compilation (trade-off: no JIT re-optimization at runtime)
  • Azul Prime ReadyNow!: caches JIT compilation profiles across restarts, eliminating warmup without AOT trade-offs

Step 5: Thread and Concurrency Tuning

Thread-related performance problems manifest as CPU saturation, high context-switch rates, or lock contention visible in JFR thread dumps. Key tuning areas:

Thread Pool Sizing: Application thread pools (HTTP server threads, executor services) should be sized to keep CPU busy without over-subscribing. A starting formula for CPU-bound work: threads = CPU cores. For I/O-bound work: threads = CPU cores * (1 + wait_time / service_time).

  • -XX:ParallelGCThreads=n — threads for STW GC phases (default: min(8, CPU_count/2))
  • -XX:ConcGCThreads=n — concurrent GC threads (default: ParallelGCThreads/4)

Virtual Threads (Java 21+): Project Loom’s virtual threads fundamentally change I/O-bound thread management. Virtual threads are lightweight (few KB of memory vs. ~1MB for platform threads) and allow millions of concurrent threads. For blocking I/O workloads, switching to virtual thread executors can eliminate thread pool sizing as a bottleneck:

  • ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();
  • Spring Boot 3.2+: spring.threads.virtual.enabled=true in application.properties
  • Quarkus: quarkus.vertx.prefer-native-transport=true and virtual thread integration via @RunOnVirtualThread

ForkJoinPool: The common ForkJoinPool (used by parallel streams and CompletableFuture.supplyAsync()) defaults to CPU count – 1 threads. Override with:

  • -Djava.util.concurrent.ForkJoinPool.common.parallelism=<n>

JVM Tuning for Java Applications

This section targets the “Java JVM performance tuning” angle: framework-specific configuration that goes beyond generic JVM flags.

Spring Boot: Spring Boot applications at startup load a large number of classes and beans, making JIT warmup and metaspace sizing critical.

  • -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m — prevent early metaspace GC during startup
  • spring.jvm.options=-Xms2g -Xmx2g — containerized Spring Boot should set both equal
  • Enable AppCDS for faster startup: spring-boot:build-image with CDS layer support in Spring Boot 3.3+
  • Spring Boot Actuator /actuator/metrics exposes JVM metrics via Micrometer — integrate with Prometheus + Grafana

Quarkus: Quarkus’s build-time optimization framework reduces runtime class loading, making JIT profile smaller and warmup faster.

  • Quarkus native mode (GraalVM): near-zero startup, low memory, no JIT re-optimization
  • JVM mode: use -XX:+UseZGC for low-latency REST APIs; Quarkus’s slim classpath reduces warmup time
  • quarkus.native.additional-build-args for GraalVM flags in native mode

Micronaut: Micronaut performs dependency injection and AOP at compile time, reducing runtime reflection and class loading overhead.

  • Smaller metaspace footprint than Spring Boot — set -XX:MaxMetaspaceSize=256m as a reasonable cap
  • Micronaut Launch generates Docker images with sensible JVM defaults; review and customize for production

Common JVM Performance Problems and Fixes

The following table covers the most frequently encountered JVM performance issues, their observable symptoms, and recommended remediation steps:

Problem Symptom Fix
Frequent full GC (G1GC) Long STW pauses (seconds), CPU spike, GC log shows “Full GC” Increase -Xmx; check for humongous allocation; consider ZGC if heap > 16GB
High allocation rate GC too frequent; short minor GC intervals in logs Profile allocation hotspots with JFR; reduce object creation; use object pools for hot paths
Metaspace exhaustion java.lang.OutOfMemoryError: Metaspace Set -XX:MaxMetaspaceSize; check for class loader leaks with heap dump analysis
JIT warmup latency High p99 latency for first few minutes after restart Use AppCDS class data sharing; consider Azul Prime ReadyNow!; implement warm-up requests in deployment
Code cache full Deoptimization in logs; throughput drop Increase -XX:ReservedCodeCacheSize to 512m or more; enable -XX:+UseCodeCacheFlushing
Thread contention High CPU with low throughput; lock contention in JFR Analyze lock profiles in JFR; reduce synchronized scope; consider virtual threads for I/O
Off-heap memory leak RSS grows without heap growth; eventual OOM from OS Audit ByteBuffer.allocateDirect calls; check native library memory; use NativeMemoryTracking
GC allocation stalls (ZGC) Latency spikes visible in ZGC logs as “Allocation Stall” Increase heap headroom; increase -XX:ConcGCThreads; check allocation rate spikes

Beyond Manual Tuning: Azul Prime

Manual JVM tuning is a time-consuming, expertise-intensive process. Every flag change requires a new baseline measurement, a controlled experiment, and careful interpretation of results. In production environments with variable load patterns, static flag configurations often fail to adapt — a configuration that works well at 60% load may produce unacceptable pauses at 95% load.

Azul Prime takes a different approach.Zing Its C4 garbage collector is self-tuning: it adapts concurrency levels, GC pacing, and heap region management automatically based on observed allocation rate and heap occupancy. Most applications running on Azul Prime need only specify -Xms and -Xmx; the GC handles the rest without requiring MaxGCPauseMillis tuning, region size adjustment, or ConcGCThreads configuration.

Azul Prime also includes the Falcon JIT compiler — LLVM-based, with more aggressive optimization passes than HotSpot C2 — and ReadyNow!, which caches JIT compilation profiles across JVM restarts. ReadyNow! eliminates the JIT warmup window: applications start at peak compiled performance rather than gradually warming up over minutes. For services with frequent restarts (rolling deployments, auto-scaling) or strict startup SLAs, ReadyNow! is a significant operational advantage.

The practical implication: teams running Azul Prime spend less engineering time on GC tuning and more time on application logic. For organizations where GC-related incidents are a recurring operational burden, the total cost of ownership often favors Azul Prime over continued manual tuning of open-source JVM configurations.

Frequently Asked Questions

What is JVM performance tuning?

JVM performance tuning is the process of configuring the Java Virtual Machine — heap size, garbage collector, JIT settings, thread counts, and runtime flags — to optimize application performance for specific workload characteristics. Tuning involves measuring baseline performance, identifying the limiting factor (GC pauses, CPU, thread contention, I/O), making a targeted configuration change, and verifying improvement with the same measurement tools.

How do I reduce JVM GC pauses?

First, identify which GC phase is causing pauses using GC logs (-Xlog:gc*). For G1GC, reduce MaxGCPauseMillis gradually (e.g., 200ms to 100ms) and check if G1 can honor the target. If full GCs are occurring, increase heap or reduce live data. If minor GC pauses are too long, reduce young gen size or allocation rate. For persistent sub-10ms latency requirements, switch to ZGC (-XX:+UseZGC) on Java 15+ or Generational ZGC on Java 21+.

What are the most important JVM flags for performance?

The highest-impact flags are: -Xms/-Xmx (heap sizing, set equal), GC algorithm selection (-XX:+UseG1GC or -XX:+UseZGC), -XX:MaxGCPauseMillis (G1 pause target), -XX:ReservedCodeCacheSize (JIT code cache), and -Xlog:gc* (GC logging, not a performance flag but essential for informed tuning). Everything else is secondary.

How do I tune the JVM for a Spring Boot application?

Start with: -Xms and -Xmx set equal (size based on your container memory limit minus ~512MB for OS and non-heap). Enable G1GC with -XX:MaxGCPauseMillis=200. Set MetaspaceSize=256m and MaxMetaspaceSize=512m to prevent early metaspace GC during startup. Enable GC logging. On Java 21+, consider -XX:+UseZGC -XX:+ZGenerational if your API has strict latency SLAs. Use Spring Boot Actuator with Micrometer to expose GC metrics to Prometheus for ongoing monitoring.

What’s the difference between -Xms and -Xmx?

-Xms sets the initial heap size — how much memory the JVM allocates on startup. -Xmx sets the maximum heap size — the ceiling beyond which the JVM throws OutOfMemoryError rather than expanding. Setting them equal (e.g., -Xms8g -Xmx8g) prevents heap resizing, which eliminates the overhead of heap expansion GC pauses and makes memory consumption predictable. This is the recommended practice for production server deployments.

Should I tune my JVM manually or use Azul Prime?

Manual tuning makes sense when you have JVM expertise on the team, a stable and well-understood workload, and time to run controlled experiments. Azul Prime makes sense when GC-related incidents are recurring, your workload has variable or unpredictable load patterns, JIT warmup is causing deployment issues, or your engineering time is better spent on application features than infrastructure tuning. The two approaches are not mutually exclusive — many teams start with manual tuning and graduate to Azul Prime as scale and SLA requirements increase.

How do I monitor JVM performance in production?

A comprehensive monitoring stack for JVM performance includes: GC logs (always on, -Xlog:gc* to a rolling file), JVM metrics via Micrometer/Prometheus (heap, GC pause time, GC count, thread count, code cache usage), JFR continuous recording for post-incident analysis, and application-level latency percentiles (p95, p99, p999) correlated with GC events. Alerting should trigger on full GC occurrence, GC overhead >10% of CPU time, or p99 latency consistently above SLA.

Conclusion

JVM performance tuning is a discipline, not a checklist of flags to apply. The workflow — measure, identify the bottleneck, make a targeted change, verify improvement — applies whether you’re tuning heap sizing, GC algorithm selection, JIT behavior, or thread concurrency. Start with GC logging and JFR, establish a baseline, then tune systematically.

For teams running latency-critical Java services at scale, Azul Prime eliminates the most complex and time-consuming parts of JVM tuning: GC algorithm selection and ongoing GC configuration. Its C4 collector, Falcon JIT, and ReadyNow! warmup elimination provide performance headroom that manual tuning of open-source JVMs cannot match.