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
5.8 KiB
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/jepsreturned 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.