Field note / java

publishedupdated 2026-05-01#java#jvm#gc#g1#zgc

JVM Tuning & Garbage Collection

GC algorithms (G1, ZGC, Shenandoah), heap sizing, GC log analysis, diagnosing memory leaks, and production JVM flags for microservices.

Overview

JVM tuning is about understanding the trade-off between throughput (total work done) and latency (pause times during GC). The right GC algorithm depends on your heap size, latency requirements, and whether you care more about peak throughput or consistent response times. Most services should start with G1GC defaults and only tune when profiling reveals a real problem.


Architecture

JVM HEAP LAYOUT (G1GC)
════════════════════════════════════════════════════════

  G1 divides heap into equal-sized regions (1–32MB each).
  Regions are dynamically assigned as Eden/Survivor/Old/Humongous.

  ┌────┬────┬────┬────┬────┬────┬────┬────┐
  │ E  │ E  │ S  │ O  │ O  │ H  │ E  │ S  │  E=Eden O=Old
  ├────┼────┼────┼────┼────┼────┼────┼────┤  S=Survivor H=Humongous
  │ O  │ O  │ E  │ E  │ O  │ O  │ E  │ O  │
  └────┴────┴────┴────┴────┴────┴────┴────┘

  Minor GC (Young): evacuate live objects from E→S or S→O
  Mixed GC (G1): evacuate some Old regions with highest garbage %
  Goal: stay within -XX:MaxGCPauseMillis target (default 200ms)

CLASSIC HEAP LAYOUT (Serial/Parallel/CMS)
════════════════════════════════════════════════════════

  ┌──────────────────────────────────────────────┐
  │              Young Generation                 │
  │   ┌─────────────┬──────────┬──────────┐      │
  │   │    Eden     │ Survivor0│ Survivor1│      │
  │   └─────────────┴──────────┴──────────┘      │
  ├──────────────────────────────────────────────┤
  │              Old Generation (Tenured)         │
  ├──────────────────────────────────────────────┤
  │   Metaspace (native memory — class metadata) │
  └──────────────────────────────────────────────┘

GC ALGORITHM COMPARISON
════════════════════════════════════════════════════════

  Algorithm   │ Pause    │ Throughput │ Heap Size  │ Since
  ────────────│──────────│────────────│────────────│──────
  Serial      │ High     │ Low        │ Small      │ JDK 1
  Parallel    │ Medium   │ High       │ 1–8GB      │ JDK 1.4
  G1GC        │ Low      │ Good       │ 4–32GB     │ JDK 9 (default)
  ZGC         │ < 1ms    │ Good       │ TB scale   │ JDK 15+
  Shenandoah  │ < 10ms   │ Good       │ Any        │ JDK 12+

  ZGC/Shenandoah: concurrent marking AND compaction — never stop-the-world
  G1: stop-the-world for marking but short pauses via incremental collection

Technical Implementation

Production JVM Flags for a Microservice

# Microservice: 2GB heap, G1GC, full GC logging, OOM heap dump
java \
  -server \
  -Xms2g -Xmx2g \                    # set initial=max to avoid resize pauses
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \         # target pause time (best effort)
  -XX:G1HeapRegionSize=16m \         # region size (1–32MB, power of 2)
  -XX:InitiatingHeapOccupancyPercent=45 \  # start concurrent marking at 45% full
  -XX:+ParallelRefProcEnabled \      # parallel reference processing
  \
  # GC logging (Java 11+)
  -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
  \
  # OOM diagnostics
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/tmp/heapdump.hprof \
  -XX:+ExitOnOutOfMemoryError \      # kill process on OOM (let orchestrator restart)
  \
  # JIT
  -XX:+TieredCompilation \           # C1 (fast start) → C2 (optimized hot code)
  -XX:ReservedCodeCacheSize=256m \
  \
  # Container-awareness (Java 11+)
  -XX:+UseContainerSupport \         # respect cgroup memory/CPU limits
  -XX:MaxRAMPercentage=75.0 \        # use 75% of container memory as heap
  \
  -jar app.jar

# For ultra-low latency (ZGC, Java 17+):
java -XX:+UseZGC -Xmx16g -XX:+ZGenerational -jar app.jar

Diagnosing Memory Leaks

# Live GC stats every 1 second
jstat -gcutil <pid> 1000
# Output: S0  S1    E     O     M   CCS   YGC  YGCT   FGC  FGCT
#          0   80.5  23.1  45.2  95   92    42   0.8    2   0.3
# Old gen (O) growing over time → memory leak or undersized heap

