Optimize Java Memory — A Practical Guide to JVM Tuning
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
-Xmsequal 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 Collector | Best For | Flag |
|---|---|---|
| G1GC | General purpose, balanced latency/throughput (default since Java 9) | -XX:+UseG1GC |
| ZGC | Ultra-low latency (< 10ms pauses), large heaps | -XX:+UseZGC |
| Shenandoah | Low latency, concurrent compaction | -XX:+UseShenandoahGC |
| Parallel GC | Maximum throughput (batch processing) | -XX:+UseParallelGC |
| Serial GC | Small heaps, single-core machines | -XX:+UseSerialGC |
G1GC Tuning (Recommended Default)
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
Recommended Monitoring Tools
| Tool | Type | Description |
|---|---|---|
| VisualVM | GUI | Real-time heap, threads, GC monitoring |
| JConsole | GUI | Built-in JMX monitoring tool |
| jstat | CLI | GC statistics from a running JVM |
| jmap | CLI | Heap dumps and memory maps |
| GCViewer | GUI | Analyze GC log files |
| Prometheus + Grafana | Dashboard | Production-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
-Xmxhigher 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?
| Flag | Rationale |
|---|---|
-Xms = -Xmx | Avoid heap resizing overhead |
| G1GC | Good balance of throughput and latency |
| 200ms pause target | Acceptable for most backend services |
| Metaspace capped at 256m | Prevent unbounded growth |
| Heap dump on OOM | Always be able to diagnose crashes |
| GC logging | Near-zero overhead, invaluable for troubleshooting |
Key Takeaways
- Understand your memory regions — heap, Metaspace, thread stacks, and native memory all contribute to total usage.
- Set
-Xmsequal to-Xmxin production to avoid resizing overhead. - Choose the right GC — G1GC for general use, ZGC for low latency, Parallel GC for throughput.
- Always enable GC logging and heap dumps — they cost almost nothing and save you hours of debugging.
- In containers, use
MaxRAMPercentageinstead of hardcoded-Xmxvalues for flexibility. - 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. 🚀