From d565be93c69b7b71e6d5519f5bafa083ed20b1cc Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Mon, 12 Oct 2026 17:29:29 +0000 Subject: [PATCH] Add jdk27-perf: the same Spring Boot service measured on JDK 25 and JDK 27 Live heap, throughput, p99 and startup across four configurations, including opt-in and opt-out of compact object headers. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WzVQ6zFh9cuLPj9RSbRH8U --- README.md | 2 +- jdk27-perf/README.md | 70 ++++++++++++++++ jdk27-perf/output/01-defaults.txt | 16 ++++ jdk27-perf/output/02-runs.txt | 48 +++++++++++ jdk27-perf/output/03-summary.txt | 4 + jdk27-perf/output/04-live-set.txt | 12 +++ jdk27-perf/pom.xml | 33 ++++++++ jdk27-perf/scripts/Load.java | 42 ++++++++++ jdk27-perf/scripts/live-set.sh | 15 ++++ jdk27-perf/scripts/run-all.sh | 35 ++++++++ jdk27-perf/scripts/run-one.sh | 20 +++++ jdk27-perf/scripts/summarize.sh | 9 ++ .../java/com/ankurm/perf/PerfApplication.java | 83 +++++++++++++++++++ 13 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 jdk27-perf/README.md create mode 100644 jdk27-perf/output/01-defaults.txt create mode 100644 jdk27-perf/output/02-runs.txt create mode 100644 jdk27-perf/output/03-summary.txt create mode 100644 jdk27-perf/output/04-live-set.txt create mode 100644 jdk27-perf/pom.xml create mode 100644 jdk27-perf/scripts/Load.java create mode 100755 jdk27-perf/scripts/live-set.sh create mode 100755 jdk27-perf/scripts/run-all.sh create mode 100755 jdk27-perf/scripts/run-one.sh create mode 100755 jdk27-perf/scripts/summarize.sh create mode 100644 jdk27-perf/src/main/java/com/ankurm/perf/PerfApplication.java diff --git a/README.md b/README.md index ff84d02..633f039 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ThreadLocal (JEP 506)**. Every number and transcript in the post comes from a fi Next LTS: Java 29, September 2027 (Oracle Java SE Support Roadmap). Layout: `g1-container/`, `object-headers/`, `jep-tour/` (27 features and `broken/` 26-era sources), `recap26/` (the Java 26 lane), `lanes/` (the 21 to 25 hub demos, the AI prompts in `lanes/prompts/` and the build-file demo in `lanes/upgrade/`), `other-changes/`, `api-diff/`, -`gatherers/` (JEP 485, standalone post), `scoped-values/` (JEP 506 vs `ThreadLocal`, standalone post). +`gatherers/` (JEP 485, standalone post), `scoped-values/` (JEP 506 vs `ThreadLocal`, standalone post). `jdk27-perf/` (the same Spring Boot service on JDK 25 and 27: footprint, throughput, p99; its README and `output/` replace a `docs/` folder). ## Quickstart diff --git a/jdk27-perf/README.md b/jdk27-perf/README.md new file mode 100644 index 0000000..6fe3036 --- /dev/null +++ b/jdk27-perf/README.md @@ -0,0 +1,70 @@ +# jdk27-perf — what actually got faster in JDK 27, measured on one Spring Boot service + +Companion module for the ankurm.com post **What Actually Got Faster in JDK 27: Measured on a Spring Boot App** +(). +No `docs/` folder: this README and the raw files in [`output/`](output) are the documentation. + +## Question + +JDK 27 changed two defaults that matter to a server: compact object headers are on (JEP 534) and G1 is the default collector everywhere (JEP 523). +Does the same Spring Boot service get faster, smaller, or neither when you move from JDK 25 to JDK 27 with no flags? + +## Setup + +| Thing | Value | +|---|---| +| JDK 25 | Temurin 25.0.4.1+1 (LTS) | +| JDK 27 | Temurin 27+35 (GA 2026-09-15) | +| Spring Boot | 4.1.1, `spring-boot-starter-webmvc` | +| Machine | 2 vCPU, 8 GB RAM Linux sandbox (the load client shares the box, so absolute numbers are modest and noisy) | +| Service | 3,000,000 resident `Item(int id, int qty)` records; `/quote?n=200` builds 200 response records; Jackson serialises them | +| Load | closed loop, 4 client threads, 15 s warmup discarded, 30 s measured, client always JDK 25 | +| Repetitions | 3 per configuration, interleaved so machine drift hits all four equally | + +Four configurations: `jdk25 defaults`, `jdk25 +CompactObjectHeaders` (opt in), `jdk27 defaults`, `jdk27 -CompactObjectHeaders` (opt out). +[`output/01-defaults.txt`](output/01-defaults.txt) shows both JDKs pick G1 on this machine, so the collector is the same everywhere and only headers and the rest of the JDK differ. + +## Quick start + +```console +mvn -B -DskipTests package # build with JDK 25 or later +export J25=/path/to/jdk-25 J27=/path/to/jdk-27 +scripts/run-all.sh # about 12 minutes; regenerates output/ +REPS=5 scripts/run-all.sh # more repetitions +``` + +## Results + +Medians of 3 runs ([`output/03-summary.txt`](output/03-summary.txt), raw blocks in [`output/02-runs.txt`](output/02-runs.txt)): + +| Config | Startup ms | Throughput req/s (range) | p99 ms | +|---|---|---|---| +| jdk25 defaults | 3437 | 6788 (6239-6868) | 3.23 | +| jdk25 +CompactObjectHeaders | 3512 | 7157 (6407-7228) | 3.09 | +| jdk27 defaults | 3220 | 7016 (6755-7275) | 2.99 | +| jdk27 -CompactObjectHeaders | 3388 | 7010 (6867-7227) | 3.03 | + +Resident data set after a forced full GC ([`output/04-live-set.txt`](output/04-live-set.txt), identical in all three repetitions): + +| Config | Live heap MiB | +|---|---| +| jdk25 defaults | 93 | +| jdk25 +CompactObjectHeaders | 69 | +| jdk27 defaults | 70 | +| jdk27 -CompactObjectHeaders | 93-94 | + +## What it says + +- **Footprint is the clear win.** Compact headers cut the live heap by 23 to 24 MiB (whole-MiB rounding). That matches 3,000,000 objects times 8 bytes saved, which is 22.9 MiB. A two-int record goes from 24 to 16 bytes. +- **Speed is not distinguishable.** The throughput ranges overlap in every row and p99 differs by fractions of a millisecond. On this box I cannot claim JDK 27 is faster. +- **Startup is inconclusive.** JDK 27 defaults had the lowest median, but the individual runs overlap with JDK 25. +- Resident set size and committed heap vary run to run with G1's sizing decisions; see the per-run `/stats` lines in `02-runs.txt` before drawing a conclusion from them. + +## Honest notes + +- `output/02-runs.txt` was produced by the first build of the jar. The `/live` endpoint used for `04-live-set.txt` was added afterwards; it does not touch the `/quote` path, but strictly the two files come from two builds of this module. +- Timings drift. Treat them as indicative. + +## Licence + +MIT, as the parent repository. diff --git a/jdk27-perf/output/01-defaults.txt b/jdk27-perf/output/01-defaults.txt new file mode 100644 index 0000000..47470a4 --- /dev/null +++ b/jdk27-perf/output/01-defaults.txt @@ -0,0 +1,16 @@ +== JDK 25 +openjdk version "25.0.4.1" 2026-08-18 LTS + size_t MaxHeapSize = 1568669696 {product} {ergonomic} + bool UseCompactObjectHeaders = false {product lp64_product} {default} + bool UseG1GC = true {product} {ergonomic} + bool UseSerialGC = false {product} {default} + bool UseZGC = false {product} {default} + +== JDK 27 +openjdk version "27" 2026-09-15 + size_t MaxHeapSize = 1568669696 {product} {ergonomic} + bool UseCompactObjectHeaders = true {product lp64_product} {default} + bool UseG1GC = true {product} {ergonomic} + bool UseSerialGC = false {product} {default} + bool UseZGC = false {product} {default} + diff --git a/jdk27-perf/output/02-runs.txt b/jdk27-perf/output/02-runs.txt new file mode 100644 index 0000000..2aefd15 --- /dev/null +++ b/jdk27-perf/output/02-runs.txt @@ -0,0 +1,48 @@ +== jdk25 defaults +startup_ms=3437 +requests=203628 throughput=6788 req/s p50=0.45 ms p99=3.23 ms p99.9=7.27 ms max=27.80 ms +{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":162,"heapCommittedMiB":252,"rssMiB":403} +== jdk25 +CompactObjectHeaders +startup_ms=3658 +requests=214723 throughput=7157 req/s p50=0.43 ms p99=2.88 ms p99.9=6.60 ms max=31.57 ms +{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":114,"heapCommittedMiB":219,"rssMiB":361} +== jdk27 defaults +startup_ms=3166 +requests=218262 throughput=7275 req/s p50=0.44 ms p99=2.76 ms p99.9=6.49 ms max=20.31 ms +{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":76,"heapCommittedMiB":284,"rssMiB":434} +== jdk27 -CompactObjectHeaders +startup_ms=3363 +requests=216804 throughput=7227 req/s p50=0.43 ms p99=3.00 ms p99.9=6.46 ms max=15.04 ms +{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":155,"heapCommittedMiB":310,"rssMiB":458} +== jdk25 defaults +startup_ms=3563 +requests=187160 throughput=6239 req/s p50=0.46 ms p99=4.36 ms p99.9=8.83 ms max=35.12 ms +{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":108,"heapCommittedMiB":177,"rssMiB":325} +== jdk25 +CompactObjectHeaders +startup_ms=3512 +requests=216853 throughput=7228 req/s p50=0.43 ms p99=3.09 ms p99.9=6.54 ms max=20.85 ms +{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":158,"heapCommittedMiB":228,"rssMiB":363} +== jdk27 defaults +startup_ms=3220 +requests=202647 throughput=6755 req/s p50=0.46 ms p99=3.20 ms p99.9=7.43 ms max=24.21 ms +{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":238,"heapCommittedMiB":289,"rssMiB":432} +== jdk27 -CompactObjectHeaders +startup_ms=3539 +requests=206016 throughput=6867 req/s p50=0.45 ms p99=3.21 ms p99.9=6.98 ms max=54.74 ms +{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":189,"heapCommittedMiB":313,"rssMiB":454} +== jdk25 defaults +startup_ms=3419 +requests=206034 throughput=6868 req/s p50=0.45 ms p99=3.14 ms p99.9=6.58 ms max=21.56 ms +{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":204,"heapCommittedMiB":273,"rssMiB":415} +== jdk25 +CompactObjectHeaders +startup_ms=3433 +requests=192205 throughput=6407 req/s p50=0.47 ms p99=3.75 ms p99.9=7.58 ms max=22.22 ms +{"javaVersion":"25.0.4.1","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":87,"heapCommittedMiB":154,"rssMiB":313} +== jdk27 defaults +startup_ms=3602 +requests=210490 throughput=7016 req/s p50=0.45 ms p99=2.99 ms p99.9=6.26 ms max=16.33 ms +{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"true","heapUsedMiB":128,"heapCommittedMiB":229,"rssMiB":386} +== jdk27 -CompactObjectHeaders +startup_ms=3388 +requests=210293 throughput=7010 req/s p50=0.45 ms p99=3.03 ms p99.9=6.68 ms max=32.18 ms +{"javaVersion":"27","gc":["G1 Young Generation","G1 Concurrent GC","G1 Old Generation"],"compactHeaders":"false","heapUsedMiB":227,"heapCommittedMiB":311,"rssMiB":475} diff --git a/jdk27-perf/output/03-summary.txt b/jdk27-perf/output/03-summary.txt new file mode 100644 index 0000000..a8a47da --- /dev/null +++ b/jdk27-perf/output/03-summary.txt @@ -0,0 +1,4 @@ +jdk25 +CompactObjectHeaders | startup 3512 ms | throughput 7157 req/s | p99 3.09 ms | heap used 114 MiB | RSS 361 MiB +jdk25 defaults | startup 3437 ms | throughput 6788 req/s | p99 3.23 ms | heap used 162 MiB | RSS 403 MiB +jdk27 -CompactObjectHeaders | startup 3388 ms | throughput 7010 req/s | p99 3.03 ms | heap used 189 MiB | RSS 458 MiB +jdk27 defaults | startup 3220 ms | throughput 7016 req/s | p99 2.99 ms | heap used 128 MiB | RSS 432 MiB diff --git a/jdk27-perf/output/04-live-set.txt b/jdk27-perf/output/04-live-set.txt new file mode 100644 index 0000000..b6e07aa --- /dev/null +++ b/jdk27-perf/output/04-live-set.txt @@ -0,0 +1,12 @@ +jdk25 defaults: live heap after full GC 93 MiB +jdk25 +CompactObjectHeaders: live heap after full GC 69 MiB +jdk27 defaults: live heap after full GC 70 MiB +jdk27 -CompactObjectHeaders: live heap after full GC 93 MiB +jdk25 defaults: live heap after full GC 93 MiB +jdk25 +CompactObjectHeaders: live heap after full GC 69 MiB +jdk27 defaults: live heap after full GC 70 MiB +jdk27 -CompactObjectHeaders: live heap after full GC 94 MiB +jdk25 defaults: live heap after full GC 93 MiB +jdk25 +CompactObjectHeaders: live heap after full GC 69 MiB +jdk27 defaults: live heap after full GC 70 MiB +jdk27 -CompactObjectHeaders: live heap after full GC 94 MiB diff --git a/jdk27-perf/pom.xml b/jdk27-perf/pom.xml new file mode 100644 index 0000000..d9bf098 --- /dev/null +++ b/jdk27-perf/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + com.ankurm + jdk27-perf + 1.0.0 + jdk27-perf + Same Spring Boot service measured on JDK 25 and JDK 27 + + 25 + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/jdk27-perf/scripts/Load.java b/jdk27-perf/scripts/Load.java new file mode 100644 index 0000000..45253c7 --- /dev/null +++ b/jdk27-perf/scripts/Load.java @@ -0,0 +1,42 @@ +import java.net.URI; +import java.net.http.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** Closed-loop load: THREADS threads, WARMUP seconds discarded, then MEASURE seconds recorded. */ +public class Load { + public static void main(String[] a) throws Exception { + int port = Integer.parseInt(a[0]), warmup = Integer.parseInt(a[1]), measure = Integer.parseInt(a[2]), threads = Integer.parseInt(a[3]); + HttpClient c = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); + HttpRequest rq = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/quote?n=200")).build(); + long start = System.nanoTime(); + long measureFrom = start + warmup * 1_000_000_000L, end = measureFrom + measure * 1_000_000_000L; + List perThread = new CopyOnWriteArrayList<>(); + ExecutorService ex = Executors.newFixedThreadPool(threads); + List> fs = new ArrayList<>(); + for (int t = 0; t < threads; t++) fs.add(ex.submit(() -> { + long[] buf = new long[2_000_000]; int n = 0; + try { + while (true) { + long s = System.nanoTime(); + if (s >= end) break; + c.send(rq, HttpResponse.BodyHandlers.discarding()); + long e = System.nanoTime(); + if (s >= measureFrom && n < buf.length) buf[n++] = e - s; + } + } catch (Exception e) { throw new RuntimeException(e); } + perThread.add(Arrays.copyOf(buf, n)); + return null; + })); + for (Future f : fs) f.get(); + ex.shutdown(); + long total = 0; for (long[] b : perThread) total += b.length; + long[] all = new long[(int) total]; int p = 0; + for (long[] b : perThread) { System.arraycopy(b, 0, all, p, b.length); p += b.length; } + Arrays.sort(all); + System.out.printf("requests=%d throughput=%.0f req/s p50=%.2f ms p99=%.2f ms p99.9=%.2f ms max=%.2f ms%n", + total, total / (double) measure, all[(int) (total * 0.50)] / 1e6, all[(int) (total * 0.99)] / 1e6, + all[(int) (total * 0.999)] / 1e6, all[(int) total - 1] / 1e6); + } +} diff --git a/jdk27-perf/scripts/live-set.sh b/jdk27-perf/scripts/live-set.sh new file mode 100755 index 0000000..97eb5d9 --- /dev/null +++ b/jdk27-perf/scripts/live-set.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Usage: live-set.sh