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
+1
View File
@@ -71,6 +71,7 @@ the part most "ZGC is faster" posts leave out.
| [`internals/PageSizeDemo.java`](internals/src/com/ankurm/zgc/internals/PageSizeDemo.java) | ZGC page classes vs G1 humongous, read from the live VM | none |
| [`analysis/GcLogParser.java`](analysis/src/com/ankurm/zgc/analysis/GcLogParser.java) | Unified-log parser that keeps pauses and concurrent phases apart | none |
| [`jmh/`](jmh/src/main/java/com/ankurm/zgc/jmh) | Throughput and barrier microbenchmarks | JMH 1.37 |
| [`jdk27-memory/`](jdk27-memory) | Spring Boot 4.1 service measured on JDK 25, 26 and 27: compact object headers (JEP 534) and G1 everywhere (JEP 523), heap footprint, container sizing with real cgroup limits. Its README and `output/` replace a `docs/` folder; the write-up is [the post](https://ankurm.com/jdk-27-compact-object-headers-g1-default-memory-spring-boot/) | Spring Boot 4.1.1, three JDKs |
### JMH benchmarks
+1
View File
@@ -0,0 +1 @@
target/
+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.
@@ -0,0 +1,41 @@
$ curl -s -w ' [HTTP %{http_code}]\n' localhost:18081/memory
java.version=27
UseCompactObjectHeaders=true
collectors=[G1 Young Generation, G1 Concurrent GC, G1 Old Generation]
availableProcessors=2
maxHeapMB=1024
products=0
heapLiveAfterGcMB=12
heapCommittedMB=1024
rssMB=238
gcCountTotal=7
gcMillisTotal=121
[HTTP 200]
$ curl -s -w ' [HTTP %{http_code}]\n' localhost:18081/load?n=2000000
loaded=2000000
loadMillis=237
gcCountDuringLoad=0
gcMillisDuringLoad=0
[HTTP 200]
$ curl -s -w ' [HTTP %{http_code}]\n' localhost:18081/memory
java.version=27
UseCompactObjectHeaders=true
collectors=[G1 Young Generation, G1 Concurrent GC, G1 Old Generation]
availableProcessors=2
maxHeapMB=1024
products=2000000
heapLiveAfterGcMB=272
heapCommittedMB=1024
rssMB=417
gcCountTotal=9
gcMillisTotal=626
[HTTP 200]
$ curl -s -w ' [HTTP %{http_code}]\n' localhost:18081/products/42
{"id":42,"priceCents":142,"stock":42,"active":true,"sku":"SKU-100000042"} [HTTP 200]
$ curl -s -w ' [HTTP %{http_code}]\n' localhost:18081/products/2000000
{"timestamp":"2026-09-24T14:57:54.671Z","status":404,"error":"Not Found","path":"/products/2000000"} [HTTP 404]
+44
View File
@@ -0,0 +1,44 @@
== JDK 25: OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
default (this machine: 2 CPUs) -> compact=false collector=UseG1GC MaxHeapSize=1498MB
-XX:+UseCompactObjectHeaders -> compact=true collector=UseG1GC MaxHeapSize=1498MB
-XX:-UseCompactObjectHeaders -> compact=false collector=UseG1GC MaxHeapSize=1498MB
== JDK 26: OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)
default (this machine: 2 CPUs) -> compact=false collector=UseG1GC MaxHeapSize=1498MB
-XX:+UseCompactObjectHeaders -> compact=true collector=UseG1GC MaxHeapSize=1498MB
-XX:-UseCompactObjectHeaders -> compact=false collector=UseG1GC MaxHeapSize=1498MB
== JDK 27: OpenJDK Runtime Environment Temurin-27+35 (build 27+35)
default (this machine: 2 CPUs) -> compact=true collector=UseG1GC MaxHeapSize=1498MB
-XX:+UseCompactObjectHeaders -> compact=true collector=UseG1GC MaxHeapSize=1498MB
-XX:-UseCompactObjectHeaders -> compact=false collector=UseG1GC MaxHeapSize=1498MB
== The same JDKs in a container: 1 GB memory limit, 1 CPU (a cgroup, set the way Docker sets it)
JDK 25, 1 GB, 1 CPU -> compact=false collector=UseSerialGC MaxHeapSize=256MB
JDK 25, 1 GB, 1 CPU, MaxRAMPercentage=75 -> compact=false collector=UseSerialGC MaxHeapSize=768MB
JDK 26, 1 GB, 1 CPU -> compact=false collector=UseSerialGC MaxHeapSize=256MB
JDK 26, 1 GB, 1 CPU, MaxRAMPercentage=75 -> compact=false collector=UseSerialGC MaxHeapSize=768MB
JDK 27, 1 GB, 1 CPU -> compact=true collector=UseG1GC MaxHeapSize=256MB
JDK 27, 1 GB, 1 CPU, MaxRAMPercentage=75 -> compact=true collector=UseG1GC MaxHeapSize=768MB
== 1 GB, 2 CPUs
JDK 26, 1 GB, 2 CPUs -> compact=false collector=UseSerialGC MaxHeapSize=256MB
JDK 27, 1 GB, 2 CPUs -> compact=true collector=UseG1GC MaxHeapSize=256MB
== -XX:MaxRAM: the flag many people used to fake a container limit
JDK 26 -XX:MaxRAM=1g -> compact=false collector=UseG1GC MaxHeapSize=256MB
JVM says: OpenJDK 64-Bit Server VM warning: Option MaxRAM was deprecated in version 26.0 and will likely be removed in a future release.
JDK 27 -XX:MaxRAM=1g -> compact=true collector=UseG1GC MaxHeapSize=1498MB
JVM says: OpenJDK 64-Bit Server VM warning: Ignoring option MaxRAM; support was removed in 27.0
== Compact headers with each collector on JDK 27
JDK 27 -XX:+UseG1GC -> compact=true collector=UseG1GC MaxHeapSize=1498MB
JDK 27 -XX:+UseParallelGC -> compact=true collector=UseParallelGC MaxHeapSize=1498MB
JDK 27 -XX:+UseSerialGC -> compact=true collector=UseSerialGC MaxHeapSize=1498MB
JDK 27 -XX:+UseZGC -> compact=true collector=UseZGC MaxHeapSize=1498MB
== Compressed pointers on JDK 27
JDK 27 -XX:-UseCompressedClassPointers -> compact=true collector=UseG1GC MaxHeapSize=1498MB
JVM says: OpenJDK 64-Bit Server VM warning: Ignoring option UseCompressedClassPointers; support was removed in 27.0
JDK 27 -XX:-UseCompressedOops -> compact=true collector=UseG1GC MaxHeapSize=1498MB
JDK 27 -Xmx32g (a large explicit heap) -> compact=true collector=UseG1GC MaxHeapSize=32768MB
+52
View File
@@ -0,0 +1,52 @@
# 2026-09-24 2000000 products, -Xms1g -Xmx1g, G1, 5 runs per configuration; MED lines are medians
# JDK25=OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
# JDK26=OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)
# JDK27=OpenJDK Runtime Environment Temurin-27+35 (build 27+35)
run jdk25-default/1 idle=13MB live=321MB rss=462MB gcDuringLoad=0 (0ms) lookup=246ns compact=false
run jdk25-default/2 idle=12MB live=320MB rss=461MB gcDuringLoad=0 (0ms) lookup=310ns compact=false
run jdk25-default/3 idle=13MB live=320MB rss=696MB gcDuringLoad=1 (213ms) lookup=187ns compact=false
run jdk25-default/4 idle=12MB live=321MB rss=462MB gcDuringLoad=0 (0ms) lookup=244ns compact=false
run jdk25-default/5 idle=12MB live=320MB rss=459MB gcDuringLoad=0 (0ms) lookup=282ns compact=false
MED jdk25-default idle=12MB live=320MB rss=462MB gcDuringLoad=0 gcMs=0 lookupNs=246
run jdk25-compact/1 idle=12MB live=273MB rss=416MB gcDuringLoad=0 (0ms) lookup=256ns compact=true
run jdk25-compact/2 idle=11MB live=273MB rss=415MB gcDuringLoad=0 (0ms) lookup=245ns compact=true
run jdk25-compact/3 idle=11MB live=273MB rss=415MB gcDuringLoad=0 (0ms) lookup=265ns compact=true
run jdk25-compact/4 idle=11MB live=273MB rss=667MB gcDuringLoad=1 (236ms) lookup=158ns compact=true
run jdk25-compact/5 idle=11MB live=272MB rss=413MB gcDuringLoad=0 (0ms) lookup=256ns compact=true
MED jdk25-compact idle=11MB live=273MB rss=415MB gcDuringLoad=0 gcMs=0 lookupNs=256
run jdk26-default/1 idle=13MB live=320MB rss=665MB gcDuringLoad=1 (203ms) lookup=202ns compact=false
run jdk26-default/2 idle=13MB live=320MB rss=693MB gcDuringLoad=1 (216ms) lookup=199ns compact=false
run jdk26-default/3 idle=13MB live=320MB rss=701MB gcDuringLoad=1 (206ms) lookup=181ns compact=false
run jdk26-default/4 idle=13MB live=320MB rss=647MB gcDuringLoad=1 (208ms) lookup=210ns compact=false
run jdk26-default/5 idle=13MB live=320MB rss=697MB gcDuringLoad=1 (198ms) lookup=188ns compact=false
MED jdk26-default idle=13MB live=320MB rss=693MB gcDuringLoad=1 gcMs=206 lookupNs=199
run jdk26-compact/1 idle=12MB live=274MB rss=419MB gcDuringLoad=0 (0ms) lookup=250ns compact=true
run jdk26-compact/2 idle=11MB live=273MB rss=645MB gcDuringLoad=1 (209ms) lookup=176ns compact=true
run jdk26-compact/3 idle=11MB live=273MB rss=416MB gcDuringLoad=0 (0ms) lookup=271ns compact=true
run jdk26-compact/4 idle=11MB live=273MB rss=418MB gcDuringLoad=0 (0ms) lookup=256ns compact=true
run jdk26-compact/5 idle=11MB live=273MB rss=615MB gcDuringLoad=1 (207ms) lookup=237ns compact=true
MED jdk26-compact idle=11MB live=273MB rss=419MB gcDuringLoad=0 gcMs=0 lookupNs=250
run jdk27-default/1 idle=12MB live=273MB rss=413MB gcDuringLoad=0 (0ms) lookup=298ns compact=true
run jdk27-default/2 idle=12MB live=272MB rss=412MB gcDuringLoad=0 (0ms) lookup=223ns compact=true
run jdk27-default/3 idle=12MB live=273MB rss=413MB gcDuringLoad=0 (0ms) lookup=318ns compact=true
run jdk27-default/4 idle=11MB live=273MB rss=594MB gcDuringLoad=1 (202ms) lookup=194ns compact=true
run jdk27-default/5 idle=12MB live=273MB rss=409MB gcDuringLoad=0 (0ms) lookup=257ns compact=true
MED jdk27-default idle=12MB live=273MB rss=413MB gcDuringLoad=0 gcMs=0 lookupNs=257
run jdk27-optout/1 idle=13MB live=319MB rss=454MB gcDuringLoad=0 (0ms) lookup=267ns compact=false
run jdk27-optout/2 idle=13MB live=319MB rss=461MB gcDuringLoad=0 (0ms) lookup=261ns compact=false
run jdk27-optout/3 idle=13MB live=320MB rss=459MB gcDuringLoad=0 (0ms) lookup=256ns compact=false
run jdk27-optout/4 idle=13MB live=320MB rss=616MB gcDuringLoad=1 (191ms) lookup=184ns compact=false
run jdk27-optout/5 idle=13MB live=320MB rss=454MB gcDuringLoad=0 (0ms) lookup=260ns compact=false
MED jdk27-optout idle=13MB live=320MB rss=459MB gcDuringLoad=0 gcMs=0 lookupNs=260
== medians of 5 runs per configuration (idle = live set of the idle app; live = after loading the catalog)
MED jdk25-default idle=12MB live=320MB rss=462MB gcDuringLoad=0 gcMs=0 lookupNs=246
MED jdk25-compact idle=11MB live=273MB rss=415MB gcDuringLoad=0 gcMs=0 lookupNs=256
MED jdk26-default idle=13MB live=320MB rss=693MB gcDuringLoad=1 gcMs=206 lookupNs=199
MED jdk26-compact idle=11MB live=273MB rss=419MB gcDuringLoad=0 gcMs=0 lookupNs=250
MED jdk27-default idle=12MB live=273MB rss=413MB gcDuringLoad=0 gcMs=0 lookupNs=257
MED jdk27-optout idle=13MB live=320MB rss=459MB gcDuringLoad=0 gcMs=0 lookupNs=260
== the same runs, grouped by whether a collection ran while the catalog was loading (all configurations together)
no collection during load runs=19 rss MB: min 409 / median 418 / max 462 lookup ns: min 223 / median 257 / max 318
a collection during load runs=11 rss MB: min 594 / median 665 / max 701 lookup ns: min 158 / median 188 / max 237
+12
View File
@@ -0,0 +1,12 @@
== bytes per instance (= #bytes / #instances) from jcmd GC.class_histogram after loading 2,000,000 products
class JDK 26 default JDK 27 default JDK 27 opt-out
com.ankurm.memory.Product 40.0 32.0 40.0
java.util.HashMap$Node 32.0 24.0 32.0
java.lang.Long 24.0 16.0 24.0
java.lang.String 24.0 24.0 24.0
[B 32.7 32.6 32.7
== total bytes of those five classes
jdk26-default 309088088 bytes (294.8 MB)
jdk27-default 260853264 bytes (248.8 MB)
jdk27-optout 309272896 bytes (294.9 MB)
+40
View File
@@ -0,0 +1,40 @@
== JDK 25
java 25.0.4.1 compact headers: false
id offset 16
priceCents offset 12
stock offset 24
active offset 28
sku offset 32
first field starts at byte 12 (so the header is at most 12 bytes)
a hard-coded 12-byte header would be right here
== JDK 26
java 26.0.2.1 compact headers: false
id offset 16
priceCents offset 12
stock offset 24
active offset 28
sku offset 32
first field starts at byte 12 (so the header is at most 12 bytes)
a hard-coded 12-byte header would be right here
== JDK 27
java 27 compact headers: true
id offset 8
priceCents offset 16
stock offset 20
active offset 24
sku offset 28
first field starts at byte 8 (so the header is at most 8 bytes)
a hard-coded 12-byte header would be WRONG here
== JDK 27 -XX:-UseCompactObjectHeaders
java 27 compact headers: false
id offset 16
priceCents offset 12
stock offset 24
active offset 28
sku offset 32
first field starts at byte 12 (so the header is at most 12 bytes)
a hard-coded 12-byte header would be right here
@@ -0,0 +1,20 @@
# 2000000 products; 2 CPUs; 3 runs per cell; rss is the median of the runs that finished
== Heap set to 75% of the container (-XX:MaxRAMPercentage=75)
limit heap JDK 26 default JDK 27 default JDK 27 opt-out
400MB 300MB 0/3 ok [1x java-OOM 2x no-result-in-25s] 3/3 ok rss 408MB 0/3 ok [3x killed]
416MB 312MB 0/3 ok [3x java-OOM] 3/3 ok rss 415MB 0/3 ok [3x java-OOM]
432MB 324MB 0/3 ok [3x java-OOM] 3/3 ok rss 406MB 3/3 ok rss 450MB
448MB 336MB 3/3 ok rss 454MB 3/3 ok rss 418MB 3/3 ok rss 458MB
512MB 384MB 3/3 ok rss 468MB 3/3 ok rss 417MB 3/3 ok rss 464MB
704MB 528MB 3/3 ok rss 527MB 3/3 ok rss 431MB 3/3 ok rss 465MB
== Heap left at the JVM default (25% of the container)
limit heap JDK 26 default JDK 27 default JDK 27 opt-out
1024MB 256MB 0/3 ok [3x java-OOM] 0/3 ok [3x java-OOM] 0/3 ok [3x java-OOM]
1152MB 288MB 0/3 ok [3x java-OOM] 3/3 ok rss 410MB 0/3 ok [3x java-OOM]
1216MB 304MB 0/3 ok [3x java-OOM] 3/3 ok rss 409MB 0/3 ok [3x java-OOM]
1280MB 320MB 0/3 ok [3x java-OOM] 3/3 ok rss 408MB 3/3 ok rss 444MB
1536MB 384MB 3/3 ok rss 465MB 3/3 ok rss 417MB 3/3 ok rss 463MB
2048MB 512MB 3/3 ok rss 485MB 3/3 ok rss 435MB 3/3 ok rss 486MB
# runs that needed a startup retry (the JVM never came up, for a reason other than OOM or a kernel kill): 0
+26
View File
@@ -0,0 +1,26 @@
# 2000000 products, container = 1 GB / 1 CPU (cgroup), -XX:MaxRAMPercentage=50 (512 MB heap), NMT summary on, medians of 3 runs
== jdk26-default (the JVM chose)
collectors=[Copy, MarkSweepCompact] cpus=1
startup=4.808s live=320MB rss=529MB loadMillis=695 gcDuringLoad=9 (523ms)
NMT: GC committed=1730 KB Total committed=597325 KB
== jdk26 -XX:+UseG1GC
collectors=[G1 Young Generation, G1 Concurrent GC, G1 Old Generation] cpus=1
startup=4.8s live=320MB rss=486MB loadMillis=909 gcDuringLoad=12 (517ms)
NMT: GC committed=46627 KB Total committed=642767 KB
== jdk27-default (the JVM chose)
collectors=[G1 Young Generation, G1 Concurrent GC, G1 Old Generation] cpus=1
startup=4.8s live=272MB rss=436MB loadMillis=700 gcDuringLoad=8 (472ms)
NMT: GC committed=43443 KB Total committed=493486 KB
== jdk27 -XX:+UseSerialGC
collectors=[Copy, MarkSweepCompact] cpus=1
startup=5.101s live=271MB rss=482MB loadMillis=692 gcDuringLoad=9 (495ms)
NMT: GC committed=1730 KB Total committed=597439 KB
== jdk27 -XX:+UseSerialGC, compact off
collectors=[Copy, MarkSweepCompact] cpus=1
startup=4.706s live=318MB rss=530MB loadMillis=630 gcDuringLoad=9 (487ms)
NMT: GC committed=1730 KB Total committed=597465 KB
+17
View File
@@ -0,0 +1,17 @@
# JDK 27, -XX:-UseCompactObjectHeaders -XX:MaxRAMPercentage=75, container limit 400 MB, 2 CPUs; 2,000,000 products
== attempt 1: /load answered HTTP 000; process gone, exit 137
memory.failcnt=155 memory.max_usage=400MB limit=400MB oom_kill 1
== attempt 2: /load answered HTTP 500; process still alive
memory.failcnt=0 memory.max_usage=397MB limit=400MB oom_kill 0
== attempt 3: /load answered HTTP 000; process gone, exit 137
memory.failcnt=182 memory.max_usage=400MB limit=400MB oom_kill 1
== attempt 4: /load answered HTTP 000; process gone, exit 137
memory.failcnt=101 memory.max_usage=400MB limit=400MB oom_kill 1
== the kernel log (dmesg): what it says about the processes that disappeared above
Memory cgroup out of memory: Killed process 21173 (java) total-vm:2923028kB, anon-rss:407580kB, file-rss:27492kB, shmem-rss:0kB, UID:0 pgtables:1128kB oom_score_adj:0
Memory cgroup out of memory: Killed process 21325 (java) total-vm:2920972kB, anon-rss:407492kB, file-rss:27304kB, shmem-rss:0kB, UID:0 pgtables:1128kB oom_score_adj:0
Memory cgroup out of memory: Killed process 21400 (java) total-vm:2920972kB, anon-rss:407424kB, file-rss:27080kB, shmem-rss:0kB, UID:0 pgtables:1128kB oom_score_adj:0
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>jdk27-memory</artifactId>
<version>1.0.0</version>
<name>jdk27-memory</name>
<description>A Spring Boot catalog service used to measure heap footprint on JDK 25, 26 and 27</description>
<properties>
<!-- Compiled once for 25 so the same jar runs on 25, 26 and 27: only the JVM changes between runs. -->
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<finalName>catalog</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Regenerates every file in output/. Needs three JDKs and, for the container runs, root plus a writable
# cgroup v1 hierarchy (the sandbox this was written in has no Docker daemon, so the limits are set by hand
# the way Docker sets them). Takes about 45 minutes.
# JDK25=/path JDK26=/path JDK27=/path ./run.sh
# FAST=1 ./run.sh one run per configuration, for a quick look
set -u
cd "$(dirname "$0")"
ORIG_JTO="${JAVA_TOOL_OPTIONS:-}" # kept only for Maven, which may need a proxy or trust store
source scripts/env.sh
RUNS=5; CRUNS=3
if [ -n "${FAST:-}" ]; then RUNS=1; CRUNS=1; fi
if [ ! -f target/catalog.jar ]; then
JAVA_HOME="$JDK25" JAVA_TOOL_OPTIONS="$ORIG_JTO" mvn -q -B -DskipTests package
fi
mkdir -p output
bash scripts/sample.sh > output/00-sample-endpoints.txt
bash scripts/flags.sh > output/01-jvm-defaults.txt
bash scripts/footprint.sh 2000000 "$RUNS" > output/02-footprint.txt
bash scripts/classes.sh > output/03-classes.txt
bash scripts/offsets.sh > output/04-offsets.txt
bash scripts/container-sizing.sh 2000000 "$CRUNS" > output/05-container-sizing.txt
bash scripts/one-cpu.sh 2000000 "$CRUNS" > output/06-one-cpu.txt
bash scripts/oom-check.sh > output/07-oom-check.txt
echo done
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Bytes per instance of the classes that dominate the heap, from GC.class_histogram of the runs made by
# footprint.sh (kept in target/hist). The point: which classes shrink, and which do not.
source "$(dirname "$0")/lib.sh"
H="$MOD/target/hist"
echo "== bytes per instance (= #bytes / #instances) from jcmd GC.class_histogram after loading 2,000,000 products"
printf '%-40s %14s %14s %14s\n' "class" "JDK 26 default" "JDK 27 default" "JDK 27 opt-out"
for cls in 'com.ankurm.memory.Product' 'java.util.HashMap$Node' 'java.lang.Long' 'java.lang.String' '[B'; do
printf '%-40s' "$cls"
for cfg in jdk26-default jdk27-default jdk27-optout; do
awk -v c="$cls" '$4==c {printf " %14.1f", $3/$2; found=1} END{if(!found) printf " %14s","?"}' "$H/$cfg.txt"
done
echo
done
echo
echo "== total bytes of those five classes"
for cfg in jdk26-default jdk27-default jdk27-optout; do
awk -v cfg="$cfg" '$4=="com.ankurm.memory.Product"||$4=="java.util.HashMap$Node"||$4=="java.lang.Long"||$4=="java.lang.String"||$4=="[B" {s+=$3} END{printf "%-16s %d bytes (%.1f MB)\n", cfg, s, s/1048576}' "$H/$cfg.txt"
done
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# The smallest container that can hold the catalog, before and after. For each memory limit the app is started
# inside a cgroup with that limit and asked to load N products; the table says what happened.
# ok the load finished
# java-OOM the JVM threw OutOfMemoryError: the heap (a fraction of the limit) was too small
# killed(...) the kernel killed the process: total memory, not just heap, went over the limit
# no-result-in-25s the JVM stayed alive but the load (about a second when healthy) did not finish in 25
# seconds: the heap is so tight that the collector runs continuously and makes no progress
# Each cell is RUNS runs ("3/3 ok" = all three finished); a cell near the boundary can be mixed.
# usage: container-sizing.sh [N products] [runs per cell]
source "$(dirname "$0")/lib.sh"
N="${1:-2000000}"; RUNS="${2:-3}"
TMP="$(mktemp -d)"; RETRIES=0
MB=$((1024*1024))
sweep() { # title, jvm-flags-for-all, limits...
local title="$1" common="$2"; shift 2
echo "== $title"
printf '%-8s %-8s %-30s %-30s %-30s\n' "limit" "heap" "JDK 26 default" "JDK 27 default" "JDK 27 opt-out"
for lim in "$@"; do
local cells=() heap="?"
for cfg in "26|$JDK26|" "27|$JDK27|" "27o|$JDK27|-XX:-UseCompactObjectHeaders"; do
IFS='|' read -r tag jh fl <<<"$cfg"
local ok=0 fails=() rss=()
for r in $(seq 1 "$RUNS"); do
d="$TMP/$tag-$lim-$r"
CG_MEM=$((lim*MB)) CG_CPUS=2 run_app "$jh" "$N" "$d" $common $fl >/dev/null 2>&1
h=$(kv "$d/memory-idle.txt" maxHeapMB); [ -n "$h" ] && heap="$h"
[ -f "$d/retries" ] && RETRIES=$((RETRIES+1))
out=$(cat "$d/outcome.txt")
if [ "$out" = ok ]; then ok=$((ok+1)); rss+=("$(kv "$d/memory.txt" rssMB)"); else fails+=("${out%%(*}"); fi
done
cell="$ok/$RUNS ok"
if [ $ok -gt 0 ]; then cell="$cell rss $(printf '%s\n' "${rss[@]}" | median)MB"; fi
if [ ${#fails[@]} -gt 0 ]; then cell="$cell [$(printf '%s\n' "${fails[@]}" | sort | uniq -c | awk '{printf "%s%sx %s",sep,$1,$2; sep=" "}')]"; fi
cells+=("$cell")
done
printf '%-8s %-8s %-30s %-30s %-30s\n' "${lim}MB" "${heap}MB" "${cells[0]}" "${cells[1]}" "${cells[2]}"
done
echo
}
# ONLY=75 or ONLY=25 runs one of the two sweeps (each takes about 12 minutes); default is both.
ONLY="${ONLY:-both}"
echo "# $N products; 2 CPUs; $RUNS runs per cell; rss is the median of the runs that finished"
if [ "$ONLY" != 25 ]; then
sweep "Heap set to 75% of the container (-XX:MaxRAMPercentage=75)" "-XX:MaxRAMPercentage=75" 400 416 432 448 512 704
fi
if [ "$ONLY" != 75 ]; then
sweep "Heap left at the JVM default (25% of the container)" "" 1024 1152 1216 1280 1536 2048
fi
echo "# runs that needed a startup retry (the JVM never came up, for a reason other than OOM or a kernel kill): $RETRIES"
rm -rf "$TMP"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Where the three JDKs live. Override with environment variables; nothing else is hard-coded.
: "${JDK25:=/tmp/tools/j25/jdk-25.0.4.1+1}"
: "${JDK26:=$(ls -d /tmp/tools/j26/jdk-26* 2>/dev/null | head -1)}"
: "${JDK27:=/tmp/tools/j27/jdk-27+35}"
export JDK25 JDK26 JDK27
# The runs must not inherit proxy/trust-store options meant for Maven.
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# What each JDK decides by default, and what the flags do. Every line is the JVM's own answer
# (java -XX:+PrintFlagsFinal), not something inferred from documentation.
source "$(dirname "$0")/lib.sh"
show() { # label jdk flags... (set CG_MEM/CG_CPUS to run inside a container-style cgroup)
local label="$1" jh="$2"; shift 2
local out cg=""
if [ -n "${CG_MEM:-}" ]; then
cg="fl$$-$RANDOM"; cg_create "$cg" "$CG_MEM" "${CG_CPUS:-2}"
out=$(bash -c "$CG_JOIN" _ "$cg" "$jh/bin/java" "$@" -XX:+PrintFlagsFinal -version 2>&1)
cg_remove "$cg"
else
out=$("$jh/bin/java" "$@" -XX:+PrintFlagsFinal -version 2>&1)
fi
if ! echo "$out" | grep -q 'UseCompactObjectHeaders'; then
printf '%-52s -> JVM refused to start:\n' "$label"; echo "$out" | head -3 | sed 's/^/ /'; return
fi
local ch gc heap
ch=$(echo "$out" | awk '$2=="UseCompactObjectHeaders"{print $4}')
gc=$(echo "$out" | awk '($2=="UseG1GC"||$2=="UseSerialGC"||$2=="UseParallelGC"||$2=="UseZGC") && $4=="true"{print $2}' | tr '\n' ' ')
heap=$(echo "$out" | awk '$2=="MaxHeapSize"{printf "%d", $4/1048576}')
printf '%-52s -> compact=%-5s collector=%-13s MaxHeapSize=%sMB\n' "$label" "$ch" "$gc" "$heap"
echo "$out" | grep -E '^(OpenJDK 64-Bit Server VM warning|Error|Unrecognized)' | sed 's/^/ JVM says: /' | head -2
}
for v in 25 26 27; do
jh_var="JDK$v"; jh="${!jh_var}"
echo "== JDK $v: $("$jh/bin/java" -version 2>&1 | sed -n 2p)"
show "default (this machine: 2 CPUs)" "$jh"
show "-XX:+UseCompactObjectHeaders" "$jh" -XX:+UseCompactObjectHeaders
show "-XX:-UseCompactObjectHeaders" "$jh" -XX:-UseCompactObjectHeaders
echo
done
echo "== The same JDKs in a container: 1 GB memory limit, 1 CPU (a cgroup, set the way Docker sets it)"
for v in 25 26 27; do
jh_var="JDK$v"; jh="${!jh_var}"
CG_MEM=$((1024*1024*1024)) CG_CPUS=1 show "JDK $v, 1 GB, 1 CPU" "$jh"
CG_MEM=$((1024*1024*1024)) CG_CPUS=1 show "JDK $v, 1 GB, 1 CPU, MaxRAMPercentage=75" "$jh" -XX:MaxRAMPercentage=75
done
echo
echo "== 1 GB, 2 CPUs"
for v in 26 27; do
jh_var="JDK$v"; jh="${!jh_var}"
CG_MEM=$((1024*1024*1024)) CG_CPUS=2 show "JDK $v, 1 GB, 2 CPUs" "$jh"
done
echo
echo "== -XX:MaxRAM: the flag many people used to fake a container limit"
show "JDK 26 -XX:MaxRAM=1g" "$JDK26" -XX:MaxRAM=1g
show "JDK 27 -XX:MaxRAM=1g" "$JDK27" -XX:MaxRAM=1g
echo
echo "== Compact headers with each collector on JDK 27"
for gc in UseG1GC UseParallelGC UseSerialGC UseZGC; do
show "JDK 27 -XX:+$gc" "$JDK27" -XX:+$gc
done
echo
echo "== Compressed pointers on JDK 27"
show "JDK 27 -XX:-UseCompressedClassPointers" "$JDK27" -XX:-UseCompressedClassPointers
show "JDK 27 -XX:-UseCompressedOops" "$JDK27" -XX:-UseCompressedOops
show "JDK 27 -Xmx32g (a large explicit heap)" "$JDK27" -Xmx32g
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Heap footprint of the same jar on JDK 25, 26 and 27, with compact object headers on and off.
# usage: footprint.sh [N products] [runs per configuration]
source "$(dirname "$0")/lib.sh"
N="${1:-2000000}"; RUNS="${2:-3}"
TMP="$(mktemp -d)"
MEDS=(); ALL=()
row() { # label jdk flags...
local label="$1" jh="$2"; shift 2
local idle=() live=() rss=() gcn=() gcms=() ns=() start=()
for r in $(seq 1 "$RUNS"); do
run_app "$jh" "$N" "$TMP/$label-$r" -Xms1g -Xmx1g "$@" || { echo "$label run $r FAILED"; return; }
local d="$TMP/$label-$r"
idle+=("$(kv "$d/memory-idle.txt" heapLiveAfterGcMB)")
live+=("$(kv "$d/memory.txt" heapLiveAfterGcMB)")
rss+=("$(kv "$d/memory.txt" rssMB)")
gcn+=("$(kv "$d/load.txt" gcCountDuringLoad)")
gcms+=("$(kv "$d/load.txt" gcMillisDuringLoad)")
ns+=("$(kv "$d/lookups.txt" nsPerLookup)")
ALL+=("${gcn[-1]} ${rss[-1]} ${ns[-1]}")
printf 'run %s/%s idle=%sMB live=%sMB rss=%sMB gcDuringLoad=%s (%sms) lookup=%sns compact=%s\n' "$label" "$r" \
"${idle[-1]}" "${live[-1]}" "${rss[-1]}" "${gcn[-1]}" "${gcms[-1]}" "${ns[-1]}" "$(kv "$d/memory.txt" UseCompactObjectHeaders)"
done
MEDS+=("$(printf 'MED %-22s idle=%sMB live=%sMB rss=%sMB gcDuringLoad=%s gcMs=%s lookupNs=%s' "$label" \
"$(printf '%s\n' "${idle[@]}" | median)" "$(printf '%s\n' "${live[@]}" | median)" "$(printf '%s\n' "${rss[@]}" | median)" \
"$(printf '%s\n' "${gcn[@]}" | median)" "$(printf '%s\n' "${gcms[@]}" | median)" "$(printf '%s\n' "${ns[@]}" | median)")")
echo "${MEDS[-1]}"
}
echo "# $(date -u +%F) ${N} products, -Xms1g -Xmx1g, G1, ${RUNS} runs per configuration; MED lines are medians"
echo "# JDK25=$("$JDK25/bin/java" -version 2>&1 | sed -n 2p)"
echo "# JDK26=$("$JDK26/bin/java" -version 2>&1 | sed -n 2p)"
echo "# JDK27=$("$JDK27/bin/java" -version 2>&1 | sed -n 2p)"
row jdk25-default "$JDK25"
row jdk25-compact "$JDK25" -XX:+UseCompactObjectHeaders
row jdk26-default "$JDK26"
row jdk26-compact "$JDK26" -XX:+UseCompactObjectHeaders
row jdk27-default "$JDK27"
row jdk27-optout "$JDK27" -XX:-UseCompactObjectHeaders
echo
echo "== medians of $RUNS runs per configuration (idle = live set of the idle app; live = after loading the catalog)"
printf '%s\n' "${MEDS[@]}"
echo
stats() { sort -n | awk '{a[NR]=$1} END{printf "min %d / median %d / max %d", a[1], a[int((NR+1)/2)], a[NR]}'; }
echo "== the same runs, grouped by whether a collection ran while the catalog was loading (all configurations together)"
for g in zero some; do
if [ $g = zero ]; then sel='$1==0'; label="no collection during load"; else sel='$1>0'; label="a collection during load"; fi
n=$(printf '%s\n' "${ALL[@]}" | awk "$sel" | wc -l)
printf '%-27s runs=%-3s rss MB: %s lookup ns: %s\n' "$label" "$n" \
"$(printf '%s\n' "${ALL[@]}" | awk "$sel {print \$2}" | stats)" "$(printf '%s\n' "${ALL[@]}" | awk "$sel {print \$3}" | stats)"
done
# keep one histogram per JVM for the class-level table
mkdir -p "$MOD/target/hist"
for c in jdk26-default jdk27-default jdk27-optout; do cp "$TMP/$c-1/histogram.txt" "$MOD/target/hist/$c.txt"; done
rm -rf "$TMP"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Shared helpers. Source this; do not run it.
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MOD="$(cd "$HERE/.." && pwd)"
source "$HERE/env.sh"
JAR="$MOD/target/catalog.jar"
# ---- containers -----------------------------------------------------------------------------------------
# There is no Docker daemon in the sandbox, so limits are set the way Docker sets them: a cgroup with a
# memory limit and a CPU quota. The JVM's container support reads exactly those files (cgroup v1 here).
CGROOT=/sys/fs/cgroup
cg_create() { # NAME MEMORY_BYTES CPUS
mkdir -p "$CGROOT/memory/$1" "$CGROOT/cpu/$1"
echo "$2" >"$CGROOT/memory/$1/memory.limit_in_bytes"
echo 100000 >"$CGROOT/cpu/$1/cpu.cfs_period_us"
echo $(( $3 * 100000 )) >"$CGROOT/cpu/$1/cpu.cfs_quota_us"
}
cg_remove() { rmdir "$CGROOT/memory/$1" "$CGROOT/cpu/$1" 2>/dev/null; }
# In run_app the JVM is started as: bash -c '<join the cgroup>; exec java ...' &
# so that $! is the JVM's own pid (a function wrapper would make $! the wrapper's pid).
CG_JOIN='n=$1; shift; echo $$ >'"$CGROOT"'/memory/$n/cgroup.procs; echo $$ >'"$CGROOT"'/cpu/$n/cgroup.procs; exec "$@"'
# run_app JAVA_HOME N OUTDIR [jvm flags...]
# Starts the catalog on that JVM, loads N products, and leaves these files in OUTDIR:
# startup.txt memory-idle.txt load.txt memory.txt lookups.txt histogram.txt outcome.txt
# Set CG_MEM (bytes) and CG_CPUS to run inside a container-style cgroup. Set NMT=1 for native memory tracking.
# Returns 1 if the load did not complete; outcome.txt says why: java-OOM, killed (kernel OOM kill),
# no-result-in-25s (the JVM is alive but collecting garbage without making progress; a healthy load takes
# about a second), or error.
run_app_once() {
local jh="$1" n="$2" out="$3"; shift 3
local port=$((20000 + RANDOM % 20000))
local cg=""
mkdir -p "$out"
local extra=()
if [ -n "${NMT:-}" ]; then extra+=(-XX:NativeMemoryTracking=summary); fi
if [ -n "${CG_MEM:-}" ]; then
cg="jm$$-$RANDOM"; cg_create "$cg" "$CG_MEM" "${CG_CPUS:-2}"
bash -c "$CG_JOIN" _ "$cg" "$jh/bin/java" "${extra[@]}" "$@" -jar "$JAR" --server.port=$port >"$out/app.log" 2>&1 &
else
"$jh/bin/java" "${extra[@]}" "$@" -jar "$JAR" --server.port=$port >"$out/app.log" 2>&1 &
fi
local pid=$!
local up=0
for _ in $(seq 1 400); do
kill -0 $pid 2>/dev/null || break
if curl -sf "localhost:$port/ping" >/dev/null 2>&1; then up=1; break; fi
sleep 0.25
done
local rc=0
if [ $up = 0 ]; then
wait $pid 2>/dev/null; local st=$?
if grep -q OutOfMemoryError "$out/app.log"; then echo "java-OOM-at-startup" >"$out/outcome.txt"
elif [ $st -ge 128 ]; then echo "killed(exit $st)" >"$out/outcome.txt"; else echo "error(exit $st)" >"$out/outcome.txt"; fi
: >"$out/startup.txt"; [ -n "$cg" ] && cg_remove "$cg"; return 1
fi
grep -o 'Started MemoryApplication in [0-9.]* seconds' "$out/app.log" >"$out/startup.txt"
curl -s --max-time 60 "localhost:$port/memory" >"$out/memory-idle.txt"
curl -s --max-time 25 "localhost:$port/load?n=$n" >"$out/load.txt"; local crc=$?
if ! grep -q '^loaded=' "$out/load.txt"; then
rc=1
if [ $crc -eq 28 ]; then echo "no-result-in-25s" >"$out/outcome.txt"
elif ! kill -0 $pid 2>/dev/null; then wait $pid 2>/dev/null; echo "killed(exit $?)" >"$out/outcome.txt"
elif grep -q OutOfMemoryError "$out/app.log" "$out/load.txt"; then echo "java-OOM" >"$out/outcome.txt"
else echo "error" >"$out/outcome.txt"; fi
else
curl -s --max-time 60 "localhost:$port/memory" >"$out/memory.txt"
curl -s --max-time 60 "localhost:$port/lookups?n=2000000" >"$out/lookups.txt"
"$jh/bin/jcmd" $pid GC.class_histogram >"$out/histogram.txt" 2>&1
if [ -n "${NMT:-}" ]; then "$jh/bin/jcmd" $pid VM.native_memory summary >"$out/nmt.txt" 2>&1; fi
if kill -0 $pid 2>/dev/null; then echo "ok" >"$out/outcome.txt"; else wait $pid 2>/dev/null; echo "killed(exit $?)" >"$out/outcome.txt"; rc=1; fi
fi
kill $pid 2>/dev/null; wait $pid 2>/dev/null
[ -n "$cg" ] && cg_remove "$cg"
return $rc
}
# run_app: run_app_once, but a run whose JVM never came up for a reason that is neither OutOfMemoryError
# nor a kernel kill (in practice: the random port was taken) is retried, and the retry is counted in
# $out/retries so that a summary can say so instead of hiding it.
run_app() {
local out="$3" tries=0
while :; do
run_app_once "$@"; local rc=$?
if [ $rc -ne 0 ] && grep -q '^error(exit' "$out/outcome.txt" 2>/dev/null && [ ! -s "$out/startup.txt" ] && [ $tries -lt 2 ]; then
tries=$((tries+1)); echo $tries >"$out/retries"; continue
fi
return $rc
done
}
# kv FILE KEY -> value
kv() { grep "^$2=" "$1" | head -1 | cut -d= -f2-; }
# median of numbers on stdin
median() { sort -n | awk '{a[NR]=$1} END{ if(NR%2){print a[(NR+1)/2]} else {printf "%d\n",(a[NR/2]+a[NR/2+1])/2} }'; }
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
source "$(dirname "$0")/lib.sh"
cd "$MOD/src/offsets"
for cfg in "25||$JDK25" "26||$JDK26" "27||$JDK27" "27|-XX:-UseCompactObjectHeaders|$JDK27"; do
IFS='|' read -r v fl jh <<<"$cfg"
echo "== JDK $v $fl"
"$jh/bin/java" $fl --sun-misc-unsafe-memory-access=allow Offsets.java 2>&1
echo
done
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# JEP 523 in a small container: 1 CPU, 1 GB. JDK 26 picks Serial here, JDK 27 picks G1. What does the app
# cost under each collector, and what does the JVM say it spends outside the heap (Native Memory Tracking)?
# usage: one-cpu.sh [N products] [runs]
source "$(dirname "$0")/lib.sh"
N="${1:-2000000}"; RUNS="${2:-3}"
TMP="$(mktemp -d)"
MB=$((1024*1024))
export NMT=1 CG_MEM=$((1024*MB)) CG_CPUS=1
nmt() { # file -> "GC committed KB|Total committed KB"
local gc tot
gc=$(awk '/^- *GC \(reserved/ {gsub(/[^0-9=]/," "); n=split($0,a,"="); print a[3]+0; exit}' "$1")
tot=$(awk '/^Total: reserved/ {gsub(/[^0-9=]/," "); n=split($0,a,"="); print a[3]+0; exit}' "$1")
echo "$gc|$tot"
}
row() { # label jdk flags...
local label="$1" jh="$2"; shift 2
local st=() live=() rss=() gcn=() gcms=() ld=() gck=() totk=() coll="" cpus=""
for r in $(seq 1 "$RUNS"); do
d="$TMP/$label-$r"
run_app "$jh" "$N" "$d" -XX:MaxRAMPercentage=50 "$@" >/dev/null 2>&1 || { echo "$label run $r FAILED: $(cat "$d/outcome.txt")"; return; }
coll=$(kv "$d/memory.txt" collectors); cpus=$(kv "$d/memory.txt" availableProcessors)
st+=("$(grep -o '[0-9.]*' "$d/startup.txt" | tail -1)")
live+=("$(kv "$d/memory.txt" heapLiveAfterGcMB)"); rss+=("$(kv "$d/memory.txt" rssMB)")
gcn+=("$(kv "$d/load.txt" gcCountDuringLoad)"); gcms+=("$(kv "$d/load.txt" gcMillisDuringLoad)")
ld+=("$(kv "$d/load.txt" loadMillis)")
IFS='|' read -r g t <<<"$(nmt "$d/nmt.txt")"; gck+=("$g"); totk+=("$t")
done
m() { printf '%s\n' "$@" | median; }
printf '%s\n collectors=%s cpus=%s\n' "== $label" "$coll" "$cpus"
printf ' startup=%ss live=%sMB rss=%sMB loadMillis=%s gcDuringLoad=%s (%sms)\n' \
"$(m "${st[@]}")" "$(m "${live[@]}")" "$(m "${rss[@]}")" "$(m "${ld[@]}")" "$(m "${gcn[@]}")" "$(m "${gcms[@]}")"
printf ' NMT: GC committed=%s KB Total committed=%s KB\n\n' "$(m "${gck[@]}")" "$(m "${totk[@]}")"
}
echo "# $N products, container = 1 GB / 1 CPU (cgroup), -XX:MaxRAMPercentage=50 (512 MB heap), NMT summary on, medians of $RUNS runs"
row "jdk26-default (the JVM chose)" "$JDK26"
row "jdk26 -XX:+UseG1GC" "$JDK26" -XX:+UseG1GC
row "jdk27-default (the JVM chose)" "$JDK27"
row "jdk27 -XX:+UseSerialGC" "$JDK27" -XX:+UseSerialGC
row "jdk27 -XX:+UseSerialGC, compact off" "$JDK27" -XX:+UseSerialGC -XX:-UseCompactObjectHeaders
rm -rf "$TMP"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# What does "killed" mean in the container sweep? Runs the edge case (JDK 27, compact headers off, 400 MB
# container, heap 75%) a few times and reads the cgroup's own counters and the kernel log afterwards, so the
# claim "the kernel's memory controller killed it" rests on the kernel's say-so and not on an exit code.
source "$(dirname "$0")/lib.sh"
KILLED=()
echo "# JDK 27, -XX:-UseCompactObjectHeaders -XX:MaxRAMPercentage=75, container limit 400 MB, 2 CPUs; 2,000,000 products"
for attempt in 1 2 3 4; do
cg="oomchk$attempt"
cg_create "$cg" $((400*1024*1024)) 2
port=$((30000 + RANDOM % 5000))
bash -c "$CG_JOIN" _ "$cg" "$JDK27/bin/java" -XX:MaxRAMPercentage=75 -XX:-UseCompactObjectHeaders -jar "$JAR" --server.port=$port >/dev/null 2>&1 &
pid=$!
for _ in $(seq 1 120); do curl -sf localhost:$port/ping >/dev/null 2>&1 && break; sleep 0.25; done
resp=$(curl -s --max-time 25 -o /dev/null -w '%{http_code}' "localhost:$port/load?n=2000000")
sleep 1
if kill -0 $pid 2>/dev/null; then state="process still alive"; else wait $pid 2>/dev/null; state="process gone, exit $?"; KILLED+=("$pid"); fi
echo "== attempt $attempt: /load answered HTTP $resp; $state"
echo " memory.failcnt=$(cat $CGROOT/memory/$cg/memory.failcnt) memory.max_usage=$(( $(cat $CGROOT/memory/$cg/memory.max_usage_in_bytes) / 1048576 ))MB limit=$(( $(cat $CGROOT/memory/$cg/memory.limit_in_bytes) / 1048576 ))MB $(grep '^oom_kill ' $CGROOT/memory/$cg/memory.oom_control)"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
cg_remove "$cg"
echo
done
echo "== the kernel log (dmesg): what it says about the processes that disappeared above"
for p in "${KILLED[@]:-}"; do
[ -n "$p" ] && dmesg 2>/dev/null | grep "Memory cgroup out of memory: Killed process $p " | sed 's/^\[[ 0-9.]*\] //'
done
exit 0
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# A transcript of the service's own endpoints on JDK 27, so the post can show what a request and the
# /memory report look like. Port 18081 is fixed so the transcript reads the same every time.
source "$(dirname "$0")/lib.sh"
PORT=18081
"$JDK27/bin/java" -Xms1g -Xmx1g -jar "$JAR" --server.port=$PORT >/dev/null 2>&1 &
PID=$!
for _ in $(seq 1 200); do curl -sf "localhost:$PORT/ping" >/dev/null 2>&1 && break; sleep 0.25; done
for path in "/memory" "/load?n=2000000" "/memory" "/products/42" "/products/2000000"; do
echo "\$ curl -s -w ' [HTTP %{http_code}]\n' localhost:$PORT$path"
curl -s -w ' [HTTP %{http_code}]\n' "localhost:$PORT$path" | sed 's/^ \[HTTP/[HTTP/'
echo
done
kill $PID 2>/dev/null; wait $PID 2>/dev/null
exit 0
@@ -0,0 +1,33 @@
package com.ankurm.memory;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* The in-memory catalog. Keys are boxed {@code Long}s on purpose: {@code Map<Long, Product>} is
* what most services write, and each entry costs a HashMap node, a Long and a Product.
*/
@Component
public class Catalog {
private volatile Map<Long, Product> byId = Map.of();
/** Replaces the catalog with {@code n} generated products. */
public void load(int n) {
Map<Long, Product> m = HashMap.newHashMap(n);
for (int i = 0; i < n; i++) {
long id = i;
m.put(id, new Product(id, 100 + (i % 9_900), i % 500, (i & 7) != 0, "SKU-" + (100_000_000L + i)));
}
this.byId = m;
}
public Product find(long id) {
return byId.get(id);
}
public int size() {
return byId.size();
}
}
@@ -0,0 +1,83 @@
package com.ankurm.memory;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class CatalogController {
private final Catalog catalog;
public CatalogController(Catalog catalog) {
this.catalog = catalog;
}
/** The endpoint a client would call. */
@GetMapping("/products/{id}")
public Product product(@PathVariable long id) {
Product p = catalog.find(id);
if (p == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return p;
}
/** Builds the catalog and reports what the collector did while it happened. */
@GetMapping(value = "/load", produces = MediaType.TEXT_PLAIN_VALUE)
public String load(@RequestParam int n) {
long gc0 = MemoryReport.gcCount();
long ms0 = MemoryReport.gcMillis();
long t0 = System.nanoTime();
catalog.load(n);
long loadMs = (System.nanoTime() - t0) / 1_000_000;
return "loaded=" + n + "\nloadMillis=" + loadMs
+ "\ngcCountDuringLoad=" + (MemoryReport.gcCount() - gc0)
+ "\ngcMillisDuringLoad=" + (MemoryReport.gcMillis() - ms0) + "\n";
}
/** Forces a full collection, then prints the live set. This is the number the post compares. */
@GetMapping(value = "/memory", produces = MediaType.TEXT_PLAIN_VALUE)
public String memory() {
Map<String, String> m = MemoryReport.snapshot(catalog.size());
return m.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("\n", "", "\n"));
}
/**
* Random lookups inside the JVM, so the number is the cost of chasing pointers through the map and
* not the cost of HTTP. Not a load test: a probe for "did the smaller heap change lookup cost".
*/
@GetMapping(value = "/lookups", produces = MediaType.TEXT_PLAIN_VALUE)
public String lookups(@RequestParam int n) {
int size = catalog.size();
long sum = 0;
long x = 88172645463325252L;
for (int warm = 0; warm < 2; warm++) {
for (int i = 0; i < n; i++) {
x ^= x << 13; x ^= x >>> 7; x ^= x << 17;
Product p = catalog.find(Math.floorMod(x, size));
sum += p.stock();
}
}
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) {
x ^= x << 13; x ^= x >>> 7; x ^= x << 17;
Product p = catalog.find(Math.floorMod(x, size));
sum += p.stock();
}
long ns = System.nanoTime() - t0;
return "lookups=" + n + "\nnsPerLookup=" + (ns / n) + "\nchecksum=" + sum + "\n";
}
@GetMapping(value = "/ping", produces = MediaType.TEXT_PLAIN_VALUE)
public String ping() {
return "ok\n";
}
}
@@ -0,0 +1,15 @@
package com.ankurm.memory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* A deliberately ordinary Spring Boot web application. It holds a product catalog in memory,
* which is the shape of a lot of real services: many small objects that live for a long time.
*/
@SpringBootApplication
public class MemoryApplication {
public static void main(String[] args) {
SpringApplication.run(MemoryApplication.class, args);
}
}
@@ -0,0 +1,75 @@
package com.ankurm.memory;
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
/** Reads what the running JVM says about itself. Everything printed by /memory comes from here. */
final class MemoryReport {
private MemoryReport() {
}
static long gcCount() {
long c = 0;
for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) {
c += Math.max(0, b.getCollectionCount());
}
return c;
}
static long gcMillis() {
long t = 0;
for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) {
t += Math.max(0, b.getCollectionTime());
}
return t;
}
static String vmOption(String name) {
try {
return ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class).getVMOption(name).getValue();
} catch (IllegalArgumentException e) {
return "(no such flag)";
}
}
/** Resident set size from /proc, in MB; -1 where /proc is not available. */
static long rssMb() {
try {
for (String line : Files.readAllLines(Path.of("/proc/self/status"))) {
if (line.startsWith("VmRSS:")) {
return Long.parseLong(line.replaceAll("\\D+", "")) / 1024;
}
}
} catch (Exception ignored) {
// not Linux
}
return -1;
}
/** Collects twice so the number is the live set, not whatever garbage happens to be around. */
static Map<String, String> snapshot(int products) {
System.gc();
System.gc();
var rt = Runtime.getRuntime();
var m = new LinkedHashMap<String, String>();
m.put("java.version", System.getProperty("java.version"));
m.put("UseCompactObjectHeaders", vmOption("UseCompactObjectHeaders"));
m.put("collectors", ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(GarbageCollectorMXBean::getName).toList().toString());
m.put("availableProcessors", Integer.toString(rt.availableProcessors()));
m.put("maxHeapMB", Long.toString(rt.maxMemory() >> 20));
m.put("products", Integer.toString(products));
m.put("heapLiveAfterGcMB", Long.toString((rt.totalMemory() - rt.freeMemory()) >> 20));
m.put("heapCommittedMB", Long.toString(rt.totalMemory() >> 20));
m.put("rssMB", Long.toString(rssMb()));
m.put("gcCountTotal", Long.toString(gcCount()));
m.put("gcMillisTotal", Long.toString(gcMillis()));
return m;
}
}
@@ -0,0 +1,5 @@
package com.ankurm.memory;
/** One catalog entry: a long, two ints, a boolean and a String. Nothing exotic. */
public record Product(long id, int priceCents, int stock, boolean active, String sku) {
}
+36
View File
@@ -0,0 +1,36 @@
import java.lang.reflect.Field;
/**
* Where does the first field of an object start? Code that hard-codes "12 bytes of header" gets this wrong
* on JDK 27. Run with: java --sun-misc-unsafe-memory-access=allow Offsets.java
* (The flag silences the JDK 24+ warning about sun.misc.Unsafe; nothing here reads or writes memory.)
*/
public class Offsets {
static class P {
long id;
int priceCents;
int stock;
boolean active;
Object sku;
}
@SuppressWarnings("removal")
public static void main(String[] args) throws Exception {
Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
sun.misc.Unsafe u = (sun.misc.Unsafe) f.get(null);
System.out.println("java " + System.getProperty("java.version")
+ " compact headers: " + java.lang.management.ManagementFactory
.getPlatformMXBean(com.sun.management.HotSpotDiagnosticMXBean.class)
.getVMOption("UseCompactObjectHeaders").getValue());
long first = Long.MAX_VALUE;
for (Field x : P.class.getDeclaredFields()) {
long off = u.objectFieldOffset(x);
first = Math.min(first, off);
System.out.printf(" %-11s offset %2d%n", x.getName(), off);
}
System.out.println(" first field starts at byte " + first + " (so the header is at most " + first + " bytes)");
System.out.println(" a hard-coded 12-byte header would be " + (first == 12 ? "right" : "WRONG") + " here");
}
}