Skip to content
Commitwise
Menu
Java Backend

JVM performance and garbage collection

Understand how the JVM's garbage collectors and JIT compiler affect a Spring service's latency and memory, choose between G1 and ZGC, and find real bottlenecks with metrics, JFR, profilers, heap dumps and JMH instead of guessing.

advanced

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The p99 latency of the checkout service jumps from 120 ms to 900 ms every few minutes. Someone suggests doubling the heap, someone else wants to rewrite the JSON mapping, a third person blames the database. All three are guesses. The JVM can tell you exactly what’s happening: how long the garbage collector paused, which methods use the CPU, which code allocates the most memory, and what is holding on to it.

This tutorial explains what the JVM does behind your code (garbage collection and just-in-time compilation), and how to measure before you change anything.

New to some of these? Open what you need, or skip ahead.

Basics:What is garbage collection?

In Java and Kotlin you create objects, but you never free them. The garbage collectorGarbage collectionThe runtime periodically finds objects nothing can reach any more and frees them for you.Open in glossary → (GC) periodically finds objects nothing can reach any more and reuses their memory. It’s automatic, but it isn’t free: it uses CPU, and some of its work can briefly pause your application.

Basics:What is the heap?

The heap is the area of memory where objects live. Its maximum size is set when the JVM starts (-Xmx, or as a percentage of the container’s memory). When it’s full and the GC can’t free enough, the program fails with OutOfMemoryError.

Part Not started

How the garbage collector works

A restaurant clears tables in two ways. Small tables turn over constantly: a waiter wipes them in seconds, without disturbing anyone. Once in a while, the big banquet hall needs a proper clean, which takes longer.

The JVM does the same, because of one observation: most objects die young. A request creates hundreds of small objects (DTOs, strings, lists) that are garbage a few milliseconds later. So the heap has:

  • a young generation, where new objects are born. It fills up quickly and is cleaned often and fast, because almost everything in it is already garbage, and only the few survivors are copied out.
  • an old generation, for objects that survived several young collections: caches, connection pools, Spring beans. It’s cleaned less often, mostly while your code keeps running.

During some steps the collector pauses all application threads (“stop the world”). Short, rare pauses are invisible; long ones show up as slow requests.

HotSpot’s collectors are generational and differ in what they do concurrently. G1 (default for server-class machines) splits the heap into regions, collects young regions in short stop-the-world evacuation pauses, marks the old generation concurrently, and compacts incrementally in mixed collections, aiming at MaxGCPauseMillis (200 ms default). Generational ZGC (default mode since JDK 23) does marking, relocation and reference fixing concurrently with load barriers, keeping pauses sub-millisecond regardless of heap size, at the cost of some throughput and memory headroom. Parallel GC maximizes throughput for batch jobs with longer pauses; Serial is what tiny containers get by default. The useful signals are pause durations (p99, max), GC CPU share, allocation rate and old-gen occupancy after collection, not raw heap usage, which is supposed to look like a saw.

env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=75
      -XX:+UseZGC
      -Xlog:gc:stdout:time,uptime,level,tags
[2026-11-09T10:14:02.118+0000][512.4s][info][gc] GC(312) Pause Young (Normal) (G1 Evacuation Pause) 612M->148M(1024M) 8.214ms
[2026-11-09T10:14:09.902+0000][520.2s][info][gc] GC(313) Pause Young (Normal) (G1 Evacuation Pause) 640M->151M(1024M) 7.902ms
  • The GC log line says: a young collection, heap went from 612 MB to 148 MB, out of 1024 MB, and the application was paused for 8 ms. That’s healthy.
  • Heap usage going up and down like a saw is normal. What matters is the level it drops back to after collections: if that keeps rising over hours, something is holding on to memory (a leak).
  • Spring Boot with Micrometer exposes the same data as metrics (jvm.gc.pause, jvm.memory.used), so you can graph and alert on them.
  • G1 is the default and a good choice for most services. ZGC (-XX:+UseZGC) is for services that need consistently low latency or have large heaps.

Predict the output