# Heap histogram (live objects, sorted by size)
jcmd <pid> GC.heap_info
jmap -histo:live <pid> | head -30

# Heap dump (point-in-time snapshot)
jmap -dump:format=b,file=heap.hprof <pid>
# or trigger programmatically:
# MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
# HotSpotDiagnosticMXBean diagnostic = ManagementFactory.newPlatformMXBeanProxy(
#     mbs, "com.sun.management:type=HotSpotDiagnostic", HotSpotDiagnosticMXBean.class);
# diagnostic.dumpHeap("/tmp/heap.hprof", true);

# Thread dump (for deadlock detection, thread state analysis)
jstack <pid>
# or: kill -3 <pid>  (prints to stdout)

ThreadLocal Memory Leak Pattern and Fix

// LEAK: ThreadLocal set in thread pool, never removed
// Thread returns to pool → next request on same thread sees old data
@Component
public class BadRequestHandler {
  private static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();

  public void handle(Request req) {
    CTX.set(new RequestContext(req));
    process();
    // BUG: if exception thrown before clear(), ThreadLocal stays in thread pool thread
  }
}

// FIX: always remove in finally block
@Component
public class SafeRequestHandler {
  private static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();

  public void handle(Request req) {
    CTX.set(new RequestContext(req));
    try {
      process();
    } finally {
      CTX.remove();  // ← mandatory: prevents leak in thread pools
    }
  }
}

// ALSO WATCH: static collections holding class references
// This keeps the ClassLoader alive → Metaspace leak in hot-reload environments
public class ClassLoaderLeak {
  // DANGEROUS in Tomcat/OSGi hot-reload:
  private static final List<Class<?>> registry = new ArrayList<>();

  public void register(Class<?> clazz) {
    registry.add(clazz);  // holds ClassLoader reference → ClassLoader can't be GC'd
  }
}
// Fix: use WeakReference<Class<?>> in registry

Interview Preparation

Q: What is the difference between Minor GC and Major GC?

Minor GC (Young GC) collects only the Young generation (Eden + Survivor spaces). It's triggered when Eden fills up, is typically fast (< 10ms for well-tuned G1), and most objects die here (generational hypothesis: most objects are short-lived). Major GC (or Full GC) collects the entire heap including Old generation. Full GCs are slow (can be seconds for large heaps with stop-the-world collectors) and indicate either: (a) too much long-lived data for the heap, (b) memory leak, or (c) GC tuning needed. G1's "mixed collections" are a middle ground — they collect Old generation incrementally to avoid a single long Full GC pause.

Q: When would you use ZGC over G1GC?

ZGC when: (1) You have strict latency SLAs — p99 < 10ms or p999 < 50ms — and GC pauses are measurable in your latency percentiles. (2) Your heap is large (8GB+) where G1 mixed collections produce longer pauses. (3) You're running a real-time trading system, game server, or interactive API where any pause is noticeable. G1 when: you want predictable behavior with good defaults, heap is 4–16GB, and 200ms pause budget is acceptable. ZGC costs some throughput (concurrent GC work steals CPU from your application). For a typical microservice with < 4GB heap, G1 defaults are fine.

Q: How do you diagnose a memory leak in production?

Step 1: Monitor Old generation size via jstat -gcutil <pid> 5000 — if Old gen grows steadily across GCs without stabilizing, you have a leak. Step 2: Take two heap dumps 30 minutes apart with jmap -dump:format=b,file=heap.hprof <pid>. Step 3: Analyze in Eclipse Memory Analyzer (MAT) — run "Leak Suspects Report". Look for: objects with large retained heap, static collections with thousands of entries, listener/callback registrations without cleanup, ThreadLocals in thread pools. Common causes: static Map caches with no eviction, event listeners never unregistered, unclosed InputStream or DB connections, Hibernate LazyInitializationException workarounds that load entire result sets into memory.

Q: What is GC pause time and why does it matter?

GC pause time is the duration during which all application threads are stopped (Stop-The-World — STW) while the GC does its work. During a pause: no requests are processed, no responses sent — from the caller's perspective, the service is completely unresponsive. A 500ms GC pause on a service with p99 latency SLA of 100ms is a guaranteed SLA violation. For high-throughput batch jobs, pauses don't matter much — optimize for throughput (-XX:+UseParallelGC). For latency-sensitive APIs, use G1 or ZGC to minimize pauses. Always measure with Xlog:gc* logging and correlate GC events with your latency percentiles in your APM tool.


Learning Resources