# 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`, 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.