A service's heap graph shows usage climbing from 200 MB to 700 MB, dropping back to 200 MB, and repeating every 30 seconds, for days. Is there a memory leak?

  • Generational hypothesis: cheap frequent young collections, concurrent old-gen work.
  • G1 (default): region-based, MaxGCPauseMillis target (200 ms), mixed collections. Generational ZGC: sub-ms pauses at any heap size, some throughput and headroom cost. Parallel for batch throughput; Serial in tiny containers.
  • Signals: pause p99/max, GC CPU %, allocation rate, old-gen after GC (the floor). A sawtooth is normal; a rising floor is a leak.
  • -Xlog:gc*, Micrometer jvm.gc.*, JFR GC events.

Check this part

  1. Why is the young generation collected often and cheaply?

  2. Which pattern suggests a memory leak?

  3. When is ZGC a better fit than G1?

Most objects die young, so the heap is split into a young generation, collected often and cheaply, and an old generation. G1 (the default) targets pauses around 200 ms or less; ZGC keeps pauses under a millisecond for large heaps at some throughput cost. Watch pause times and allocation rate, not only heap usage.

Part Not started

Measure, don’t guess

A doctor doesn’t operate because the patient “feels tired”; they run tests first. Performance work is the same. Most slow services are slow because of one thing, and it’s rarely the thing anyone guessed. Measuring tells you which one.

Start broad, then zoom in:

  1. Metrics: is the slowness in every request or only some? In this service or in a call it makes (the trace shows that)? Are GC pauses or CPU high at the same times?
  2. Profile: record where the CPU time and memory allocations go while the problem happens. Java Flight Recorder (JFR) is built into the JVM and cheap enough to run in production.
  3. Change one thing, measure again. If the numbers didn’t move, undo it.

The order of investigation is: latency percentiles and traces (is the time in this JVM at all?), then saturation signals (CPU, GC CPU share and pauses, thread pool and connection pool waits), then profiling. JFR records CPU samples, allocations by stack trace, lock contention, GC, I/O and thread states at ~1% overhead and can run continuously; async-profiler adds accurate native/CPU flame graphs and wall-clock mode for “where is the thread waiting”. Read flame graphs by width (time), not height. For allocation-heavy services, allocation rate drives GC frequency, so reducing allocation often beats GC tuning.

# Record 2 minutes from a running JVM (pid 1), with low overhead:
jcmd 1 JFR.start duration=120s settings=profile filename=/tmp/checkout.jfr

# Or always on, keeping the last 30 minutes in a ring buffer:
-XX:StartFlightRecording=maxage=30m,settings=default,dumponexit=true,filename=/tmp/app.jfr

# Then open the file in JDK Mission Control, or summarize it:
jfr print --events jdk.GarbageCollection /tmp/checkout.jfr
jfr summary /tmp/checkout.jfr

# async-profiler: a CPU flame graph for 60 seconds
asprof -d 60 -e cpu -f /tmp/cpu.html 1
  • jcmd talks to a running JVM: start recordings, print flags, take heap dumps, without a restart.
  • A flame graph shows call stacks stacked on top of each other; the wider a box, the more time was spent there. Look for wide boxes in your own code (a regex compiled on every request, JSON parsed twice, a slow log formatter).
  • Allocation profiles show which code creates the most objects. Less allocation means fewer collections.
Spot the bugA benchmark that measures nothing

Tap the line with the bug.

val start = System.nanoTime()
for (i in 0 until 1_000_000) {
    val s = i.toString()
    s.hashCode()
}
val took = System.nanoTime() - start
println("1M conversions: ${took / 1_000_000} ms")
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
    String s = String.valueOf(i);
    s.hashCode();
}
long took = System.nanoTime() - start;
System.out.println("1M conversions: " + took / 1_000_000 + " ms");

The JIT compiler and warm-up

The JVM doesn’t compile your code to machine code ahead of time. It starts by interpreting bytecode, counts which methods run often, and compiles those hot methods with the JIT (just-in-time) compiler, first quickly, then again with heavy optimizations based on how the code actually behaves (inlining, removing checks that never fail).

