Skip to main content

Optimize Java Memory — A Practical Guide to JVM Tuning

· 6 min read
Hieu Nguyen
Senior Software Engineer at OCB

A practical guide to understanding JVM memory management and tuning garbage collection for better performance in production Java applications.

As a backend engineer working with Java in the financial sector, I've seen how improper memory configuration can lead to high latency, frequent GC pauses, and even OutOfMemoryError in production. In this post, I'll share the key concepts and practical JVM tuning tips I've learned along the way.

JVM Memory Architecture Overview

The JVM divides memory into several regions. Understanding them is the first step to effective tuning.

┌─────────────────────────────────────────────────┐
│ JVM Memory │
├──────────────────────┬──────────────────────────┤
│ Heap │ Non-Heap │
│ ┌────────────────┐ │ ┌────────────────────┐ │
│ │ Young Gen │ │ │ Metaspace │ │
│ │ ┌──────────┐ │ │ │ (Class metadata) │ │
│ │ │ Eden │ │ │ ├────────────────────┤ │
│ │ ├──────────┤ │ │ │ Code Cache │ │
│ │ │ S0 | S1 │ │ │ │ (JIT compiled) │ │
│ │ └──────────┘ │ │ ├────────────────────┤ │
│ ├────────────────┤ │ │ Thread Stacks │ │
│ │ Old Gen │ │ │ │ │
│ │ (Tenured) │ │ └────────────────────┘ │
│ └────────────────┘ │ │
└──────────────────────┴──────────────────────────┘

Heap Memory

The heap is where all Java objects live. It's divided into:

  • Young Generation (Eden + Survivor Spaces) — Short-lived objects are allocated here. Minor GC collects this area frequently.
  • Old Generation (Tenured) — Objects that survive multiple GC cycles get promoted here. Major GC (or Full GC) cleans this area.

Non-Heap Memory

  • Metaspace — Stores class metadata (replaced PermGen since Java 8). Grows dynamically by default.
  • Code Cache — JIT-compiled native code.
  • Thread Stacks — Each thread has its own stack (default ~1MB per thread).

Essential JVM Memory Flags

Here are the most commonly used flags for memory tuning:

Heap Size

# Set initial and maximum heap size
java -Xms512m -Xmx2g -jar app.jar

# -Xms → Initial heap size (set equal to -Xmx to avoid resizing overhead)
# -Xmx → Maximum heap size

Tip: In production, always set -Xms equal to -Xmx. This avoids the overhead of the JVM constantly resizing the heap.

Young Generation Size

# Set Young Gen size directly
java -Xmn512m -jar app.jar

# Or use a ratio (Young:Old = 1:2)
java -XX:NewRatio=2 -jar app.jar

Metaspace

# Set Metaspace limits (recommended for containerized apps)
java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -jar app.jar

Thread Stack Size

# Reduce thread stack size (useful for apps with many threads)
java -Xss512k -jar app.jar

Choosing the Right Garbage Collector

Java offers several GC algorithms. The choice depends on your application's requirements.

Garbage CollectorBest ForFlag
G1GCGeneral purpose, balanced latency/throughput (default since Java 9)-XX:+UseG1GC
ZGCUltra-low latency (< 10ms pauses), large heaps-XX:+UseZGC
ShenandoahLow latency, concurrent compaction-XX:+UseShenandoahGC
Parallel GCMaximum throughput (batch processing)-XX:+UseParallelGC
Serial GCSmall heaps, single-core machines-XX:+UseSerialGC

G1GC is the default collector since Java 9 and works well for most applications.

java \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:InitiatingHeapOccupancyPercent=45 \
-jar app.jar

# MaxGCPauseMillis → Target max GC pause time (ms)
# G1HeapRegionSize → Size of G1 heap regions (1MB–32MB, power of 2)
# InitiatingHeapOccupancyPercent → Start concurrent marking when heap is 45% full

ZGC for Low Latency

If your application requires consistently low latency (e.g., real-time transaction processing):

java \
-XX:+UseZGC \
-XX:+ZGenerational \
-Xmx4g \
-jar app.jar

# ZGC delivers sub-millisecond pause times regardless of heap size
# ZGenerational (Java 21+) enables generational mode for better throughput

