Garbage collection

Reachability and cycles, most objects dying young in G1's own age log, humongous objects — and five collectors on one workload, where heap size mattered as much as the collector.

7 min read⚙️ JVM Internals and Performance

Garbage collection is the JVM finding memory your program can no longer use and making it available again. It is the reason Java programs do not free memory by hand, and it is also the reason a Java service sometimes stops answering for half a second. Understanding it means understanding three things: what counts as garbage, why collectors split the heap by age, and what each collector trades to keep pauses short.

The measurements here were made on Java 21 in a container limited to 4 CPUs.

Reachability, not reference counting

An object is live if it can be reached by following references from a GC root. Roots are the starting points the JVM knows are in use: local variables and operands on every thread's stack, static fields of loaded classes, JNI references, and a few JVM-internal handles. Everything reachable from a root is kept; everything else is garbage, whatever references it holds.

That rule has consequences worth seeing:

plaintext
baseline used:                        1 MB
two 10 MB nodes referencing each other: 23 MB
after dropping our references:         1 MB   (the cycle was collected)

Two objects referencing each other form a cycle. Under reference counting, each would keep the other alive forever. Under reachability, once no root reaches either of them, both are collected — cycles are not a leak in Java.

The inverse matters more in practice: anything reachable is kept, however useless. A static map that holds every request, a thread-local on a pool thread, a listener never unregistered — all reachable, none collectable. The finding memory leaks lesson is about those paths.

Reference types let a program tell the collector how much it cares:

plaintext
after a GC: strong kept=true  weak kept=false  soft kept=true

A weak reference does not keep its object alive — it was cleared by the first collection. A soft reference is kept until the JVM is short of memory. Weak references are what WeakHashMap and many caches of metadata use; soft references look like a free cache and behave like an unpredictable one, and most real caches are better served by an explicit size limit.

The generational hypothesis

Most objects die young. A request allocates strings, lists, DTOs and iterators, and nearly all of them are garbage by the time the response is written. That observation — the weak generational hypothesis — is why most collectors split the heap:

  • Young generation: new objects are allocated in eden. When eden fills, a young collection copies the few live objects into a survivor space and discards eden wholesale. Cost is proportional to what survives, not to what was allocated.
  • Old generation: objects that survive enough young collections are promoted here. It is collected less often, and collecting it is more expensive.

G1's age log shows the hypothesis at work. A program allocates 20,000 short-lived arrays in each round and keeps one in a thousand:

plaintext
GC(0) Desired survivor size 1048576 bytes, new threshold 15 (max threshold 15)
GC(0) - age   1:      92920 bytes,      92920 total
GC(1) - age   1:      15624 bytes,      15624 total
GC(1) - age   2:      92704 bytes,     108328 total
GC(2) - age   1:      18936 bytes,      18936 total
GC(2) - age   2:      15312 bytes,      34248 total
GC(2) - age   3:      92704 bytes,     126952 total
GC(3) - age   1:      16560 bytes,      16560 total
GC(3) - age   2:      18480 bytes,      35040 total
GC(3) - age   3:      15312 bytes,      50352 total
GC(3) - age   4:      92704 bytes,     143056 total

Between collections, megabytes are allocated; 15 to 19 KB survive each one. The same cohort — the 92,704-byte group, the long-lived objects from startup — is visible growing one year older at every collection. After surviving up to the tenuring threshold (15 here), an object is promoted to the old generation.

Two consequences for application code:

  • Short-lived garbage is cheap. Allocating a temporary object that dies before the next young collection costs almost nothing to collect. Pooling small objects to "help the GC" usually makes things worse, because pooled objects live long and get promoted.
  • Medium-lived objects are expensive. Objects that survive a few collections and then die — a cache entry with a two-minute TTL, a batch held across several requests — get copied repeatedly, promoted, and then have to be cleaned from the old generation.

Humongous objects

G1 divides the heap into equal regions — sized automatically between 1 and 32 MB unless set explicitly. An object of half a region or more is humongous: it is allocated directly in contiguous old-generation regions, skipping the young generation entirely. With 4 MB regions, allocating 3 MB arrays:

plaintext
GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 53M->5M(128M) 1.821ms
GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 57M->5M(128M) 0.408ms
GC(0) Humongous regions: 13->1

Every few allocations triggered a collection with the reason G1 Humongous Allocation. They were cheap here because the arrays died at once. In a real service, large byte arrays for uploads, big JSON responses held as strings, or a large HashMap table being resized do this constantly, and they fragment the old generation. If the log shows many humongous allocations, a larger region size (-XX:G1HeapRegionSize) or streaming instead of buffering is the fix.

Stop-the-world, safepoints and concurrent work

Some collector work requires every application thread to be stopped — a stop-the-world pause. Threads can only be stopped at safepoints, points in compiled code where the JVM knows where every reference is. A pause therefore has two parts: waiting for all threads to reach a safepoint, and the work done while they are stopped.

Collectors differ in how much work they do inside pauses:

  • Serial — one thread, everything stopped. Simple and small.
  • Parallel — many threads, everything stopped. Maximum throughput, long pauses on large heaps.
  • G1 — young collections are stopped and parallel; old-generation marking runs concurrently, and old regions are reclaimed incrementally in mixed collections. It aims for a pause target (200 ms by default).
  • ZGC and Shenandoah — marking, relocation and compaction run concurrently with the application, using read barriers on references so objects can move while threads use them. Pauses are kept to short, roughly constant phases that do not grow with the heap.