That’s why a freshly started service is slower for its first minute or so, and why the first requests after a deploy can be several times slower than the rest. Readiness probes, a short warm-up (sending a few requests before taking traffic) and slow rollouts hide it from users; newer JDKs can also start from a cache of earlier compilation (the AOT cache, -XX:AOTCache).

Production puzzles

Predict the output

A service with a 16 GiB heap on G1 shows young pauses of 15 ms, but every hour or so a single pause of 4 seconds. The GC log says 'Pause Full (G1 Compaction Pause)'. What does that most likely mean?

Tuning, in the order it pays off

  1. Fix the workload: remove the hot allocation or the needless work the profile shows (N+1 queries, re-parsing, logging at DEBUG with string building, huge responses). This beats every flag.
  2. Size correctly: enough heap that the live set after GC is well under half of it; container memory with non-heap headroom (see Docker and deployment).
  3. Pick the collector: G1 by default; ZGC for latency SLOs or heaps beyond ~8–16 GB; Parallel for batch throughput.
  4. Then one flag at a time, with before/after pause and throughput numbers. Copy-pasted flag lists from old blog posts (CMS-era options, -XX:+AggressiveOpts) are usually obsolete or harmful.
  • Investigate top-down: percentiles and traces → CPU, GC CPU %, pauses, pool waits → JFR (continuous, ~1%) or async-profiler (CPU, alloc, wall-clock, locks).
  • Flame graphs by width; allocation rate drives GC frequency.
  • JMH for microbenchmarks (warm-up, forks, Blackhole); hand-rolled loops measure JIT and dead-code elimination.
  • G1 full GCs mean marking fell behind (humongous allocations, IHOP); fix workload → size → collector → single flags.
  • JIT warm-up after deploys: readiness gating, warm-up traffic, AOT cache/CDS.

Check this part

  1. Why are the first requests after a deploy often slower?

  2. In a flame graph, what does a wide box mean?

  3. Why use JMH instead of timing a loop with System.nanoTime?

Measure before changing anything: start from latency and GC metrics, then record with Java Flight Recorder or async-profiler to see where CPU time and allocations go. Microbenchmarks need JMH; hand-written timing loops measure the JIT, not your code.

Part Not started

Memory leaks and allocation

The garbage collector frees objects that nothing can reach. A leak in Java isn’t forgotten memory, like in C; it’s memory something still reaches by accident. The classic case is a map in a long-lived object that only ever grows: every entry is reachable, so the GC can never free it.

JVM leaks are unintended reachability: static or singleton collections, caches without bounds or expiry, listeners and callbacks never deregistered, ThreadLocals on pooled threads, class loaders pinned across redeploys, and per-request data captured in long-lived lambdas. Diagnose with a heap dump (jcmd <pid> GC.heap_dump, or automatically with -XX:+HeapDumpOnOutOfMemoryError) and Eclipse MAT or VisualVM: the dominator tree and retained sizes show what keeps the most memory alive, and paths to GC roots show why. JFR’s Old Object Sample event catches slow leaks in production without a full dump.

Spot the bugMemory that only grows

Tap the line with the bug.

@Service
class ExchangeRates(private val client: RatesClient) {
    private val cache = ConcurrentHashMap<String, BigDecimal>()

    fun rate(from: String, to: String, day: LocalDate): BigDecimal {
        val key = "$from:$to:$day"
        return cache.computeIfAbsent(key) { client.fetch(from, to, day) }
    }
}
@Service
public class ExchangeRates {
    private final Map<String, BigDecimal> cache = new ConcurrentHashMap<>();
    private final RatesClient client;   // injected through the constructor

    public BigDecimal rate(String from, String to, LocalDate day) {
        String key = from + ":" + to + ":" + day;
        return cache.computeIfAbsent(key, k -> client.fetch(from, to, day));
    }
}

