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 live set L, and collections happen every time the free part of the heap, H − L, fills. So GC 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
GC overhead should roughly follow 1/(H/L − 1): it should explode as H/L approaches 1, then flatten. Moving the map off-heap, expect the mark cost to fall sharply. With only short-lived objects, off-heap should not help.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What 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
Data table
| H / L (heap ÷ live set) | GC share of wall time (%) |
|---|---|
| 1.25 | 83.2 |
| 1.52 | 67.5 |
| 1.7 | 64.6 |
| 2.08 | 60.9 |
| 2.63 | 39.6 |
| 3.73 | 21.4 |
| 7.27 | 7.17 |
Heap sizes
| Heap (MB) | H/L | GC share | 1/(H/L − 1) |
|---|---|---|---|
| 330 | 1.25 | 83.2% | 3.93 |
| 400 | 1.52 | 67.5% | 1.93 |
| 450 | 1.7 | 64.6% | 1.42 |
| 550 | 2.08 | 60.9% | 0.928 |
| 700 | 2.63 | 39.6% | 0.612 |
| 1,000 | 3.73 | 21.4% | 0.366 |
| 2,000 | 7.27 | 7.17% | 0.159 |
Where the live set lives (heap 400 MB)
| Case | GC share |
|---|---|
| live set as heap objects | 67.5% |
| live set in one direct ByteBuffer | 0% |
| short-lived objects only | 0% |
-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 timeWhat 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.