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
This commit is contained in:
Claude
2026-09-24 15:09:55 +00:00
parent e189da79ba
commit e83c9949d8
29 changed files with 1041 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# 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)](https://ankurm.com/jdk-27-compact-object-headers-g1-default-memory-spring-boot/)**
on [ankurm.com](https://ankurm.com). Everything the post quotes is a file in [`output/`](output), regenerated by [`run.sh`](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](https://ankurm.com/generational-zgc-jdk-25-vs-g1/) 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/`](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`](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`](scripts/footprint.sh) | The same jar on JDK 25, 26 and 27, compact headers on and off, N runs each |
| [`scripts/classes.sh`](scripts/classes.sh) | Bytes per instance of the classes that dominate the heap, from `jcmd GC.class_histogram` |
| [`scripts/flags.sh`](scripts/flags.sh) | What each JDK chooses by default (collector, header mode, heap) and which flags still work |
| [`scripts/container-sizing.sh`](scripts/container-sizing.sh) | The smallest container that can hold the catalog, before and after, using real cgroup limits |
| [`scripts/one-cpu.sh`](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`](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`](scripts/sample.sh) | A transcript of the service's own endpoints |
| [`scripts/lib.sh`](scripts/lib.sh), [`scripts/env.sh`](scripts/env.sh) | Shared start/load/measure logic and JDK locations |
## Output
| File | Contents |
|---|---|
| [`output/00-sample-endpoints.txt`](output/00-sample-endpoints.txt) | The endpoints on JDK 27: idle, loaded, one lookup, one 404 |
| [`output/01-jvm-defaults.txt`](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`](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`](output/03-classes.txt) | Per-class bytes per instance, JDK 26 vs JDK 27 vs JDK 27 opted out |
| [`output/04-offsets.txt`](output/04-offsets.txt) | Field offsets on JDK 25, 26, 27 and 27 opted out |
| [`output/05-container-sizing.txt`](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`](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`](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`](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`](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=...`.
```console
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.