Other common leaks to recognize:

  • Listeners added to a long-lived object and never removed.
  • ThreadLocal values set in a request and never cleared: request threads come from a pool and live forever, so the value stays.
  • Collections of “recent” things (recent orders, last errors) without a maximum size.
Exercisemedium

Find the leak

A service is OOMKilled every two days. You took a heap dump at 85% heap usage. In the dominator tree, the biggest entry is com.shop.audit.AuditBuffer retaining 1.4 GB, through a field pending: ArrayList with 9 million AuditEntry objects. The buffer is supposed to send entries to an audit service every 5 seconds and clear them. What questions do you ask, and what would you change?

›

The buffer only ever grows if the flush stops working. When could a flush fail, and what does the code do with the entries then?

SolutionTry it yourself first

Questions:

  • Is the flush running? A scheduled task that throws an uncaught exception once stops forever in ScheduledExecutorService (scheduleAtFixedRate suppresses further runs). Check logs for the first exception and the time the buffer started growing.
  • Does a failed send clear the entries? If the audit service is down, the code probably keeps the entries for the next attempt, and new entries keep arriving: the buffer grows without limit during an outage.
  • Is the buffer bounded at all? It should never be able to take the whole heap.

Changes:

  • Catch and log exceptions inside the scheduled task, so one failure doesn’t stop it, and expose the buffer size as a metric with an alert.
  • Bound the buffer (for example 100,000 entries). When full, decide explicitly: drop with a counter, block producers briefly, or spill to disk or a durable queue (Kafka) if audit entries must never be lost.
  • Retry sending with backoff, and send in batches, so a recovering audit service isn’t flooded.

Allocation in hot paths

Short-lived garbage is cheap, but not free: at a few GB/s of allocation, young collections run several times a second and each one costs CPU and a pause. The allocation profile usually points at a handful of sources:

  • Logging: string concatenation or String.format in log calls that end up disabled; use parameterized logging (log.debug("x={}", x)) and avoid computing arguments eagerly.
  • Boxing: maps of boxed Long counters and streams over Integer in tight loops; primitive specializations (LongStream, mapToInt) or primitive collections.
  • Copying: reading whole request bodies into String then into byte[], repeated toList() copies, building large intermediate collections instead of streaming.
  • Serialization: creating a new ObjectMapper per call (expensive and allocation-heavy); share one.

Don’t pool ordinary objects to “help the GC”; modern collectors handle short-lived objects better than hand-written pools, which add contention and keep objects alive into the old generation.

  • Leaks = unintended reachability: unbounded static/singleton maps and caches, listeners, ThreadLocal on pools, class loaders, captured lambdas.
  • Heap dump (jcmd GC.heap_dump, HeapDumpOnOutOfMemoryError) → MAT dominator tree, retained size, path to GC roots; JFR Old Object Sample for slow leaks.
  • Bound every cache (Caffeine: size + expiry + stats) and every buffer; scheduled tasks must not die on one exception.
  • Reduce hot-path allocation (logging, boxing, copying, per-call ObjectMapper); don’t pool plain objects.

Check this part

  1. What is a memory leak in Java?

  2. What does every cache need?

  3. In a heap dump, what tells you which object keeps the most memory alive?

A leak is something that keeps objects reachable: an unbounded cache or map, listeners never removed, ThreadLocals in pools. Find it with a heap dump and the dominator tree. Reduce allocation in hot paths, and bound every cache.

Key takeaways

  • The GC collects young objects often and cheaply; a heap graph shaped like a saw is normal, a rising floor is a leak.
  • G1 is the default; ZGC keeps pauses tiny for latency-sensitive services.
  • Measure first: metrics, then a JFR recording or a flame graph; change one thing at a time.
  • Bound every cache and buffer; a leak is something that still reaches objects you no longer need.
  • Watch pause percentiles, GC CPU share, allocation rate and old-gen after GC.
  • Profile with JFR or async-profiler; benchmark with JMH, never with timing loops.
  • G1 full GCs are a failure mode: fix allocation and headroom, or move to ZGC.
  • Fix the workload before tuning flags; reduce hot-path allocation instead of pooling objects.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.