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.
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.
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:
| collector | longest pause | all pauses | wall clock | CPU (user) | GC's own memory |
|---|---|---|---|---|---|
| Serial | 26.6 ms | 1,413 ms | 2.08 s | 2.07 s | 0.8 MB |
| Parallel | 21.7 ms | 521 ms | 1.19 s | 2.50 s | 37.9 MB |
| G1 | 8.3 ms | 515 ms | 1.30 s | 2.65 s | 59.6 MB |
| Shenandoah | 5.0 ms | 49 ms | 1.16 s | 1.50 s | 15.5 MB |
| ZGC | 0.039 ms | 1.7 ms | 1.11 s | 1.65 s | 1.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.
what the JVM picks for you
| JDK | if you don't choose |
|---|---|
| 8 | Parallel (Serial on small "client" machines) |
| 9 to 26 | G1, 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:
[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 space58 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 want | flags |
|---|---|
| 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 |
- 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.