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?
The saw is the GC working as designed: new objects fill the young generation, a collection frees them, usage drops. A leak shows up as the floor rising: after each collection, more memory stays in use than before, until full collections can't free enough and the service slows down or fails with OutOfMemoryError. Graph old-generation usage after GC, or the minimum per hour, to see the trend.
- Generational hypothesis: cheap frequent young collections, concurrent old-gen work.
- G1 (default): region-based,
MaxGCPauseMillistarget (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*, Micrometerjvm.gc.*, JFR GC events.
Check this part
Why is the young generation collected often and cheaply?
Which pattern suggests a memory leak?
When is ZGC a better fit than G1?