From e83c9949d881bdc65edaf59ffde123337eba22c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:09:55 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TF9JWFvJSNm6HVzswzZU5a --- README.md | 1 + jdk27-memory/.gitignore | 1 + jdk27-memory/README.md | 80 ++++++++++++++++ jdk27-memory/output/00-sample-endpoints.txt | 41 ++++++++ jdk27-memory/output/01-jvm-defaults.txt | 44 +++++++++ jdk27-memory/output/02-footprint.txt | 52 ++++++++++ jdk27-memory/output/03-classes.txt | 12 +++ jdk27-memory/output/04-offsets.txt | 40 ++++++++ jdk27-memory/output/05-container-sizing.txt | 20 ++++ jdk27-memory/output/06-one-cpu.txt | 26 +++++ jdk27-memory/output/07-oom-check.txt | 17 ++++ jdk27-memory/pom.xml | 42 ++++++++ jdk27-memory/run.sh | 26 +++++ jdk27-memory/scripts/classes.sh | 19 ++++ jdk27-memory/scripts/container-sizing.sh | 53 ++++++++++ jdk27-memory/scripts/env.sh | 8 ++ jdk27-memory/scripts/flags.sh | 64 +++++++++++++ jdk27-memory/scripts/footprint.sh | 56 +++++++++++ jdk27-memory/scripts/lib.sh | 96 +++++++++++++++++++ jdk27-memory/scripts/offsets.sh | 9 ++ jdk27-memory/scripts/one-cpu.sh | 44 +++++++++ jdk27-memory/scripts/oom-check.sh | 28 ++++++ jdk27-memory/scripts/sample.sh | 15 +++ .../main/java/com/ankurm/memory/Catalog.java | 33 +++++++ .../com/ankurm/memory/CatalogController.java | 83 ++++++++++++++++ .../com/ankurm/memory/MemoryApplication.java | 15 +++ .../java/com/ankurm/memory/MemoryReport.java | 75 +++++++++++++++ .../main/java/com/ankurm/memory/Product.java | 5 + jdk27-memory/src/offsets/Offsets.java | 36 +++++++ 29 files changed, 1041 insertions(+) create mode 100644 jdk27-memory/.gitignore create mode 100644 jdk27-memory/README.md create mode 100644 jdk27-memory/output/00-sample-endpoints.txt create mode 100644 jdk27-memory/output/01-jvm-defaults.txt create mode 100644 jdk27-memory/output/02-footprint.txt create mode 100644 jdk27-memory/output/03-classes.txt create mode 100644 jdk27-memory/output/04-offsets.txt create mode 100644 jdk27-memory/output/05-container-sizing.txt create mode 100644 jdk27-memory/output/06-one-cpu.txt create mode 100644 jdk27-memory/output/07-oom-check.txt create mode 100644 jdk27-memory/pom.xml create mode 100755 jdk27-memory/run.sh create mode 100755 jdk27-memory/scripts/classes.sh create mode 100755 jdk27-memory/scripts/container-sizing.sh create mode 100755 jdk27-memory/scripts/env.sh create mode 100755 jdk27-memory/scripts/flags.sh create mode 100755 jdk27-memory/scripts/footprint.sh create mode 100755 jdk27-memory/scripts/lib.sh create mode 100755 jdk27-memory/scripts/offsets.sh create mode 100755 jdk27-memory/scripts/one-cpu.sh create mode 100644 jdk27-memory/scripts/oom-check.sh create mode 100644 jdk27-memory/scripts/sample.sh create mode 100644 jdk27-memory/src/main/java/com/ankurm/memory/Catalog.java create mode 100644 jdk27-memory/src/main/java/com/ankurm/memory/CatalogController.java create mode 100644 jdk27-memory/src/main/java/com/ankurm/memory/MemoryApplication.java create mode 100644 jdk27-memory/src/main/java/com/ankurm/memory/MemoryReport.java create mode 100644 jdk27-memory/src/main/java/com/ankurm/memory/Product.java create mode 100644 jdk27-memory/src/offsets/Offsets.java diff --git a/README.md b/README.md index b9a48f9..411a9d7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/jdk27-memory/.gitignore b/jdk27-memory/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/jdk27-memory/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/jdk27-memory/README.md b/jdk27-memory/README.md new file mode 100644 index 0000000..0ed0c2a --- /dev/null +++ b/jdk27-memory/README.md @@ -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`, 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. diff --git a/jdk27-memory/output/00-sample-endpoints.txt b/jdk27-memory/output/00-sample-endpoints.txt new file mode 100644 index 0000000..0cf2ed1 --- /dev/null +++ b/jdk27-memory/output/00-sample-endpoints.txt @@ -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] + diff --git a/jdk27-memory/output/01-jvm-defaults.txt b/jdk27-memory/output/01-jvm-defaults.txt new file mode 100644 index 0000000..29634b2 --- /dev/null +++ b/jdk27-memory/output/01-jvm-defaults.txt @@ -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 diff --git a/jdk27-memory/output/02-footprint.txt b/jdk27-memory/output/02-footprint.txt new file mode 100644 index 0000000..dfc2e44 --- /dev/null +++ b/jdk27-memory/output/02-footprint.txt @@ -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 diff --git a/jdk27-memory/output/03-classes.txt b/jdk27-memory/output/03-classes.txt new file mode 100644 index 0000000..2bd70fb --- /dev/null +++ b/jdk27-memory/output/03-classes.txt @@ -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) diff --git a/jdk27-memory/output/04-offsets.txt b/jdk27-memory/output/04-offsets.txt new file mode 100644 index 0000000..19e1323 --- /dev/null +++ b/jdk27-memory/output/04-offsets.txt @@ -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 + diff --git a/jdk27-memory/output/05-container-sizing.txt b/jdk27-memory/output/05-container-sizing.txt new file mode 100644 index 0000000..5c270fd --- /dev/null +++ b/jdk27-memory/output/05-container-sizing.txt @@ -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 diff --git a/jdk27-memory/output/06-one-cpu.txt b/jdk27-memory/output/06-one-cpu.txt new file mode 100644 index 0000000..bb93a52 --- /dev/null +++ b/jdk27-memory/output/06-one-cpu.txt @@ -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 + diff --git a/jdk27-memory/output/07-oom-check.txt b/jdk27-memory/output/07-oom-check.txt new file mode 100644 index 0000000..fbf1376 --- /dev/null +++ b/jdk27-memory/output/07-oom-check.txt @@ -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 diff --git a/jdk27-memory/pom.xml b/jdk27-memory/pom.xml new file mode 100644 index 0000000..7775bc8 --- /dev/null +++ b/jdk27-memory/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + jdk27-memory + 1.0.0 + jdk27-memory + A Spring Boot catalog service used to measure heap footprint on JDK 25, 26 and 27 + + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + catalog + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/jdk27-memory/run.sh b/jdk27-memory/run.sh new file mode 100755 index 0000000..8971a8a --- /dev/null +++ b/jdk27-memory/run.sh @@ -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 diff --git a/jdk27-memory/scripts/classes.sh b/jdk27-memory/scripts/classes.sh new file mode 100755 index 0000000..13ef5aa --- /dev/null +++ b/jdk27-memory/scripts/classes.sh @@ -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 diff --git a/jdk27-memory/scripts/container-sizing.sh b/jdk27-memory/scripts/container-sizing.sh new file mode 100755 index 0000000..4249f57 --- /dev/null +++ b/jdk27-memory/scripts/container-sizing.sh @@ -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" diff --git a/jdk27-memory/scripts/env.sh b/jdk27-memory/scripts/env.sh new file mode 100755 index 0000000..5ad316c --- /dev/null +++ b/jdk27-memory/scripts/env.sh @@ -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 diff --git a/jdk27-memory/scripts/flags.sh b/jdk27-memory/scripts/flags.sh new file mode 100755 index 0000000..bb09f97 --- /dev/null +++ b/jdk27-memory/scripts/flags.sh @@ -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 diff --git a/jdk27-memory/scripts/footprint.sh b/jdk27-memory/scripts/footprint.sh new file mode 100755 index 0000000..a9feb47 --- /dev/null +++ b/jdk27-memory/scripts/footprint.sh @@ -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" diff --git a/jdk27-memory/scripts/lib.sh b/jdk27-memory/scripts/lib.sh new file mode 100755 index 0000000..16b64fd --- /dev/null +++ b/jdk27-memory/scripts/lib.sh @@ -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 '; 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} }'; } diff --git a/jdk27-memory/scripts/offsets.sh b/jdk27-memory/scripts/offsets.sh new file mode 100755 index 0000000..2b13534 --- /dev/null +++ b/jdk27-memory/scripts/offsets.sh @@ -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 diff --git a/jdk27-memory/scripts/one-cpu.sh b/jdk27-memory/scripts/one-cpu.sh new file mode 100755 index 0000000..56acad1 --- /dev/null +++ b/jdk27-memory/scripts/one-cpu.sh @@ -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" diff --git a/jdk27-memory/scripts/oom-check.sh b/jdk27-memory/scripts/oom-check.sh new file mode 100644 index 0000000..f94c3e0 --- /dev/null +++ b/jdk27-memory/scripts/oom-check.sh @@ -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 diff --git a/jdk27-memory/scripts/sample.sh b/jdk27-memory/scripts/sample.sh new file mode 100644 index 0000000..8862cc9 --- /dev/null +++ b/jdk27-memory/scripts/sample.sh @@ -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 diff --git a/jdk27-memory/src/main/java/com/ankurm/memory/Catalog.java b/jdk27-memory/src/main/java/com/ankurm/memory/Catalog.java new file mode 100644 index 0000000..3606574 --- /dev/null +++ b/jdk27-memory/src/main/java/com/ankurm/memory/Catalog.java @@ -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} is + * what most services write, and each entry costs a HashMap node, a Long and a Product. + */ +@Component +public class Catalog { + + private volatile Map byId = Map.of(); + + /** Replaces the catalog with {@code n} generated products. */ + public void load(int n) { + Map 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(); + } +} diff --git a/jdk27-memory/src/main/java/com/ankurm/memory/CatalogController.java b/jdk27-memory/src/main/java/com/ankurm/memory/CatalogController.java new file mode 100644 index 0000000..8892ffd --- /dev/null +++ b/jdk27-memory/src/main/java/com/ankurm/memory/CatalogController.java @@ -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 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"; + } +} diff --git a/jdk27-memory/src/main/java/com/ankurm/memory/MemoryApplication.java b/jdk27-memory/src/main/java/com/ankurm/memory/MemoryApplication.java new file mode 100644 index 0000000..6add196 --- /dev/null +++ b/jdk27-memory/src/main/java/com/ankurm/memory/MemoryApplication.java @@ -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); + } +} diff --git a/jdk27-memory/src/main/java/com/ankurm/memory/MemoryReport.java b/jdk27-memory/src/main/java/com/ankurm/memory/MemoryReport.java new file mode 100644 index 0000000..fdaaa26 --- /dev/null +++ b/jdk27-memory/src/main/java/com/ankurm/memory/MemoryReport.java @@ -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 snapshot(int products) { + System.gc(); + System.gc(); + var rt = Runtime.getRuntime(); + var m = new LinkedHashMap(); + 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; + } +} diff --git a/jdk27-memory/src/main/java/com/ankurm/memory/Product.java b/jdk27-memory/src/main/java/com/ankurm/memory/Product.java new file mode 100644 index 0000000..8de3793 --- /dev/null +++ b/jdk27-memory/src/main/java/com/ankurm/memory/Product.java @@ -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) { +} diff --git a/jdk27-memory/src/offsets/Offsets.java b/jdk27-memory/src/offsets/Offsets.java new file mode 100644 index 0000000..0abdd49 --- /dev/null +++ b/jdk27-memory/src/offsets/Offsets.java @@ -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"); + } +}