JVM GC / heap-tuning harness
JVM GC / heap-tuning harness
A small Java “service” allocation workload run under different garbage collectors
and heap sizes inside a digest-pinned eclipse-temurin:21-jdk container, measuring
what each choice does to request-latency tails and throughput.
The workload (Bench.java) keeps a bounded in-memory cache (the live working set
that sits in the old generation) and runs a request loop. Each request allocates a
few KB of short-lived garbage, touches a cache entry, and every Nth request replaces
a cache entry with a fresh payload — that replacement is what promotes objects into
the old generation and eventually triggers old/mixed/full collections.
Every request is timed with System.nanoTime(). A stop-the-world GC pause lands on
top of whatever request is running, so it shows up as a latency spike in the tail.
Percentiles come from a fixed microsecond-bucket histogram (no per-request
allocation, so the measurement doesn’t perturb the heap). Separately, the driver
parses the JVM’s own -Xlog:gc output to get real STW pause counts / totals / max,
and cross-checks them against the in-app tail.
Two experiments
-
1. Collector comparison (
collector_comparison.csv) — the same workload at a fixed-Xmxunder-XX:+UseParallelGC,-XX:+UseG1GC, and generational ZGC (-XX:+UseZGC -XX:+ZGenerational). The live set is deliberately large so a full/old collection has a lot to compact. Story: ParallelGC (a throughput collector) takes big stop-the-world pauses that wreck the tail; G1 is bounded but still visible; ZGC’s pauses are sub-millisecond, so the tail stays flat — at some throughput cost. -
2. Heap sizing thrash (
heap_sizing.csv) — collector fixed at G1, a smaller live set, and-Xmxswept from comfortable down to barely-fits. Story: undersize the heap and GC frequency and % time in GC explode while throughput collapses.
The two experiments use different workload scales on purpose (large live set for the collector story, small live set for the heap-sizing story) — a 2.5 GB live set would simply OOM at 256m. Within each experiment the workload is identical across every config (same seed, same live set, same op count); only the JVM flag under test changes.
Run it
Docker with Compose v2, plus Python 3.9+ (standard library only — nothing to pip
install).
cd benchmarks/java-gc-tuning
docker compose pull # fetch the digest-pinned JDK image
python benchmark.py # runs both experiments, writes results/
benchmark.py drives everything with docker run (compile Bench.java with
javac, then run java with the GC flags under test). There is no long-lived
service and no network — the workload is purely in-process CPU + heap, so
docker-compose.yml only exists to pin/fetch the image.
Every knob is an environment variable (see the top of benchmark.py): RUNS
(repeats per config, median reported), COLLECTOR_HEAP, HEAP_SIZES, and per-
experiment COL_* / HEAP_* workload sizes. Example — a faster smoke run:
RUNS=1 COL_OPS=500000 HEAP_OPS=1000000 python benchmark.py
Results
summary.txt— human-readable headline table for both experiments.collector_comparison.csv— per collector: GC pause count / total / max / p99, request-latency p50/p99/p99.9/max (µs), throughput (ops/s), % time in GC.heap_sizing.csv— per-Xmx: % time in GC, GC count, total pause, p99/p99.9 request latency, throughput.run_metadata.csv—java -version, image + sha256 digest, arch, run count, and both workload profiles (op counts, live-set sizes, heaps).attempts/— parked runs that didn’t reproduce a clean story, with notes.
Honesty notes
These are laptop measurements demonstrating the mechanism, not capacity planning.
GC pause magnitudes depend heavily on core count and memory bandwidth: this host has
10 cores, so ParallelGC’s parallel mark-compact is fast, and the dramatic
hundred-millisecond full-GC pauses only appear once the live set is a couple of GB
(a smaller heap gives tens-of-ms pauses, still an order of magnitude worse than
ZGC). Runs are repeated RUNS times and the median is reported; the collector
comparison is stable run-to-run, the small-heap thrash point is the noisiest number
because it lives right at the edge of fitting. The shape — big STW pauses on the
throughput collectors versus flat sub-millisecond tails on ZGC, and throughput
collapse when the heap is undersized — is not hardware-specific.