amazoncorretto:26, or a Temurin 25, to a JDK 27 one. The build is green, the container starts, and nothing in the logs says that every object on your heap just got smaller, or that a one-CPU container that used to run the Serial collector is now running G1. Those are two separate changes. JEP 534 makes compact object headers the default, and JEP 523 makes G1 the default collector everywhere. The Java 27 overview gives each of them one row in a table. This article takes those two rows and puts a real Spring Boot service underneath them.
It starts with what an object header is, measures how much of a real heap it accounts for, and shows exactly which classes shrank and which did not. It then explains why the numbers people usually look at first (resident memory and latency) do not follow the heap down, what a smaller heap buys you when you size a container, what G1 costs in a one-CPU container, and when to opt out. Every number comes from a program that was compiled and run, and each code block links to its file in the companion repository.
Versions. JDK 27+35 (Temurin, GA 15 September 2026), JDK 26.0.2.1 and JDK 25.0.4.1 (Temurin, the current LTS), on a 2-CPU, 8 GB virtual machine. The service is Spring Boot 4.1.1 withspring-boot-starter-web, compiled once for Java 25 so that the same jar runs on all three JDKs and only the JVM changes between runs. The sandbox has no Docker daemon, so container limits are real Linux cgroups created by hand with a memory limit and a CPU quota: the same files Docker writes, and the ones the JVM reads.openjdk.org/jepsreturned HTTP 403 to the tooling used for this article, so the behaviour below comes from running the JDKs, not from the JEP text. Timings on a shared 2-CPU machine are noisy, and the article says where that matters.
An object’s header is a tax you pay on every object
Start with what is being changed. Every object on the Java heap begins with a small header that the JVM uses for its own bookkeeping. Part of it, the mark word, holds bookkeeping such as lock state, a cached identity hash and a garbage-collection age (that description is background knowledge; nothing in this article depends on it). Part of it is a pointer that says which class the object belongs to. On a 64-bit JVM with the default settings of JDK 25 and 26 that is 8 bytes for the mark word plus a 4-byte compressed class pointer: 12 bytes before your first field. Compact object headers fold the class pointer into the mark word, so the header is 8 bytes. Four bytes sounds small, and it is small per object. Two more facts make it matter. The JVM lays objects out on 8-byte boundaries, so a 20-byte object occupies 24 bytes; that is why saving 4 bytes sometimes saves 8 and sometimes saves nothing. And a typical service is full of small objects: boxed numbers, map entries, small records, short strings. The smaller the average object, the larger the share of it that the header is.Long is 8 bytes of value wrapped in 16 bytes of overhead by default (a 12-byte header plus 4 bytes of padding), and in 8 bytes of overhead with compact headers. Whether a given class shrinks depends on where its fields land against the 8-byte boundaries, and you will see later in this article that some classes shrink by 8 bytes and others do not shrink at all.
The JVM can tell you where the first field starts. The program below reads the field offsets of a small class with the same shape as the catalog entry used in the rest of the article.
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");
}
}
Source: Offsets.java. It uses sun.misc.Unsafe only to read offsets, which is why it needs a flag to silence the JDK 24+ warning about that class.
== 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 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
Output: 04-offsets.txt, which also holds JDK 26 (identical to 25) and JDK 27 with -XX:-UseCompactObjectHeaders (identical to 25). On JDK 25 the first field starts at byte 12, and on JDK 27 at byte 8. Everything after it moves too, and the whole object is 8 bytes smaller (32 instead of 40; the histogram later in the article shows the same). Code that hard-codes a 12-byte header (some serialisation libraries, off-heap tools and profilers do) computes the wrong answer on JDK 27.
Going deeper: where the offsets come from and what I did not verify
The field order in the output is the JVM’s choice, not the source order: on JDK 25 the 4-byte priceCents is placed at offset 12, in the gap the 12-byte header leaves before the first 8-byte-aligned slot, and the long follows at 16. With an 8-byte header there is no gap to fill, so the long goes first. That is a layout observation from the output above, not something I read in the JVM source.
The Java 27 overview draws the same layouts with JOL, a library made for exactly this. This article uses the JVM’s own answers (Unsafe offsets here, jcmd GC.class_histogram below) so that the numbers come from the running service and not from a model of it.
Going deeper on this section
- Companion repo: jdk27-memory README (how to regenerate every transcript)
- Official reference: JEP 534 (the change described here; the page returned 403 to my tooling, so read it for the authoritative wording)
- Related on this site: Java 27 Is Out: Every JEP, with the same object drawn by JOL; Value Classes (JEP 401), the larger project that attacks object overhead from the other direction
A Spring Boot service that keeps a catalog in memory
To see what the header saves in practice, you need a workload that looks like production and not a micro-benchmark. The service here is deliberately ordinary. It is a Spring Boot web application that keeps a product catalog in aMap<Long, Product>: two million products, each with an id, a price, a stock count, an active flag and a SKU string. That is the shape of a lot of real services (a cache, a reference-data table, an in-memory index): many small objects, created once, kept for a long time.
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) {
}
Source: Product.java. One record: a long, two ints, a boolean and a String.
@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();
}
}
Source: Catalog.java. The keys are boxed Longs on purpose, because Map<Long, X> is what most services write. Each entry therefore costs three objects that the map creates or holds: a HashMap.Node, a Long and a Product, plus a String and its byte[] for the SKU.
The service has four endpoints besides the product lookup: /load?n= builds the catalog, /memory reports what the JVM says about itself, /lookups?n= times random lookups inside the JVM, and /ping. The one that matters is /memory: it forces two full collections first, so the heap figure it prints is the live set (what the program is really holding), not whatever garbage happens to be lying around.
$ 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]
Output: 00-sample-endpoints.txt, which shows the whole session against a JDK 27 run: idle, loaded, then a lookup that succeeds and one that returns 404.
Going deeper: why the measurement is built this way
Two decisions are worth explaining. First, the heap is fixed (-Xms1g -Xmx1g) in the footprint runs, so the limit is the same in every run. That does not make everything else the same, as the next-but-one section shows, which is exactly why the compared figure is the live set. Second, the figure that is compared is heap used after System.gc(), called twice, because that number is (nearly) deterministic: across the five runs of every configuration it varied by at most one megabyte. Resident memory and timings are collected too, and the next-but-one section is about why they cannot carry the comparison.
The runs are scripted in footprint.sh and the shared start, load and measure logic is in lib.sh. The script takes the JDK locations from environment variables, so it runs on any machine with three JDKs.
Going deeper on this section
- Companion repo: CatalogController.java (the four endpoints) and MemoryReport.java (what
/memoryreads from the JVM) - Official reference: the
jcmdmanual page, forGC.class_histogram - Related on this site: Scoped Values vs ThreadLocal, which uses the same
jcmdobject-census technique on a different question
The live set dropped 15%, and here is exactly which objects shrank
Here are the results for the six configurations, five runs each: JDK 25, 26 and 27 with their defaults, each also with the header flag flipped (compact headers switched on for 25 and 26, switched off for 27).== 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
Output: 02-footprint.txt, which has every individual run as well as these medians. Read the live column first. After loading two million products the live set is 320 MB on JDK 25, on JDK 26, and on JDK 27 with the flag turned off, and 273 MB on JDK 27 with its default and on JDK 25 and 26 with -XX:+UseCompactObjectHeaders. That is 47 MB, or 15%, and it is the same figure whichever JDK you get it from. The JDK version is not what changed the heap; the header is. The idle column, the same service before any catalog is loaded, moves from 12 or 13 MB to 11 or 12 MB: a real but much smaller effect, presumably because an idle Spring Boot application holds few objects compared with a catalog of millions (I did not take a histogram of the idle heap).
The heap histogram says where the 47 MB came from. It counts every live object by class, and dividing bytes by instances gives the size of one.
== 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)
Output: 03-classes.txt, computed by classes.sh from the histograms of the footprint runs. Three of the five biggest classes shrank by 8 bytes each (Product 40 to 32, HashMap$Node 32 to 24, Long 24 to 16). Two did not move: String stays at 24 and the byte[] that holds its characters stays at about 32.6.
String and byte[] bars show that it is not: the saving only appears when it crosses an 8-byte boundary.
Why the String did not shrink. AStringholds a reference to its characters (4 bytes), a cached hash (4 bytes), a one-byte coder and a one-byte flag: 10 bytes of fields. On the old layout that is a 12-byte header plus 10 bytes, 22, rounded up to 24. With an 8-byte header the same fields need 18, and the next 8-byte boundary is 24 again. Thebyte[]for a 13-character SKU is 16 bytes of header and length plus 13 bytes of data, rounded up to 32, and with the smaller header it is 12 plus 13 rounded up, which is 32 again. Compact headers are not free 4 bytes per object; they are 4 bytes per object when the rounding lets them be, and for a class whose fields are small and few it often does not. I derived the String and byte[] numbers from the field layout, and the measured 24.0 and 32.6 above agree with them.
Going deeper: what to expect for your own heap
A 15% smaller live set is the answer for this catalog, not a constant. The saving on your service depends on the mix of classes: a heap dominated by large arrays and big strings will save close to nothing, because a 4-byte saving on a 4 MB array is invisible, while a heap of millions of small records, boxed numbers and map entries will save more than this one. The way to find out is the same histogram used here. Run jcmd <pid> GC.class_histogram on your service under representative load, divide bytes by instances for the top ten classes, and compare a JDK 26 run with a JDK 27 run of the same build.
I measured one workload, so I cannot say how typical 15% is. What the histogram does tell you is which way to expect your own number to move: the larger the share of your live set that is array data and long strings, the smaller the saving, and the larger the share that is small records, boxed numbers and map entries, the larger it is.
Going deeper on this section
- Companion repo: footprint.sh and classes.sh
- Official reference: the
jcmdmanual forGC.class_histogram - Related on this site: Generational ZGC on JDK 25: Benchmarks vs G1, where the collector choice changes the size of every reference, the other big lever on heap size
Why resident memory and lookup time did not follow the heap down
If the live set is 15% smaller, you would expect the process to use less memory and to run a little faster. The table above does not show either cleanly, and it is worth understanding why before you run your own comparison and draw the wrong conclusion. Look at the individual runs in the transcript: the resident set size (RSS, the memory the operating system has actually given the process) is not spread around a median. It falls into two groups, 409 to 462 MB and 594 to 701 MB, and the lookup time differs between the same two groups. The script groups the same thirty runs by one question: did the garbage collector run at all while the catalog was loading?== 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
Output: 02-footprint.txt, last block. The split has nothing to do with the header: both header modes appear in both groups. A plausible reading is this. In a run where no collection happened during the load, the heap grew into memory that had never been touched, and RSS stayed near the size of the data. In a run where a young collection did happen, the collector copied surviving objects into fresh regions and touched more memory, and RSS ended about 250 MB higher (medians of 665 MB against 418 MB); the run ends shortly afterwards, so I cannot say how long it would have stayed there. The same collection also moved the products and map entries next to each other in memory, which is a plausible reason the lookups in those runs were faster (158 to 237 ns against 223 to 318 ns); I did not test that explanation, it is only consistent with the data.
What this means for your comparison. RSS after a run is a function of the heap’s history (how far the JVM grew it and whether it ever collected), and lookup time here is a function of memory layout after the last collection. Neither is a clean measure of object size. The live set afterOnce you compare like with like, RSS does show the effect. Among the runs with no collection during the load, the eight with the old header sit at 454 to 462 MB and the eleven with compact headers at 409 to 419 MB, a difference of about 45 MB, the same size as the live-set saving. That is the honest reading: the heap is smaller by 47 MB, and how much of that shows up in RSS depends on what else the collector did.System.gc()is. If you compare RSS between two JDKs, control the heap (-Xmsequal to-Xmxdoes not fully do it, as the data above shows), repeat the run several times, and look for whether the runs cluster before you take a median.
Going deeper: throughput and GC time
The transcript also records how long the collection during the load took: 191 to 236 ms, one young collection, in the runs where it happened. Compact headers do not explain whether it happened. Of five runs each, a collection happened during the load in 1 run on JDK 25 default, 1 on JDK 25 with compact headers, 5 on JDK 26 default, 2 on JDK 26 with compact headers, 1 on JDK 27 default and 1 on JDK 27 with compact headers off. JDK 25 default and JDK 27 opted out have identical heap contents and the same count, while JDK 26 default, with the same contents again, collected every time. So something differs between the releases (plausibly how G1 sizes the young generation early in the run); I did not investigate what.
If throughput is the question you actually have, measure it on your own service with a load generator and JFR’s GC events. The /lookups endpoint here is a probe for pointer-chasing cost, not a load test.
Going deeper on this section
- Companion repo: 02-footprint.txt (all thirty runs)
- Official reference: HotSpot Virtual Machine Garbage Collection Tuning Guide
- Related on this site: Generational ZGC on JDK 25: Benchmarks vs G1, for how much a measurement method (open versus closed loop) can change a headline number
What a smaller heap buys you: the smallest container that still works
The saving that matters to an operator is not the megabytes on a heap dump; it is the memory limit in the deployment file. A container has a memory limit, and the JVM turns that limit into a maximum heap by taking a percentage of it: 25% by default, set by-XX:MaxRAMPercentage. Everything that is not heap (class metadata, thread stacks, compiled code, the collector’s own structures, network buffers) has to fit in the rest. If the total goes over the limit, the operating system kills the process: on Kubernetes that is the OOMKilled status and exit code 137, with no Java stack trace, because the JVM never got to say anything.
So how much smaller can the container be? The script starts the catalog service inside a real cgroup at each memory limit, three times per JVM, and records whether the two-million-product load finishes. It runs the sweep twice: once with the heap raised to 75% of the container (a common production setting) and once with the JVM’s default 25%.
== 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
First sweep, from 05-container-sizing.txt.
== 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
Second sweep, the same file, produced by container-sizing.sh. Each cell is three runs. 3/3 ok means all three loaded the catalog; the figure after it is the median resident memory of those runs. A failed cell says how it failed.
Three failures that look alike from outside.The same configuration can end in more than one of these ways. To check thatjava-OOMis the Java heap running out: the process is alive, the request fails with HTTP 500, anOutOfMemoryErroris thrown, and you know what happened.killedis the kernel enforcing the container limit on total memory: no Java stack trace, the process is simply gone (exit code 137), and the fix is more container memory or a smaller heap percentage.no-result-in-25sis the worst of the three: the process is alive, the load did not finish in 25 seconds although a healthy one takes about a second, and I saw the process using a full CPU while it made no progress, which I take to be the collector working flat out on a heap that is almost full. A health check that only tests whether the port is open would pass. JDK 26 at a 400 MB limit ended this way in two of three runs and with a heapOutOfMemoryErrorin the other.
killed really is the kernel and not something in my scripts, the repository has a small check that runs the opted-out JDK 27 at a 400 MB limit four times and reads the cgroup’s own counters afterwards.
== attempt 1: /load answered HTTP 000; process gone, exit 137
memory.failcnt=155 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
Output: 07-oom-check.txt, from oom-check.sh. In the run quoted here, three of the four attempts ended with the process gone and exit code 137, memory.failcnt counting the times the limit was hit, oom_kill 1 in the cgroup’s own record, and the kernel log naming the process it killed; the fourth stayed alive at 397 MB of a 400 MB limit and answered the request with HTTP 500. An earlier run of the same script, which I did not keep, had the opposite split (one kill, three survivors), and the sweep above killed this configuration three times out of three at the same limit, so a limit this close to the edge does not give one answer. That is the practical reason not to run with a live set close to the heap size, and it is why the sweep above uses three runs per cell.
Going deeper: how the limits are set here, and how to do it with Docker
The sandbox has no Docker daemon, so lib.sh creates a cgroup directly (a memory limit in memory.limit_in_bytes and a CPU quota in cpu.cfs_quota_us), starts the JVM inside it and removes the cgroup afterwards. That is the mechanism Docker and Kubernetes use, and the JVM confirms it read the limits: the one-CPU runs in the next section report cpus=1, and the heap sizes in the table follow the percentage (300 MB at a 400 MB limit with 75%). The script is written for cgroup v1, which is what this machine has. On a cgroup v2 host the files have different names (memory.max, cpu.max) and the script would need adapting; I did not test that.
The Docker equivalent of a row in the table is docker run --memory=400m --cpus=2 -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 your-image. I expect it to behave the same, but I could not run it here, so treat that as untested.
Resident memory in the ok cells is the median of three runs, and an earlier section explains why to read it with care: I did not record whether a collection happened during the load in these runs, so I cannot say whether that explains the spread at a 704 MB limit (JDK 26 median 527 MB, JDK 27 median 431 MB, JDK 27 opted out 465 MB).
Going deeper on this section
- Companion repo: 05-container-sizing.txt and container-sizing.sh
- Official reference: the
javacommand reference (search forMaxRAMPercentage), and the Linux kernel’s cgroup v1 memory controller - Related on this site: Deploying Spring Boot 4 on Kubernetes (what the JVM decides for nine pod shapes, and why a probe that only checks the port lies to you)
G1 everywhere: what JEP 523 changes in a small container
The second change is about which collector you get without asking. A garbage collector is the part of the JVM that finds and frees objects nobody uses any more. HotSpot has several, and if you do not pick one it picks for you. Until JDK 26 the rule was: on a machine with at least two CPUs and a couple of gigabytes of memory, use G1; on anything smaller, use Serial, a simple single-threaded collector with very little bookkeeping. The JVM looks at the container’s limits, not the host’s, so a pod with a one-CPU limit got Serial however big the node underneath it was. In JDK 27, G1 is the default everywhere. Rather than describe that, ask the JVM. The script puts each JDK in a cgroup with a 1 GB memory limit and a CPU quota, and prints which collector it chose.== 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
Output: 01-jvm-defaults.txt, produced by flags.sh. With one CPU, JDK 25 and JDK 26 choose UseSerialGC and JDK 27 chooses UseG1GC. The next block of the same file shows the case that surprises people: two CPUs but only 1 GB.
== 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
Same file (01-jvm-defaults.txt). JDK 26 still chooses Serial with two CPUs and 1 GB, and JDK 27 chooses G1. On the unrestricted sandbox (2 CPUs, several GB) all three JDKs choose G1 (the top of the same file), so nothing changes for a service that was already large.
== 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
Output: 06-one-cpu.txt, first block: JDK 26 with the collector the JVM chose (Serial).
== 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
Same file: JDK 27 with the collector the JVM chose (G1).
== 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
Same file (06-one-cpu.txt), medians of three runs, which also has two more configurations: JDK 26 forced onto G1, and JDK 27 on Serial with compact headers off. The first part is native memory outside the heap. Native Memory Tracking reports the collector’s own data structures: 1.7 MB for Serial and 43 to 47 MB for G1, on the same 512 MB heap. That is roughly 9% of the heap size: the collector’s own bookkeeping, which I did not break down further. In a container sized tightly around a small heap it is real money. The second part is that the visible effects on speed were small in this run: the number of young collections during the load (8 to 12), their total pause time (472 to 523 ms) and the start-up time (4.7 to 5.1 seconds) show no pattern I would trust from three runs, and the load time ranged from 630 to 909 ms with no clear order either.
The extra native memory did not show up as extra resident memory. Resident memory was 529 MB on JDK 26 with Serial and 530 MB on JDK 27 with Serial and compact headers off: the same. Turning compact headers on (Serial, 482 MB) took 48 MB off, close to the live-set saving. Then switching from Serial to G1 (436 MB) took a further 46 MB off, even though G1 carries about 42 MB more collector structures. So in this container the default move from 26 to 27 used about 93 MB less resident memory, not more. I do not know why G1’s heap ended up smaller than Serial’s here (Serial’s young generation may simply have grown larger); I did not investigate, and one workload in one container is not a rule. It is a reason to measure your own pod before you assume the G1 cost is the thing to worry about.If you want the old behaviour back for small pods, ask for it:
-XX:+UseSerialGC works on JDK 27, and the last block of the output above shows it running there (collectors=[Copy, MarkSweepCompact] is Serial’s pair of collections, young and old). The JVM will not overrule an explicit choice.
Going deeper: the ergonomics rule and what I did not verify
The Java 27 overview quotes the old server-class threshold (about 2 GB, 1792 MB in HotSpot’s source) from reading the source, and this article did not re-derive it. What it did establish is the two rows that matter in practice: with a 1 GB limit, both one CPU and two CPUs gave Serial on 26 and G1 on 27. I did not run a two-CPU pod with 2 GB or a one-CPU pod with 8 GB, so I make no claim about which side of the old line those fall on.
For the wider question of what limits and requests to set on Kubernetes, and why a CPU limit throttles the collector’s threads, Deploying Spring Boot 4 on Kubernetes measured nine pod shapes on a real cluster.
Going deeper on this section
- Companion repo: one-cpu.sh (how the containers and the native-memory readings are made)
- Official reference: Native Memory Tracking, and JEP 523 (returned 403 to my tooling)
- Related on this site: Deploying Spring Boot 4 on Kubernetes for pod shapes and CPU throttling; Dockerizing Spring Boot 4 for the image side
Opting out, and what changes when you do
Both changes can be reversed with a flag, and it helps to know exactly which flags work on JDK 27 and which quietly stopped. The script asks the JVM directly and records the answer.== -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
From 01-jvm-defaults.txt: the old MaxRAM flag on JDK 26 and 27.
== 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
Same file: the header flag with every collector.
== 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
Same file (01-jvm-defaults.txt), last block. Four things in it are worth acting on.
-XX:-UseCompactObjectHeaders turns compact headers off on JDK 27. The earlier sections used it, and the live set went back to 320 MB. -XX:+UseCompactObjectHeaders turns them on for JDK 25 and 26, which is a way to try the change on the current LTS before moving; the flag is a product flag and needs no unlock on either.
The flag combines with every collector that was tried: G1, Parallel, Serial and ZGC all start with compact=true.
Two flags that used to work are now ignored, with a warning.-XX:MaxRAM=1g, which tells the JVM to assume a given amount of memory, was deprecated in 26 and prints Ignoring option MaxRAM; support was removed in 27.0 on 27: the maximum heap stayed at the machine default (1498 MB) instead of the 256 MB it produced on 26. And-XX:-UseCompressedClassPointersprints the same message on 27 (presumably because compact headers keep the class pointer inside the header, but the JVM’s message gives no reason). A start-up script that carries either flag from an older setup still starts, and silently does something different. Search your launch scripts and Dockerfiles for both before upgrading.
Unsafe.objectFieldOffset and jcmd, both gave correct answers on each JDK without any change.
I did not test any third-party APM agent, profiler or serialisation library against JDK 27, and this article makes no claim about any of them. The safest way to check yours is the way the flag suggests: run the same test suite and a smoke test on JDK 27 with -XX:+UseCompactObjectHeaders and with -XX:-UseCompactObjectHeaders, and if only the first fails, you have found a component that assumes the old layout.
Going deeper: why the MaxRAM change matters more than it looks
-XX:MaxRAM tells the JVM to assume a different amount of memory from what the machine has, which made it a cheap way to pretend to be a small container without one. On JDK 27 the JVM ignores it, so a test that used it to simulate a small container no longer simulates anything: the JVM sizes itself for the real machine (the 1498 MB heap in the output above). For a realistic test you need a real cgroup limit (a container, or the cgroup setup in lib.sh), or you set the heap explicitly with -Xmx or -XX:MaxRAMPercentage, which still work.
The removed and deprecated flags are also collected in the Java 27 overview, in its small-breakages section.
Going deeper on this section
- Companion repo: flags.sh and Offsets.java
- Official reference: the
javacommand reference, and JEP 534 - Related on this site: Java 27 Is Out: Every JEP for the other removals in the same release
Should you keep the defaults?
Yes, for most services, with one check and one habit. Keep compact object headers on. The catalog service saved 15% of its live set for no code change, the saving costs nothing at run time that this article could measure, and switching it off is one flag if something breaks. Keep G1 as the collector unless you have a reason not to; on any machine with two or more CPUs and reasonable memory it was already the default (see the grid above).That closes the loop on the two rows in the overview. Compact headers make a heap of small objects smaller by an amount you can predict from a class histogram. G1 everywhere changes the collector in small pods, at a cost you can read off Native Memory Tracking. Neither is dramatic, both are easy to measure, and both are worth measuring on your own service before you trust anyone else’s percentage, including this one.
The check: before you upgrade, grep your launch scripts and Dockerfiles for-XX:MaxRAMand-XX:-UseCompressedClassPointers, and run your test suite with the header flag both ways on the current LTS (-XX:+UseCompactObjectHeadersworks on 25). The second test finds any agent or native library that assumed a 12-byte header.
The habit: size a container from a measured live set, not from a percentage of the old one. A 15% smaller live set does not entitle you to a 15% smaller memory limit. The sweep above shows that the container needs more than the heap: at a 400 MB limit with a 300 MB heap, the opted-out JDK 27 was killed by the kernel in all three runs, so the total went over the limit while the heap had not. And the failures at the edge are abrupt: a JavaOutOfMemoryErrorif the heap is short, a kernel kill with no Java stack trace if the total is, or a process that stays alive and stops making progress. Lower the limit in steps, watch for restarts, and leave headroom.
When to think harder: a heap of large arrays or long strings (little to gain), a fleet of one-CPU pods (G1 carries about 42 MB more collector structures per pod; that did not show up as more resident memory in this test, but it is the first number to check), or third-party native code that touches object headers.
Further reading
- Companion repository: jdk27-memory in zgc-jdk25-benchmarks, with
run.shto regenerate every transcript quoted here - On this site: Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped; Generational ZGC on JDK 25: Benchmarks vs G1; Deploying Spring Boot 4 on Kubernetes; Dockerizing Spring Boot 4
- JEPs (all returned HTTP 403 to my tooling; read them for the authoritative wording): JEP 534, JEP 523
- Reference: HotSpot GC Tuning Guide, Native Memory Tracking,
jcmd
No Comments yet!