The actual tools: JVM flags, reading GC logs, jstat, heap dumps and Eclipse MAT, VisualVM/JFR profiling, JMX, memory thrashing, CPU flame graphs, and object pooling's real tradeoff.
Published September 23, 2026
Everything so far in this chapter has been conceptual. This lesson is the practical toolkit: the flags, the logs, and the tools you'd actually reach for when a production JVM is misbehaving.
-Xms2g # initial heap size
-Xmx2g # maximum heap size (setting Xms == Xmx avoids resize pauses)
-XX:+UseG1GC # select the collector (see Modern Garbage Collectors)
-XX:MaxGCPauseMillis=200 # G1's soft pause-time target
Setting -Xms equal to -Xmx is a common production practice: it avoids the JVM spending time dynamically resizing the heap under load (each resize is itself a pause-worthy event) by just committing the full heap up front.
-Xlog:gc*
This produces a log entry per GC event, including pause duration and heap occupancy before/after — the raw data for answering "is GC actually the problem, and how bad is it": how often collections run, how long each pause is, and how much memory each collection actually reclaimed (before-vs-after heap size shows whether a collection is doing useful work or barely making a dent, an early warning sign of the memory-thrashing pattern below).
jstat -gcutil <pid> 1000 # print GC stats every 1000ms
A lightweight, always-available way to watch generational occupancy and collection counts in real time on a running JVM, without the overhead of a full profiler attached — useful as a first, cheap check before reaching for heavier tooling.
jmap -dump:live,format=b,file=heap.hprof <pid>
jcmd <pid> GC.heap_dump heap.hprof
Or, for the case that matters most in production — capture automatically at the moment of failure:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps/
This flag is worth setting on every production JVM by default — an OutOfMemoryError without a heap dump captured at the exact moment of failure is usually much harder to diagnose after the fact, since the process typically needs restarting and the leaked state is gone.
A raw heap dump is just a snapshot of every object and reference — Eclipse MAT (Memory Analyzer Tool) turns that into something navigable:
Java Flight Recorder (JFR) is built into the JDK and specifically designed for low-overhead, always-on profiling in production — unlike a traditional profiler that can slow an application significantly, JFR's overhead is typically low enough to leave running continuously, capturing a rolling window of events (GC pauses, allocation hot spots, thread activity, lock contention) that can be pulled and analyzed after an incident, rather than requiring you to have already been profiling before the problem occurred. VisualVM provides a GUI for both live JVM inspection and JFR recording analysis.
Java Management Extensions expose MBeans — standardized runtime metrics (heap usage, thread counts, GC statistics, and application-specific metrics you register yourself) that any JMX-aware tool can connect to. This isn't a separate tool itself — it's the protocol that JConsole, VisualVM, and many production monitoring integrations (Prometheus JMX exporters, for instance) actually connect through to inspect a running JVM remotely.
When the heap is too small for the application's actual live data set, the JVM ends up spending most of its time running GC rather than executing application code — each collection reclaims very little (because most of the heap genuinely is live data, not garbage), so another collection triggers again almost immediately. The visible symptom is deceptive: high CPU with low application throughput — CPU usage looks pegged, but it's GC, not useful work, consuming it. This is diagnosable directly from GC logs: collections that are frequent and reclaim little memory each time (heap occupancy before/after barely changes) is the signature to look for.
When the JVM isn't thrashing on GC but is still slow, the next question is which methods are consuming CPU. Tools like async-profiler (or JFR's own built-in CPU sampling) sample the call stack repeatedly across many threads over time and aggregate the results into a flame graph — a visualization where the width of each bar represents how often that method appeared on the sampled stack (i.e., how much CPU time it's roughly responsible for), letting you spot hot paths visually rather than guessing.
Reusing a fixed set of expensive-to-create objects (DB connections, thread pool workers) instead of allocating and discarding them repeatedly reduces allocation rate and therefore GC pressure — this is exactly why connection pools and thread pools exist as a default architectural choice, not an optimization reserved for extreme cases. The real risk: state leakage between reuses — if a pooled object isn't fully reset before being handed to the next borrower, residual state from the previous use can silently corrupt the next use's behavior. Pooling trades a GC-pressure problem for a correctness-discipline problem — worth it for genuinely expensive-to-create resources, generally not worth it for cheap, short-lived objects where escape analysis (see JVM Memory Areas) might already be eliminating the allocation cost for free.
Q: If -Xlog:gc shows frequent Minor GCs but no Major GCs, is that a problem?* A: Usually not by itself — frequent, cheap Minor GCs reclaiming most of Eden each time is exactly the generational hypothesis working as intended (see Garbage Collection Fundamentals). It becomes a concern only if Minor GC pause duration itself is affecting latency-sensitive code paths, which would point toward tuning young-gen size or reconsidering the collector choice, not toward the presence of Minor GCs itself.
Q: Why would you use jstat instead of just always running JFR? A: jstat requires zero setup and near-zero overhead for a quick, immediate check ("is GC even the issue right now") — JFR, while low-overhead, still requires starting a recording and produces a file to analyze afterward, which is more appropriate for capturing a fuller picture over time than for an immediate, single-command sanity check.
Q: How would you distinguish memory thrashing from a genuine memory leak using GC logs alone? A: A leak shows heap occupancy trending upward over time even after full GCs — each Full GC reclaims less than the last, because live (leaked) data keeps growing. Thrashing shows heap occupancy staying roughly flat (near capacity) but collections running very frequently — the difference is trend over time (growing = leak) vs frequency at a steady-state ceiling (thrashing).
Q: What would make async-profiler's flame graph misleading? A: Sampling profilers can under-represent very short-lived hot spots that don't happen to land in enough samples, and can be skewed by safepoint bias in some JVM versions/configurations (where sampling can only happen at certain safe points, potentially over- or under-counting specific code patterns) — worth cross-checking a surprising flame-graph result against JFR's allocation/lock-contention data rather than trusting CPU sampling alone for a final diagnosis.