Files
Claude e83c9949d8 Add jdk27-memory: compact object headers and G1-everywhere on a Spring Boot service
Spring Boot 4.1.1 catalog service measured on JDK 25, 26 and 27: live heap 320 MB to 273 MB with compact headers, per-class bytes per instance, container-sizing sweeps with real cgroup limits, 1-CPU Serial vs G1 with Native Memory Tracking, removed flags (MaxRAM, UseCompressedClassPointers), and a kernel OOM-kill check. Outputs in jdk27-memory/output.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TF9JWFvJSNm6HVzswzZU5a
2026-09-24 15:09:55 +00:00
..

jdk27-memory: compact object headers and G1-everywhere, measured on a Spring Boot service

Companion module for JDK 27 Memory Changes: Compact Object Headers by Default and G1 Everywhere (JEP 534 + 523) on ankurm.com. Everything the post quotes is a file in output/, regenerated by run.sh. The explanation lives in the post; this repository holds the code and the raw transcripts.

The module sits inside the zgc-jdk25-benchmarks repository because it answers the follow-up question to the G1 vs Generational ZGC work: what did the default collector and the object header do to the same kind of heap on JDK 27.

What is here

Path What it is
src/main/java/com/ankurm/memory/ A Spring Boot 4.1.1 service holding a 2,000,000-product catalog in a Map<Long, Product>, with /load, /memory, /lookups, /products/{id} and /ping
src/offsets/Offsets.java Prints where the first field of an object starts (JDK 25/26: byte 12; JDK 27: byte 8)
scripts/footprint.sh The same jar on JDK 25, 26 and 27, compact headers on and off, N runs each
scripts/classes.sh Bytes per instance of the classes that dominate the heap, from jcmd GC.class_histogram
scripts/flags.sh What each JDK chooses by default (collector, header mode, heap) and which flags still work
scripts/container-sizing.sh The smallest container that can hold the catalog, before and after, using real cgroup limits
scripts/one-cpu.sh A 1 GB, 1-CPU container: Serial (JDK 26) versus G1 (JDK 27), with Native Memory Tracking
scripts/oom-check.sh Runs the edge case of the sweep four times and reads the cgroup's counters and the kernel log, to show that "killed" is the kernel's memory controller
scripts/sample.sh A transcript of the service's own endpoints
scripts/lib.sh, scripts/env.sh Shared start/load/measure logic and JDK locations

Output

File Contents
output/00-sample-endpoints.txt The endpoints on JDK 27: idle, loaded, one lookup, one 404
output/01-jvm-defaults.txt Default collector and header mode per JDK, in containers of 1 CPU and 2 CPUs, and the flags that no longer work
output/02-footprint.txt Every run of the six-configuration matrix, medians, and the runs grouped by whether a collection happened during the load
output/03-classes.txt Per-class bytes per instance, JDK 26 vs JDK 27 vs JDK 27 opted out
output/04-offsets.txt Field offsets on JDK 25, 26, 27 and 27 opted out
output/05-container-sizing.txt Pass/fail per memory limit, with the failure kind (Java OutOfMemoryError, kernel kill, or no progress)
output/06-one-cpu.txt Serial vs G1 in a 1 GB, 1-CPU container: footprint, GC time, and the collector's native memory
output/07-oom-check.txt Four runs at the 400 MB edge: HTTP status, exit code, memory.failcnt, oom_kill, and the kernel log line for each killed process

Headline result

Two million products, -Xms1g -Xmx1g, G1, five runs per configuration (output/02-footprint.txt):

Configuration Live heap after loading Idle live heap
JDK 25 default 320 MB 12 MB
JDK 26 default 320 MB 13 MB
JDK 27 -XX:-UseCompactObjectHeaders 320 MB 13 MB
JDK 25 / 26 -XX:+UseCompactObjectHeaders 273 MB 11 MB
JDK 27 default 273 MB 12 MB

The saving is the header, not the JDK version: JDK 25 and 26 reach the same 273 MB with the flag. output/03-classes.txt shows where it comes from: Product 40 to 32 bytes, HashMap$Node 32 to 24, Long 24 to 16, while String (24) and the byte[] behind it (about 32.6) do not move.

Resident memory and lookup time did not follow the heap cleanly, and the reason is in the data rather than in the headers: they depend on whether a young collection happened during the load. The last block of 02-footprint.txt groups the runs to show it.

Running it

Needs three JDKs (25, 26, 27), Maven and curl. The container runs additionally need root and a writable cgroup v1 hierarchy: the sandbox this was written in has no Docker daemon, so scripts/lib.sh creates cgroups with a memory limit and CPU quota by hand, which is what Docker does and what the JVM reads. On a machine with Docker you can reproduce the same effect with docker run --memory=... --cpus=....

cd jdk27-memory
JDK25=/path/to/jdk-25 JDK26=/path/to/jdk-26 JDK27=/path/to/jdk-27 ./run.sh      # about 45 minutes
FAST=1 ./run.sh                                                                 # one run per configuration

Each script also runs on its own, for example bash scripts/flags.sh or bash scripts/footprint.sh 2000000 5.

Caveats

  • One 2-CPU virtual machine. Timings are noisy; the live-heap figures are not (they varied by at most 1 MB across runs).
  • Compiled for Java 25 and run on 25, 26 and 27, so the jar is identical across runs.
  • openjdk.org/jeps returned HTTP 403 to the tooling used, so JEP claims come from running the JDKs, not from the JEP text.
  • No third-party agent, profiler or serialisation library was tested against compact headers.