Skip to content
About
Main content

GC cost against headroom: 1 / (H/L − 1)

Measure your machineNeeds Java 17+, Python

Problem

A tracing collector's work per collection is proportional to the L, and collections happen every time the free part of the , H − L, fills. So cost per byte allocated grows as L/(H − L) = 1/(H/L − 1): it explodes as the heap approaches the live set and flattens as headroom grows. Moving a large live set out of the heap (into one direct buffer) removes it from the collector's work entirely; it does nothing for garbage that is short-lived, which the young generation already handles cheaply.

Write a Java program that builds a live set of small objects (a HashMap<Long, byte[]> with millions of 50-byte values), then keeps allocating and discarding objects for 20 seconds. Run it with heap sizes from 330 MB to 2 GB. Compute H/L for each run (L is the live size after a full collection) and check that GC overhead roughly follows 1/(H/L − 1). Then move the map into a single ByteBuffer.allocateDirect region, and run with only short-lived objects. The script uses the Parallel collector so that all GC work is counted, scaled to an 8 GB laptop.

The question. How does GC's share of time follow headroom, and what does moving the live set off-heap do?


Predict

With a 1 GB heap for about 265 MB of live data (H/L ≈ 3.7), what share of time goes to GC?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    gc-cost-against-headroom-1-h.py81 linesDownload
    View the code
    gc-cost-against-headroom-1-h.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """GC cost against headroom: 1/(H/L - 1).
    
    A Java program builds a live set of small objects (a HashMap<Long, byte[]>
    of 50-byte values), then keeps allocating and discarding objects for a fixed
    time (short-lived garbage, and a share of map values replaced so that old
    objects die too). It runs at several heap sizes H; L is the live size after a
    full collection. Then the same live set moves into one direct ByteBuffer, and
    finally a run with only short-lived objects, on and off heap. GC time comes
    from the JVM's own collector counters (the same totals the GC log sums).
    Needs a JDK (javac, java) 17 or newer. Uses the Parallel collector so that
    all GC work is stop-the-world and counted.
    """
    import argparse, json, os, re, subprocess, tempfile
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--entries", type=int, default=2_000_000, help="Live map entries (50-byte values).")
    p.add_argument("--heaps", default="330m,400m,450m,550m,700m,1000m,2000m", help="Heap sizes (-Xmx) to run.")
    p.add_argument("--seconds", type=int, default=20, help="Churn time per run.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    JAVA = r"""
    import java.lang.management.*; import java.nio.ByteBuffer; import java.util.*;
    public class Churn {
      static long gcMillis() { long t = 0; for (GarbageCollectorMXBean g : ManagementFactory.getGarbageCollectorMXBeans()) t += g.getCollectionTime(); return t; }
      static long used() { System.gc(); return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed(); }
      public static void main(String[] a) {
        String mode = a[0]; int n = Integer.parseInt(a[1]); long secs = Long.parseLong(a[2]);
        Random r = new Random(1); HashMap<Long, byte[]> map = null; ByteBuffer buf = null;
        if (mode.equals("heap")) { map = new HashMap<>(n * 2); for (long i = 0; i < n; i++) map.put(i, new byte[50]); }
        if (mode.equals("offheap")) { buf = ByteBuffer.allocateDirect(n * 58); for (int i = 0; i < n; i++) buf.putLong(i * 58, i); }
        long live = used(); long g0 = gcMillis(); long t0 = System.nanoTime(); long ops = 0; long sink = 0;
        while (System.nanoTime() - t0 < secs * 1_000_000_000L) {
          for (int i = 0; i < 1000; i++) { byte[] tmp = new byte[100]; tmp[0] = (byte) i; sink += tmp[0]; }      // short-lived garbage
          int k = r.nextInt(n);
          if (map != null) for (int i = 0; i < 20; i++) map.put((long) ((k + i) % n), new byte[50]);           // old values die
          if (buf != null) for (int i = 0; i < 20; i++) buf.putLong(((k + i) % n) * 58, ops);                // overwritten in place
          ops++;
        }
        double wall = (System.nanoTime() - t0) / 1e6; long gc = gcMillis() - g0;
        System.out.println(live + " " + gc + " " + wall + " " + ops + " " + (sink & 1));
      }
    }
    """
    d = tempfile.mkdtemp(); open(os.path.join(d, "Churn.java"), "w").write(JAVA)
    subprocess.run(["javac", "Churn.java"], cwd=d, check=True)
    version = subprocess.run(["java", "-version"], capture_output=True, text=True).stderr.splitlines()[0]
    
    def run(mode, heap, entries):
        pr = subprocess.run(["java", f"-Xmx{heap}", f"-Xms{heap}", "-XX:+UseParallelGC", f"-XX:MaxDirectMemorySize=2g", "-cp", d, "Churn",
                             mode, str(entries), str(args.seconds)], capture_output=True, text=True)
        if pr.returncode != 0:
            return {"mode": mode, "heap_mb": None, "live_mb": None, "h_over_l": None, "gc_pct": None,
                    "outcome": "OutOfMemoryError" if "OutOfMemory" in pr.stderr else pr.stderr.strip().splitlines()[-1][:80]}
        live, gc, wall, ops, _ = pr.stdout.split()
        return {"mode": mode, "live_mb": int(live) / 2**20, "gc_ms": int(gc), "wall_ms": float(wall), "gc_pct": int(gc) / float(wall) * 100,
                "ops_per_s": int(ops) / float(wall) * 1000, "outcome": "ok"}
    
    def mb(h): return float(h[:-1]) * (1024 if h.endswith("g") else 1)
    rows = []
    for h in args.heaps.split(","):
        r = run("heap", h, args.entries); r["heap_mb"] = mb(h)
        if r["outcome"] == "ok":
            r["h_over_l"] = r["heap_mb"] / r["live_mb"]; r["model"] = 1 / (r["h_over_l"] - 1)
        rows.append(r)
        print(f"-Xmx{h:<6} " + (f"H/L {r['h_over_l']:.2f}  GC {r['gc_pct']:5.1f}% of the time  (1/(H/L-1) = {r['model']:.2f})" if r["outcome"] == "ok" else r["outcome"]))
    biggest = args.heaps.split(",")[1]
    other = []
    for mode, entries, label in (("offheap", args.entries, "live set in one direct ByteBuffer"), ("small", args.entries, "short-lived objects only")):
        r = run(mode, biggest, entries); r["case"] = label; r["heap_mb"] = mb(biggest); other.append(r)
        print(f"-Xmx{biggest:<6} {label:<36} GC {r['gc_pct']:5.1f}% of the time")
    heap_same = next(r for r in rows if r["heap_mb"] == mb(biggest)); other.insert(0, {**heap_same, "case": "live set as heap objects"})
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"java": version, "tables": {"heaps": rows, "layouts": other}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run gc-cost-against-headroom-1-h.py
    uv run gc-cost-against-headroom-1-h.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--entries2,000,000 Live map entries (50-byte values).
--heaps330m, 400m, 450m, 550m, 700m, 1000m, 2000m Heap sizes (-Xmx) to run.
--seconds20 Churn time per run.

Our run

GC share of time against heap headroomApple M2 · 8 cores · 8 GB
Data table
GC share of time against heap headroom
H / L (heap ÷ live set)GC share of wall time (%)
1.2583.2
1.5267.5
1.764.6
2.0860.9
2.6339.6
3.7321.4
7.277.17
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Heap sizes

Heap sizes
Heap (MB)H/LGC share1/(H/L − 1)
3301.2583.2%3.93
4001.5267.5%1.93
4501.764.6%1.42
5502.0860.9%0.928
7002.6339.6%0.612
1,0003.7321.4%0.366
2,0007.277.17%0.159

Where the live set lives (heap 400 MB)

Where the live set lives (heap 400 MB)
CaseGC share
live set as heap objects67.5%
live set in one direct ByteBuffer0%
short-lived objects only0%
Output · uv run gc-cost-against-headroom-1-h.py
-Xmx330m   H/L 1.25  GC  83.2% of the time  (1/(H/L-1) = 3.93)
-Xmx400m   H/L 1.52  GC  67.5% of the time  (1/(H/L-1) = 1.93)
-Xmx450m   H/L 1.70  GC  64.6% of the time  (1/(H/L-1) = 1.42)
-Xmx550m   H/L 2.08  GC  60.9% of the time  (1/(H/L-1) = 0.93)
-Xmx700m   H/L 2.63  GC  39.6% of the time  (1/(H/L-1) = 0.61)
-Xmx1000m  H/L 3.73  GC  21.4% of the time  (1/(H/L-1) = 0.37)
-Xmx2000m  H/L 7.27  GC   7.2% of the time  (1/(H/L-1) = 0.16)
-Xmx400m   live set in one direct ByteBuffer    GC   0.0% of the time
-Xmx400m   short-lived objects only             GC   0.0% of the time

What we found

GC's share fell steeply with headroom: 83.2% of the time at H/L = 1.25, 60.9% at 2.08, 21.4% at 3.73 and 7.17% at 7.27, the shape of 1/(H/L − 1), compressed at the top because a share of time cannot exceed 100%. With the live set in one direct buffer, GC took 0% at the same heap size; with only short-lived objects, also 0%: there was no old live set for off-heap to remove.


Read it

Headroom is the cheapest GC tuning there is, until the curve flattens; past H/L of three or four, more heap buys little. Off-heap storage helps exactly when a large, long-lived set of small objects is what the collector keeps re-tracing, and costs you manual layout and lifetime management in exchange.