skip to content
everything.gc.choosingAGc

choosing a collector

throughput, latency, footprint. pick two.

chapter
chapter 14.10
reading time
6 min read

every collector trades between three things: throughput (how much of the CPU your app gets), latency (how long the pauses are) and footprint (how much memory it all takes). you can have two of them cheaply. all three costs money, usually in the form of more heap or more cores.

start with the default (G1), measure, and switch only when a GC log tells you to.

the picture

you know the "fast, cheap, good: pick two" joke? garbage collection is that, with real numbers attached. a stop-the-world collector wastes the least CPU but makes everyone wait. a concurrent collector barely pauses but needs spare cores and spare heap to stay ahead. a tiny collector saves memory but can't be both.

G1ZGCParallelSeriallow latencythroughputsmall footprint
G1(nothing: it's the default) or -XX:+UseG1GC

a bit of everything, which is most services. predictable pauses around a goal, decent throughput, sane footprint. start here and only move if a GC log tells you to.

longest pause
8.3 ms
all pauses
515 ms
wall clock
1.30 s
GC's own memory
59.6 MB
weights: latency 33% · throughput 33% · footprint 33%. drag the point, or focus the triangle and use the arrow keys.
drag the point toward what you care about most. the numbers under each collector are measured on the same program (the toy service from serial and parallel, 256 MB heap, jdk 25, median of three runs on a busy 14-core laptop), so compare them with each other, not with your production.

see how lopsided it is? on this program, ZGC's longest pause is hundreds of times shorter than Serial's, and its pauses add up to under 2 ms for the whole run. but this laptop had idle cores to lend. take those away and the picture changes: concurrent collectors do their work on other threads, and if there's no spare CPU, they fall behind.

the whole table

here are all five on the same program, same settings. read it as "what happens to this workload", not as a ranking:

collectorlongest pauseall pauseswall clockCPU (user)GC's own memory
Serial26.6 ms1,413 ms2.08 s2.07 s0.8 MB
Parallel21.7 ms521 ms1.19 s2.50 s37.9 MB
G18.3 ms515 ms1.30 s2.65 s59.6 MB
Shenandoah5.0 ms49 ms1.16 s1.50 s15.5 MB
ZGC0.039 ms1.7 ms1.11 s1.65 s1.4 MB

a few honest caveats. this program has one app thread on a 14-core machine, so the concurrent collectors had 13 cores to themselves (which is why their CPU numbers look so good). "GC's own memory" is the native memory tracking GC category of an idle program with a 256 MB heap: ZGC keeps most of its bookkeeping elsewhere and reserves a lot more address space than the others, so don't read that column as "ZGC is free". and one toy program is one toy program. your app's mix of allocation rate, live set and thread count decides which collector wins. that's why the last step is always reading the logs of your own app under real load.

quick check
a 2 vCPU container, 1 GB of memory, a REST service. what's the reasonable first pick?

what the JVM picks for you

JDKif you don't choose
8Parallel (Serial on small "client" machines)
9 to 26G1, except Serial when the machine has 1 CPU or less than 1792 MB of memory (JEP 248)
27+G1, always (JEP 523)

in a container, "the machine" is whatever the container limits say. i checked on jdk 25: -XX:ActiveProcessorCount=1 is enough to make the JVM pick Serial. the default max heap is 25% of the memory it sees (-XX:MaxRAMPercentage=25), except on tiny machines where it goes up to half: with a 256 MB limit you get a 128 MB heap, with 512 MB also 128 MB, with 4 GB 1 GB. more in the JVM in containers.

Epsilon: the collector that doesn't

-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC (java 11, JEP 318) allocates and never frees anything. when the heap is full, the JVM exits. here's the toy service on a 64 MB heap:

java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx64m -Xlog:gc Service.java
[0.010s][info][gc] Using Epsilon
[0.029s][info][gc] Heap: 65536K reserved, 65536K (100.00%) committed, 5510K (8.41%) used
...
[0.058s][info][gc] Heap: 65536K reserved, 65536K (100.00%) committed, 63399K (96.74%) used
Terminating due to java.lang.OutOfMemoryError: Java heap space

58 milliseconds. that's the point: it's for runs where you know the total allocation fits (short-lived jobs, some serverless functions), for measuring a program's raw allocation and barrier-free speed, and for testing that latency-sensitive code really doesn't allocate. it is not a production collector for anything long-running.

the cheat sheet

you wantflags
the default, with a pause goal-XX:MaxGCPauseMillis=100 (G1, nothing else needed)
max throughput, batch-XX:+UseParallelGC
tiny heap, few cores-XX:+UseSerialGC
lowest pauses-XX:+UseZGC
lowest pauses, compressed oops-XX:+UseShenandoahGC (generational: add -XX:ShenandoahGCMode=generational)
no GC at all-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC
heap size-Xmx, -Xms, or -XX:MaxRAMPercentage in containers
see what it's doing-Xlog:gc*:file=gc.log:time,uptime,level,tags
keylines
  • throughput, latency, footprint: every collector picks a spot in that triangle.
  • G1 is the default (since 9, and everywhere since 27). start there.
  • ZGC and Shenandoah buy tiny pauses with spare CPU and heap headroom.
  • Parallel wins batch jobs. Serial wins tiny heaps. Epsilon never frees anything.
  • tune from your own GC logs, one flag at a time.