JVM profiling and performance tuning are most effective when done in the right order: profile first, then tune. Changing JVM flags without profiling data is guesswork. You might improve one metric while degrading another, or fix a symptom while missing the actual bottleneck. Profiling tells you where time is actually going: which methods are hot, what is being allocated, where threads are blocking, and how GC events distribute across your SLA window.
Key JVM Performance Factors
Effective profiling requires knowing which subsystem to investigate for a given symptom:
- Heap and GC: Symptoms include GC pause spikes, OOM errors, and gradual throughput degradation over time. Profile with JFR GC events or jstat to measure allocation rates, promotion rates, and collection frequency.
- CPU and JIT: Symptoms include high CPU utilization without corresponding throughput, or throughput that grows slowly after startup. Profile with async-profiler in CPU mode to identify hot methods; check whether they are in compiled or interpreted frames.
- Thread contention: Symptoms include throughput that does not scale with added concurrency, or latency spikes that correlate with concurrent request count. Profile with async-profiler in lock mode or JFR’s thread park and monitor events.
- Memory leaks: Symptoms include monotonically growing heap occupancy across GC cycles. Profile with jmap heap histograms or JFR’s object allocation events to identify which classes are accumulating.
- Startup and warmup: Symptoms include elevated latency for the first N minutes after deployment. Profile with async-profiler during warmup to see the ratio of interpreted to compiled frames over time.
Essential JVM Tuning Parameters
Profiling informs which of these parameters to adjust. Without profiling data, the common starting points are:
| Flag | Purpose | Recommended Value/Range |
|---|---|---|
| -Xms / -Xmx | Heap bounds; set equal in production | Equal; 60% of available RAM |
| -XX:+UseG1GC | G1 GC (default JDK 9+) | Standard baseline |
| -XX:+UseZGC | Sub-millisecond pauses | Latency-sensitive workloads |
| -XX:MaxGCPauseMillis | G1 pause target (soft) | 200ms; tune down after profiling |
| -XX:ReservedCodeCacheSize | JIT code cache ceiling | 256m-512m |
| -Xlog:gc* | GC logging (JDK 9+) | Recommended on, rotate to file |
| -XX:+HeapDumpOnOutOfMemoryError | Heap dump on OOM | Recommended on |
GC Tuning
GC profiling is the most common JVM performance investigation. The workflow: enable GC logging as a first step, then overlay GC events with application latency metrics to identify correlation.
Reading GC Logs
Unified GC logging on JDK 9+ (-Xlog:gc*) produces structured output with timestamps, GC type, pause duration, and heap occupancy before and after collection. Key metrics to extract from GC logs:
- Pause duration: G1 minor GC pauses should be under 50ms for most workloads. Mixed collection pauses can be 200-500ms. Any full GC pause is a problem indicator.
- GC frequency: More than two minor GC events per second suggests the heap is too small or allocation rate is too high. Investigate with JFR allocation profiling.
- Heap utilization: If heap occupancy after GC is consistently above 70% of max heap, old generation is accumulating live data. Increase -Xmx or investigate object retention.
- Concurrent cycle failures: G1 concurrent marking cannot finish before the heap fills — visible as ‘concurrent mode failure’ — and triggers a full GC. This is a signal to increase heap headroom or reduce allocation rate.
ZGC GC Profiling
ZGC exposes GC data through JFR with ZGC-specific events. Key metrics: allocation stall events (application threads blocked waiting for GC to free space, indicating the heap is too small for ZGC’s concurrent operation), and relocation pause duration (should be under 1ms; if higher, something is preventing ZGC’s concurrent relocation from completing on time).
JIT Compilation Tuning
JIT compilation problems require different profiling than GC problems. The primary tool is async-profiler in CPU mode, which samples thread stacks and can distinguish between interpreter frames, C1-compiled frames, and C2-compiled frames.
- Warm-up profiling: Run async-profiler for 30 seconds at startup and generate a flame graph. If interpreter frames dominate, warmup is still in progress. As C2 compilation completes, the interpreter frames should disappear from hot code paths.
- Code cache monitoring: jcmd <pid> VM.native_memory summary scale=MB shows code cache utilization. If it is within 90% of -XX:ReservedCodeCacheSize, increase the limit. Code cache exhaustion causes a dramatic throughput cliff.
- Deoptimization events: JFR’s Deoptimization event captures when the JVM invalidates a compiled method. Frequent deoptimization of the same method indicates speculative optimization assumptions are being repeatedly violated by new class loading.
Profiling Tools Reference
The table below compares the available JVM profiling tools by overhead, use case, and JVM compatibility. Tool selection depends on whether you are profiling in production (requires low overhead), development, or a dedicated staging environment.
| Tool | Type | Overhead | Best For | JVM Support |
|---|---|---|---|---|
| Java Flight Recorder (JFR) | Built-in JDK | < 1% (continuous) | Production profiling, GC analysis, thread state | JDK 8u262+; all distributions |
| Java Mission Control (JMC) | JFR analysis GUI | None (offline) | Visualizing JFR recordings | Standalone or JDK-bundled |
| async-profiler | Sampling profiler | 1-3% (active) | CPU, allocation, lock contention profiles | JDK 8+; Linux/macOS |
| JVisualVM | JDK tool | 5-20% (active) | Development and staging environments | JDK 8+; all platforms |
| YourKit Java Profiler | Commercial | 3-10% (active) | Deep call-tree analysis, memory leak hunting | JDK 8+; all platforms |
| JProfiler | Commercial | 3-10% (active) | Memory, CPU, thread profiling with IDE integration | JDK 8+; all platforms |
| jstat | JDK CLI | < 1% | Quick GC stats and heap generation data | All JDK versions |
| jmap | JDK CLI | Pauses JVM briefly | Heap histograms and live object counts | All JDK versions |
| jstack | JDK CLI | Negligible | Thread dump and deadlock detection | All JDK versions |
| Azul Mission Control | Free (Azul) | < 1% | Prime-specific GC and JIT profiling with C4 events | Azul Prime JVM |
Recommended Tool Combinations
- Production baseline: JFR continuous recording (< 1% overhead) + jstat polling for real-time GC metrics. JFR captures the data; jstat gives immediate visibility without post-processing.
- GC investigation: JFR recording with GC event detail level + GCEasy or GCViewer for log analysis. If allocation profiling is needed, enable JFR ObjectAllocationInNewTLAB event.
- CPU and hotspot analysis: async-profiler in CPU mode for 60-120 seconds under load. Generate both flame graph (wall-clock) and allocation profile. Flame graphs show where CPU time goes; allocation profiles show what drives GC pressure.
- Thread contention: async-profiler in lock mode or JFR with lock profiling enabled. jstack provides thread dumps for deadlock detection; JFR’s MonitorEnter event provides statistical lock wait data.
Beyond Manual Tuning: Azul Prime
Azul Prime integrates profiling and monitoring at a level that changes the standard workflow. Rather than adding profiling instrumentation separately, Prime’s observability is built into the JVM itself:
- Azul Mission Control: a JFR-compatible profiling and monitoring tool extended with Prime-specific events. C4 GC events, Falcon JIT compilation state, and ReadyNow! profile status are all visible in real time without adding instrumentation overhead.
- Self-monitoring GC: Prime’s C4 collector continuously monitors allocation rates, object lifetimes, and heap utilization. It adjusts its own behavior in response — reducing the need for iterative profiling and tuning cycles.
- No GC pause events to profile: Because C4 eliminates stop-the-world pauses, the primary category of JVM performance investigation (GC pause spikes) is removed. Profiling effort can focus on application-level CPU and allocation behavior.
- ReadyNow! telemetry: ReadyNow! exposes warmup progress — which methods have pre-loaded compilation profiles and which are still warming up — giving operations teams a clear signal of when a newly deployed instance is ready for full production traffic.
The practical shift: teams running Azul Prime spend less time with GC log analysis tools and more time with application-level profiling tools. The JVM optimization problem is handled; the remaining profiling work is application-specific.
Frequently Asked Questions
What is the overhead of Java Flight Recorder in production?
JFR in its default continuous recording configuration adds less than 1% CPU overhead. This is low enough for always-on production use, which is the recommended configuration. JFR overhead increases when you enable high-frequency events (object allocation profiling, method profiling at high sample rate) — these should be enabled only for specific investigation sessions, not continuously.
How is async-profiler different from sampling profilers in VisualVM or JProfiler?
async-profiler uses Linux perf_events or AsyncGetCallTrace to capture stack samples at the OS level, including stack frames that are executing native code or blocked in the kernel. Traditional JVMTI-based profilers (VisualVM, JProfiler in sampling mode) can only sample Java frames and miss time spent in JNI, GC, or kernel calls. For JVM profiling, this means async-profiler gives a more accurate picture of where wall-clock time actually goes, especially for I/O-bound or GC-affected workloads.
Can I use JFR to diagnose memory leaks?
Yes, but with caveats. JFR’s Old Object Sample event captures a statistical sample of objects that survive many GC cycles, which is a proxy for memory leak candidates. Enable it with jfr.configuration events and look for classes with unexpectedly high retained counts. For precise memory leak diagnosis, jmap -histo:live or a full heap dump analyzed with Eclipse MAT or VisualVM’s heap analyzer gives complete object retention graphs. Note that jmap -histo:live forces a full GC to get accurate live counts, causing a pause.
How do I profile JVM performance in a container environment?
async-profiler requires Linux perf_events access, which depends on container security context. In Kubernetes, add securityContext: capabilities: add: [PERFMON] to the container spec (preferred, kernel 5.8+) or set privileged: true (less preferred). JFR works without elevated privileges and is easier to use in container environments: start a JFR recording with jcmd <pid> JFR.start, let it run for 5-10 minutes, then dump with jcmd <pid> JFR.dump. Use kubectl cp to retrieve the recording file for analysis.
What should I look for in a flame graph to identify JIT warmup problems?
In async-profiler CPU mode flame graphs generated during warmup, look for wide interpreter frames (labeled Interpreter in the profile) on hot code paths. These indicate methods that have not yet been JIT compiled. As warmup completes, these should be replaced by compiled native frames. If interpreter frames persist on hot paths after 5+ minutes of load, investigate whether the code cache is full (limiting new compilations) or whether the methods are excluded from compilation by JVM heuristics (check -XX:+PrintCompilation output).
Conclusion
JVM profiling and performance tuning are most effective as a combined practice: profile to locate the actual bottleneck, then tune the relevant parameter. The profiling tool landscape (JFR, async-profiler, jstat, and commercial options) covers every JVM performance dimension: GC behavior, CPU utilization, allocation rates, thread contention, and warmup progress. The profiling tools comparison table in this guide provides a reference for matching tool to use case.
Azul Prime changes the profiling and tuning dynamic by eliminating the largest category of JVM performance investigation — GC pause analysis — through its C4 garbage collector. Azul Mission Control provides Prime-specific profiling with built-in visibility into C4, Falcon JIT, and ReadyNow! behavior. For teams that spend significant engineering time on JVM performance, Prime shifts that time from GC tuning to application-level optimization.