← Java articles

Java notes

G1 vs ZGC at JDK 17+: Choosing a Collector and Sizing the Heap

When G1 still wins, what ZGC costs in memory and CPU, and how to measure it yourself

Shubham Anandani··~10 min read

What each collector optimizes

  • G1 is the default collector since JDK 9. It balances throughput and pause time, and works well across a wide range of heap sizes.
  • ZGC is built for low pause times that stay short regardless of heap size, using concurrent work. On JDK 17 it is non-generational, so it examines the whole heap each cycle. JDK 21 added a generational mode (JEP 439), which lowers memory and CPU overhead compared with the single-generation version JEP 439.
# JDK 17
-XX:+UseG1GC
-XX:+UseZGC

# JDK 21+ (generational ZGC)
-XX:+UseZGC -XX:+ZGenerational

Where the cost shows up

  • Plan for more memory when upgrading. Moving a service from G1 on an older JDK to ZGC can require a noticeably bigger heap and more native memory. Budget for it in your container limits, or the kernel's OOM killer will make the decision for you.
  • Watch RSS, not just heap. Total process memory includes metaspace, thread stacks, code cache, GC data structures, and direct buffers. A container limit equal to -Xmx is almost always too small.

When G1 still wins

  • Small to medium heaps where G1 pauses are already well inside your latency budget.
  • Throughput-oriented batch jobs, where a few longer pauses cost less than constant concurrent overhead.
  • CPU-constrained containers with very few cores, since concurrent GC threads need cores to run on.

When ZGC is worth it

  • Large heaps or strict tail-latency targets where G1's pauses show up in your p99.
  • Services with enough spare CPU and memory to give the collector room.
  • Workloads where a measured test shows fewer long pauses and no allocation stalls.

Heap sizing rules of thumb

  1. Measure the live set (memory still reachable after a full collection) from GC logs.
  2. Start with a heap at roughly 2x to 3x the live set for concurrent collectors, then adjust from measurement.
  3. Set -Xms equal to -Xmx for steady services, to avoid resize pauses.
  4. Keep the container limit well above -Xmx to leave room for native memory.
  5. Always turn on GC logging in production:
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

A load generator you can run

import java.util.*;
import java.util.concurrent.ThreadLocalRandom;

public final class AllocationLoad {

    public static void main(String[] args) {
        int cacheEntries = Integer.parseInt(args.length > 0 ? args[0] : "2000000");
        int operations   = Integer.parseInt(args.length > 1 ? args[1] : "5000000");

        Map<Integer, byte[]> cache = new HashMap<>();
        for (int i = 0; i < cacheEntries; i++) {
            cache.put(i, new byte[64]);                       // long-lived data
        }

        long[] latencyNanos = new long[operations];
        ThreadLocalRandom rnd = ThreadLocalRandom.current();

        for (int i = 0; i < operations; i++) {
            long start = System.nanoTime();

            byte[] shortLived = new byte[512 + rnd.nextInt(2048)];     // garbage
            shortLived[0] = 1;
            if ((i & 0x3FF) == 0) {                                    // occasional churn in the cache
                cache.put(rnd.nextInt(cacheEntries), new byte[64]);
            }
            blackhole += shortLived.length;

            latencyNanos[i] = System.nanoTime() - start;
        }

        Arrays.sort(latencyNanos);
        System.out.printf("p50=%dus p99=%dus p99.9=%dus max=%dus%n",
                latencyNanos[operations / 2] / 1_000,
                latencyNanos[(int) (operations * 0.99)] / 1_000,
                latencyNanos[(int) (operations * 0.999)] / 1_000,
                latencyNanos[operations - 1] / 1_000);
    }

    static volatile long blackhole;
}
for heap in 2g 4g 8g; do
  java -Xms$heap -Xmx$heap -XX:+UseG1GC  -Xlog:gc*:file=g1-$heap.log  AllocationLoad
  java -Xms$heap -Xmx$heap -XX:+UseZGC   -Xlog:gc*:file=zgc-$heap.log AllocationLoad
done

What to chart

Failure modes

  • A heap sized to the old collector's needs, causing allocation stalls under ZGC.
  • A container limit too close to the heap size, causing out-of-memory kills from native memory.
  • Judging by averages. GC problems live in the tail.

Takeaways

  • ZGC reduces pauses by spending CPU and memory. G1 remains a strong default.
  • Size the heap from the measured live set, and size the container from RSS.
  • Decide with your own tail-latency numbers, not slogans.