Monitoring JVM Memory

Enable GC Logging

Always enable GC logging in production — it has minimal overhead:

# Java 9+ (Unified Logging)
java \
-Xlog:gc*:file=gc.log:time,level,tags:filecount=5,filesize=100m \
-jar app.jar

# Java 8
java \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:gc.log \
-jar app.jar

Useful Diagnostic Flags

# Print JVM flags at startup (verify your settings)
java -XX:+PrintFlagsFinal -jar app.jar | grep -i heap

# Enable heap dump on OOM
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof \
-jar app.jar

# Enable JMX for remote monitoring (e.g., VisualVM, JConsole)
java \
-Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.port=9010 \
-Dcom.sun.management.jmxremote.authenticate=false \
-Dcom.sun.management.jmxremote.ssl=false \
-jar app.jar
ToolTypeDescription
VisualVMGUIReal-time heap, threads, GC monitoring
JConsoleGUIBuilt-in JMX monitoring tool
jstatCLIGC statistics from a running JVM
jmapCLIHeap dumps and memory maps
GCViewerGUIAnalyze GC log files
Prometheus + GrafanaDashboardProduction-grade metrics with Micrometer/JMX exporter

Quick example with jstat:

# Monitor GC activity every 1 second
jstat -gcutil <pid> 1000

# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 98.44 65.23 45.12 97.80 95.32 124 1.234 3 0.456 1.690

Common Memory Issues & Fixes

1. OutOfMemoryError: Java heap space

Cause: Heap is full — either too small or there's a memory leak.

# Quick fix: increase heap
java -Xmx4g -jar app.jar

# Long-term: find the leak
# 1. Enable heap dump on OOM
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/ -jar app.jar

# 2. Analyze the dump with Eclipse MAT or VisualVM

2. OutOfMemoryError: Metaspace

Cause: Too many classes loaded (common with many dependencies or dynamic class generation).

# Increase Metaspace limit
java -XX:MaxMetaspaceSize=512m -jar app.jar

3. Long GC Pauses

Cause: Full GC on a large Old Gen, or wrong GC algorithm.

# Switch to a low-pause collector
java -XX:+UseZGC -Xmx4g -jar app.jar

# Or tune G1GC pause target
java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -jar app.jar

4. High Memory Usage in Containers

Docker and Kubernetes containers need special attention:

# Always set explicit limits (don't rely on container memory limits alone)
java \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=75.0 \
-jar app.jar

# MaxRAMPercentage → Use 75% of container memory for heap
# Leave 25% for non-heap (Metaspace, thread stacks, native memory, OS)

Warning: Never set -Xmx higher than your container's memory limit. The JVM will be killed by the OOM killer without a heap dump.

My Production JVM Template

Here's the JVM configuration I use as a starting point for production services:

java \
-server \
-Xms2g -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+UseContainerSupport \
-XX:MetaspaceSize=128m \
-XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heapdump.hprof \
-Xlog:gc*:file=/var/log/app/gc.log:time,level,tags:filecount=5,filesize=50m \
-jar app.jar

Why these values?

FlagRationale
-Xms = -XmxAvoid heap resizing overhead
G1GCGood balance of throughput and latency
200ms pause targetAcceptable for most backend services
Metaspace capped at 256mPrevent unbounded growth
Heap dump on OOMAlways be able to diagnose crashes
GC loggingNear-zero overhead, invaluable for troubleshooting

Key Takeaways

  1. Understand your memory regions — heap, Metaspace, thread stacks, and native memory all contribute to total usage.
  2. Set -Xms equal to -Xmx in production to avoid resizing overhead.
  3. Choose the right GC — G1GC for general use, ZGC for low latency, Parallel GC for throughput.
  4. Always enable GC logging and heap dumps — they cost almost nothing and save you hours of debugging.
  5. In containers, use MaxRAMPercentage instead of hardcoded -Xmx values for flexibility.
  6. Monitor continuously — use jstat, GC logs, and Prometheus/Grafana to catch issues early.

Thanks for reading! If you're working with Java in production, I hope these tips save you from a few 3 AM incidents. 🚀