Five collectors, one workload

A synthetic service: about 537 MB of long-lived data (a map of 2.5 million orders), continuous short-lived garbage, and one in ten operations replacing a map entry. A probe thread sleeps for 1 ms in a loop and records how late it wakes up — which is what a request thread feels during a pause. Twenty seconds per run, on 4 CPUs.

With a 2 GB heap:

plaintext
-XX:+UseSerialGC                 6,703,300 ops/s   worst stall 1766.6 ms   >10ms 68  >50ms 68  >100ms 68  >500ms 2 | full GCs 3
-XX:+UseParallelGC               12,851,350 ops/s   worst stall 1097.9 ms   >10ms 134  >50ms 14  >100ms 4  >500ms 4 | full GCs 5
-XX:+UseG1GC                     11,847,500 ops/s   worst stall 698.4 ms   >10ms 78  >50ms 54  >100ms 13  >500ms 1 | full GCs 9
-XX:+UseZGC -XX:+ZGenerational   2,016,150 ops/s   worst stall 105.4 ms   >10ms 8  >50ms 2  >100ms 1  >500ms 0 | full GCs 0
-XX:+UseShenandoahGC             1,364,400 ops/s   worst stall 43.1 ms   >10ms 2  >50ms 0  >100ms 0  >500ms 0 | full GCs 0

With a 1 GB heap — the same 537 MB of live data, now more than half the heap — two runs:

plaintext
run 1
-XX:+UseSerialGC                 5,318,950 ops/s   worst stall 669.6 ms   >10ms 108  >50ms 98  >100ms 16  >500ms 10 | full GCs 14
-XX:+UseParallelGC               1,884,200 ops/s   worst stall 585.7 ms   >10ms 41  >50ms 41  >100ms 41  >500ms 2 | full GCs 41
-XX:+UseG1GC                     8,464,300 ops/s   worst stall 780.4 ms   >10ms 185  >50ms 36  >100ms 33  >500ms 1 | full GCs 32
-XX:+UseZGC -XX:+ZGenerational   3,009,250 ops/s   worst stall 60.4 ms   >10ms 12  >50ms 1  >100ms 0  >500ms 0 | full GCs 0
-XX:+UseShenandoahGC             969,850 ops/s   worst stall 60.8 ms   >10ms 4  >50ms 1  >100ms 0  >500ms 0 | full GCs 0
run 2
-XX:+UseSerialGC                 4,924,650 ops/s   worst stall 847.1 ms   >10ms 100  >50ms 92  >100ms 18  >500ms 11 | full GCs 14
-XX:+UseParallelGC               1,884,200 ops/s   worst stall 640.2 ms   >10ms 41  >50ms 41  >100ms 41  >500ms 3 | full GCs 41
-XX:+UseG1GC                     9,428,000 ops/s   worst stall 332.0 ms   >10ms 208  >50ms 37  >100ms 37  >500ms 0 | full GCs 37
-XX:+UseZGC -XX:+ZGenerational   3,898,700 ops/s   worst stall 30.7 ms   >10ms 1  >50ms 0  >100ms 0  >500ms 0 | full GCs 0
-XX:+UseShenandoahGC             1,096,250 ops/s   worst stall 30.2 ms   >10ms 1  >50ms 0  >100ms 0  >500ms 0 | full GCs 0

Read these as shapes, not benchmark results. They come from one synthetic workload on a laptop, and the same collector moved a lot between runs — G1's worst stall was 780 ms in one run and 332 ms in the next. The shapes held across every run, including a first 1 GB run not shown here:

  • The pausing collectors stalled for hundreds of milliseconds — Serial, Parallel and G1 had worst stalls from about 240 ms to 1.8 s, and all ran full collections.
  • The concurrent collectors never stalled beyond about 130 ms — Shenandoah's worst, in that first run — usually far less, and never ran a full collection.
  • They paid in throughput on this machine. ZGC and Shenandoah did a fraction of the work of Parallel or G1 with a comfortable heap. Concurrent collection uses CPU the application would otherwise use, and with 4 CPUs there is little to spare.
  • Heap size changed everything for the pausing collectors. Parallel did 41 full collections and 1.9 million operations a second with 1 GB; with 2 GB, 5 full collections and 12.9 million. Most of what looks like a "bad collector" in production is a heap too small for its live set.

Choosing a collector

The default is chosen for you, and it is worth knowing what it is:

  • On a machine the JVM considers server-class — at least 2 CPUs and at least about 1.8 GB of memory — the default is G1.
  • Otherwise, the default is Serial. A container with 512 MB of memory gets Serial even with 2 CPUs; the profiling and tuning lesson shows that happening.

Then:

if the service needsconsider
balanced latency and throughput on a general heapG1, the default, with the heap sized properly first
consistently low pauses, and it has CPU headroomZGC (generational from Java 21 with -XX:+ZGenerational, the default mode from Java 23) or Shenandoah
maximum throughput and pauses do not matter — batch jobsParallel
a tiny heap or a single CPUSerial

And the order of operations matters more than the choice: size the heap for the live set first, measure pauses from GC logs second, change collector third. The profiling and tuning lesson covers reading those logs.

Progress is saved on this device and to your account when signed in.