Practical JVM tuning — -Xms/-Xmx and container sizing, reading GC logs (PrintGCDetails vs unified -Xlog:gc*), spotting memory pressure and leaks in logs, G1 flags worth touching, SurvivorRatio and MaxTenuringThreshold, heap fragmentation, ZGC/Shenandoah tuning, sizing young/old generations, Metaspace tuning, direct memory, the real impact of common JVM flags, optimising GC, low-garbage system design, and reducing memory footprint and improving scalability of large Java applications.
Published September 25, 2026
Senior tuning answers follow one discipline:
Most "tuning" is removing bad flags, sizing the heap correctly for the container, and allocating less. Say that, and you'll stand out.
-Xms and -Xmx, and what are the best practices?Short answer: -Xms sets the initial heap and -Xmx the maximum. The heap grows and shrinks between them. Best practices:
-Xms equal to -Xmx, to avoid resize pauses and surprise growth, and add -XX:+AlwaysPreTouch to fault pages in at startup, so latency is consistent.-XX:MaxRAMPercentage=70–75 (and InitialRAMPercentage) over hard-coded sizes, leaving 25–30% for non-heap memory (Metaspace, threads, code cache, direct buffers). Otherwise the pod is OOMKilled despite a "healthy" heap.-XX:+PrintGCDetails show, and how do you read GC logs?Short answer: -XX:+PrintGCDetails printed per-collection details: the generation sizes before and after, the pause time, and the cause. Since Java 9 it's deprecated, and mapped onto unified logging. Use:
-Xlog:gc*,gc+phases=debug,safepoint:file=/var/log/app/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=50m
A G1 line looks like:
[2026-09-25T10:15:02.114+0000][123.456s][info][gc] GC(512) Pause Young (Normal) (G1 Evacuation Pause) 3480M->1210M(4096M) 18.734ms
It shows the collection number, type (young, mixed, full, remark, cleanup), cause, heap before → after (committed), and the pause duration.
What to look at:
Tools: GCeasy, GCViewer, JDK Mission Control, or Grafana dashboards from jvm_gc_* metrics.
Short answer: These signs indicate pressure:
Plot the "heap after GC" floor over time. Rising means a leak or a growing live set. Flat but high means the heap is too small for the live set.
-XX:+UseG1GC do, and which G1 parameters are worth tuning?Short answer: It selects G1, the default on server-class machines since Java 9, so it's usually only needed to override Serial in small containers. Tune as little as possible:
-XX:MaxGCPauseMillis (default 200): the pause goal. G1 adapts the young-generation size to meet it.-XX:G1HeapRegionSize (1–32 MB, and up to 512 MB in newer releases): raise it to avoid humongous objects if you allocate large arrays.-XX:InitiatingHeapOccupancyPercent (with adaptive IHOP on by default), and -XX:G1ReservePercent: start concurrent marking earlier to prevent evacuation failures.-XX:ParallelGCThreads/ConcGCThreads: in CPU-constrained containers.-XX:G1NewSizePercent/G1MaxNewSizePercent: bound the young generation (they're experimental flags, so avoid them unless measured).Common trap: setting -Xmn or NewRatio with G1. A fixed young size disables G1's pause-time adaptation.
-XX:SurvivorRatio and -XX:MaxTenuringThreshold affect GC behaviour?Short answer:
SurvivorRatio is the Eden-to-one-Survivor ratio (8 means Eden is 8/10 of the young generation, and each survivor space 1/10). Survivors too small means live objects overflow into the old generation early: premature promotion, which fills the old generation and causes more old or full GCs.MaxTenuringThreshold (default 15, the maximum) is the number of young GCs an object must survive before promotion. The JVM computes an adaptive threshold, based on survivor occupancy (TargetSurvivorRatio).
Check -Xlog:gc+age=trace to see the age distribution. This matters most for Parallel or Serial. With G1, prefer leaving the young generation adaptive.
Short answer: Fragmentation happens when free memory is scattered in small holes, so a large allocation fails even though total free space is enough. That triggers Full GCs, or an OOM. It happens with non-compacting collectors (CMS's old generation), and in G1 with humongous objects (they need contiguous free regions).
Avoid it by:
Off-heap fragmentation (the native malloc arenas) can also grow RSS. Consider MALLOC_ARENA_MAX, or jemalloc, for native-heavy applications.
Short answer: Both are designed to need very little tuning:
-XX:+UseZGC (generational by default since 23; -XX:+ZGenerational is no longer needed, and the non-generational mode was removed in 24).-Xmx): give it headroom so concurrent collection finishes before allocation stalls.-XX:SoftMaxHeapSize (try to stay below it), -XX:ConcGCThreads, -XX:ZUncommitDelay, and -XX:+UseLargePages (or transparent huge pages set to madvise).-XX:+UseShenandoahGC.-XX:ShenandoahGCHeuristics=adaptive|static|compact|aggressive).-XX:ShenandoahGCMode=generational, a product feature in Java 25).Watch for allocation stalls or pacing in the logs. They mean the heap or GC threads are insufficient.
Short answer:
Short answer:
OutOfMemoryError.Confirm with heap-dump diffs (two dumps some time apart) or JFR's Old Object Sample event, which records allocation stack traces of long-lived objects.
Short answer: Metaspace grows in native memory, unbounded by default. The flags:
-XX:MaxMetaspaceSize: a cap, so a classloader leak fails fast with OOM: Metaspace, instead of consuming the container's memory.-XX:MetaspaceSize: the initial high-water mark. Reaching it triggers a GC to unload classes. Raise it for large applications, to avoid early "Metadata GC Threshold" Full GCs at startup.-XX:CompressedClassSpaceSize (default 1 GB): for applications with huge numbers of classes.Tune from observations (jcmd <pid> VM.metaspace, the NMT class section), and fix classloader leaks rather than just raising limits.
Short answer: Off-heap native memory allocated for ByteBuffer.allocateDirect() (and used internally by NIO for socket and file I/O, by Netty, and by gRPC). The OS can do I/O directly on it, with no copy between the Java heap and native buffers, which makes it good for network servers and large I/O. It's limited by -XX:MaxDirectMemorySize (defaults to roughly -Xmx). When exhausted, you get OutOfMemoryError: Direct buffer memory.
Key points to cover:
PooledByteBufAllocator).Internal/Other), or BufferPoolMXBean.Arena) gives deterministic off-heap lifetimes.Short answer: Flags control heap and memory sizing, GC choice and behaviour, JIT behaviour, CPU detection and diagnostics. The high-impact, commonly correct ones:
-Xms/-Xmx, or -XX:MaxRAMPercentage in containers; -XX:MaxMetaspaceSize; -XX:MaxDirectMemorySize; -Xss (per-thread stack).-XX:+UseG1GC/UseZGC/UseParallelGC; -XX:MaxGCPauseMillis.-XX:ActiveProcessorCount=N, when container CPU quotas mislead the ergonomics (it affects GC and JIT thread counts, and the ForkJoin common pool size).-Xlog:gc* (not the deprecated PrintGCDetails), -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=..., -XX:+ExitOnOutOfMemoryError (let the orchestrator restart the process cleanly), and continuous JFR (-XX:StartFlightRecording).-XX:TieredStopAtLevel=1 for tools.Common trap: copying old flag lists: -XX:+UseConcMarkSweepGC, -XX:+UseParNewGC, -XX:PermSize, -XX:+AggressiveOpts. These are removed or invalid on modern JVMs. Some even prevent startup. Unknown -XX options are fatal.
Short answer: In order:
-e alloc). This is the biggest win.System.gc() calls.Short answer: Allocate less, and keep what you do allocate short-lived:
ThreadLocal or pooled ByteBuffers and StringBuilders. Flyweights for repeated values. Interning of hot immutable keys.Common trap: "use object pooling everywhere". Pooling ordinary short-lived objects is usually slower with modern GCs: young-generation allocation and collection are nearly free, while pools create long-lived objects, synchronisation, and leak risks. Pool only expensive resources (connections, threads, large direct buffers).
Short answer:
ArrayList.trimToSize for long-lived lists).-XX:+UseStringDeduplication, or interning domain codes).BitSet, primitive arrays).-XX:+UseCompactObjectHeaders).Short answer: The JVM provides the mechanisms, and you have to enable and design for them:
jlink builds a custom minimal runtime image containing only the modules you use, which means smaller container images and less memory mapped.For the smallest footprint, GraalVM native images remove the JIT and most of the metadata, at the cost of peak throughput and build complexity.
Q: Why does my container get OOMKilled when the heap is only 60% full?
A: The container limit covers the whole process: heap + Metaspace + code cache + thread stacks + direct and mapped memory + GC structures + native libraries. Enable NMT to see the breakdown, lower MaxRAMPercentage, cap Metaspace and direct memory, and reduce thread counts (or use virtual threads).
Q: What does -XX:+ExitOnOutOfMemoryError buy you?
A: After an OOM, the JVM's state is unreliable. Exiting immediately (with a heap dump written first) lets Kubernetes restart a clean instance, instead of leaving a zombie that fails requests.
Q: How do you check which flags are actually in effect?
A: java -XX:+PrintFlagsFinal -version, jcmd <pid> VM.flags and jcmd <pid> VM.command_line. The JFR recording's JVM information event also lists them.
Q: Is it worth setting -XX:+UseStringDeduplication?
A: For heaps with many duplicate strings (parsed JSON, CSV data), yes with G1 (and other collectors that support it). It deduplicates the backing arrays of long-lived strings in the background, at a small CPU cost. Measure the saving in the logs (-Xlog:stringdedup).