Skip to main content

What Actually Got Faster in JDK 27: Measured on a Spring Boot App

The same Spring Boot 4.1.1 service on JDK 25 and JDK 27, with compact object headers opted in and out. The footprint win is real and repeatable (about 24 MiB less live heap); throughput, p99 and startup are not distinguishable on a small box. Includes the numbers, the noise, and a flag that gets the memory win on JDK 25 today.

Every new JDK release comes with the sentence “it is faster”. JDK 27 reached general availability on 15 September 2026, and two of its changes alter defaults that a server cares about: compact object headers are now on (JEP 534) and G1 is the default collector in every environment (JEP 523). So I took one Spring Boot service, ran it on JDK 25 and JDK 27 with no tuning, and measured what changed. The short answer is that memory got smaller, repeatably and for a reason I can calculate, while speed did not change by anything I can distinguish from noise. The rest of this post shows the numbers, including the ones that did not come out the way a release announcement would like.
Versions tested. Temurin 25.0.4.1+1 (LTS) and Temurin 27+35 (GA 2026-09-15), Spring Boot 4.1.1. Machine: 2 vCPU, 8 GB RAM, with the load generator on the same machine, so absolute numbers are modest and noisy. Three repetitions per configuration, interleaved. Companion code and raw output: jdk27-perf in the javademos repository.

The two defaults that changed

The OpenJDK page for JDK 27 lists the features in the release. Two of them affect a server without any code change. JEP 534 turns on compact object headers by default. Every Java object starts with a header; the compact form shrinks it from 12 bytes to 8, so small objects take less memory. JEP 523 makes G1 the default collector in all environments. On JDK 25 the JVM picked the serial collector instead when a container looked too small (fewer than about two CPUs or less than about 1.8 GB), which is why the same app could behave differently in a tiny pod. You can see the first change directly. This is from 01-defaults.txt, the JVM’s own report of its defaults on this machine:
== 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}
On JDK 25 the same file shows UseCompactObjectHeaders = false. Both JDKs chose G1 on this 2-CPU, 6 GB-visible machine, so the collector is identical in every comparison below. That is deliberate: it isolates headers and the rest of the JDK from the GC choice. (The container effect of JEP 523 is covered in JDK 27 memory changes.)

The service under test

A benchmark of an empty app would measure nothing, and a benchmark of a toy loop would not look like a server. This service has two properties of a real one. It holds a large resident data set of small objects: 3,000,000 records of two ints each, so object headers matter. And its request path allocates and serialises: /quote?n=200 builds 200 response records and Jackson turns them into JSON, so the GC and the JIT matter. The source is PerfApplication.java, and the closed-loop load generator is Load.java (4 threads, 15 seconds of warmup discarded, 30 seconds measured). I ran four configurations: JDK 25 as shipped, JDK 25 with compact headers switched on, JDK 27 as shipped, and JDK 27 with compact headers switched off. The two extra rows are the useful ones: they separate “what the new default does” from “what else changed in the JDK”.

Result one: the resident data set shrank by about 24 MiB

To measure memory fairly I did not read the heap during load, because the used heap includes garbage that has not been collected yet and changes with every GC. Instead the harness forces a full collection and then reads what is still in use. That is the resident data set. These are the first four lines of 04-live-set.txt (the three repetitions agreed to within 1 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 93 MiB
jdk25 defaultsjdk25 +compactjdk27 defaultsjdk27 -compact 93 MiB 69 MiB 70 MiB 93 MiB Live heap after a forced full GC. Green = compact headers on. Bars start at zero.
The chart shows that the headers, not the JDK version, decide the size: the two green bars (headers on) are 24 MiB smaller than the two amber bars (headers off), whichever JDK runs them. The arithmetic confirms it. A record of two ints is 8 bytes of data; with a 12-byte header it occupies 24 bytes after alignment, and with an 8-byte header it occupies 16. 3,000,000 objects times 8 bytes saved is 24,000,000 bytes, which is 22.9 MiB. The measured drop is 23 to 24 MiB (93 to 69-70, rounded to whole MiB), so the saving is explained by the header size alone. Your own saving depends on how many small objects you keep alive. A service whose memory is mostly large arrays and strings will see less.

Result two: speed did not change in any way I can measure

Now the part a release announcement would prefer to skip. These are the medians of three runs from 03-summary.txt:
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
Reading medians alone would be misleading, so here are the throughput ranges across the three runs of each configuration:
ConfigMedian req/sLowest–highest runp99 (median)
JDK 25 defaults67886239–68683.23 ms
JDK 25 +compact headers71576407–72283.09 ms
JDK 27 defaults70166755–72752.99 ms
JDK 27 −compact headers70106867–72273.03 ms
Every row overlaps every other row. JDK 27 has the highest single run (7275) and a slightly lower median p99, but JDK 25 with compact headers reaches 7228 and the JDK 25 default has a 6239 outlier that drags its median down. On a 2-CPU box where the load generator competes with the server for the same cores, the run-to-run spread is bigger than any difference between configurations. I cannot claim JDK 27 is faster on this workload, and I cannot claim it is slower. Startup tells the same story: JDK 27 defaults had the lowest median (3220 ms), but its three runs were 3166, 3220 and 3602 ms, and JDK 25’s were 3437, 3563 and 3419. The honest reading is that the changes in JDK 27 that matter for this kind of service are about footprint and defaults, not throughput. If a faster server is your reason to upgrade, measure your own workload on hardware that does not share cores with the load generator before you believe anyone’s slide.
Going deeper: why RSS and “heap used” are not a memory result
The summary lines above include heap used and RSS columns, and they look like a memory comparison. They are not a reliable one. Heap used is read at one instant after load and includes garbage; RSS includes everything G1 decided to keep committed. Here are the per-run values from 02-runs.txt, resident set size in MiB:
ConfigRSS across 3 runs
JDK 25 defaults325, 403, 415
JDK 25 +compact headers313, 361, 363
JDK 27 defaults386, 432, 434
JDK 27 −compact headers454, 458, 475
Within one JDK, turning compact headers on lowered RSS ((JDK 27: roughly 25 to 70 MiB lower with them on, comparing medians and extremes)). But JDK 27 defaults used more RSS than JDK 25 defaults in these runs, even with the smaller live set. G1 sizes its committed heap from allocation behaviour, and that differs between the two JDKs and between runs. So the footprint claim I am willing to make is the one from the forced-GC measurement: the live data is smaller. What your container’s memory graph does is a separate question, and you should measure it.

What to do with this

You do not need to leave JDK 25 to get the memory win. The row “jdk25 +CompactObjectHeaders” ran with just -XX:+UseCompactObjectHeaders on Temurin 25.0.4.1 and produced the same 69 MiB live heap as JDK 27’s default. If you run the LTS and keep many small objects in memory, try that flag, compare your own live set, and keep it if the numbers hold. Test it under your real workload first; I measured one service on one machine.
Should you move to JDK 27 for speed? Not on this evidence. JDK 27 is a feature release, not an LTS, and what I measured was a footprint improvement you can also get on 25 with one flag. Move because you want the features or have a short upgrade cycle, not because of a throughput number.
Reproduce it, and the caveats on method
Build with mvn -B -DskipTests package, set J25 and J27, and run run-all.sh (about 12 minutes). Use REPS=5 for tighter medians. Configurations are interleaved within each repetition so any slow drift on the machine hits all four equally. Two caveats from the module README. First, the throughput file was produced by the first build of the jar; the /live endpoint used for the live-heap measurement was added afterwards. It does not touch the /quote request path, but strictly the two result files come from two builds. Second, timings drift between runs and machines; treat them as indicative.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.