Skip to main content

JDK 27 Memory Changes: Compact Object Headers by Default and G1 Everywhere (JEP 534 + 523)

JDK 27 makes compact object headers and G1 the defaults everywhere. Measured on a Spring Boot 4.1 service with JDK 25, 26 and 27: 15% smaller live set, which classes shrink, real cgroup container sizing, and the flags that stopped working.

You change the base image from 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 with spring-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/jeps returned 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.
java.lang.Long, one boxed number (each square is one byte, 20 pixels wide) JDK 25 / 26 default mark word 8 class 4 value 8 padding 4 = 24 bytes JDK 27 default (compact object headers) mark word + class 8 value 8 = 16 bytes The header (red and orange) shrinks by 4 bytes, and here the padding disappears with it: a third of the object is gone.
The picture is one object, but it is the shape of the whole result. A 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 a Map<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.
catalog.jarone jar, built once JVM under testJDK 25 / 26 / 27compact headerson or off /load?n=2000000build the catalog /memory2 full GCs jcmdclasshistogram The only thing that changes between runs is the JVM. The jar, the data and the heap size are the same.
The diagram is the whole method. One jar, started fresh on each JVM with the same fixed heap, loaded with the same two million products, then asked for its live heap after two full collections. Because the input and the program are identical, any difference in the live set is the JVM’s doing.
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

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.
Bytes per instance 40 32 Product 32 24 HashMap.Node 24 16 Long 24 24 String 32.7 32.6 byte[] (SKU) JDK 26 default JDK 27 default
The chart is the table drawn, and the point of drawing it is the two bars that did not move. A saving of 8 bytes on three classes is what you would expect if the header were 4 bytes smaller and everything else were free to shrink. The 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. A String holds 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. The byte[] 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

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 after System.gc() is. If you compare RSS between two JDKs, control the heap (-Xms equal to -Xmx does 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.
Once 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.
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

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.
Smallest tested container limit where 3 of 3 runs loaded the catalog Heap = 75% of the container JDK 26448 MB JDK 27 -off432 MB JDK 27400 MB Heap = 25% of the container (the JVM default) JDK 261536 MB JDK 27 -off1280 MB JDK 271152 MB
The bars are the smallest limit in each sweep at which every run finished. The lower group is drawn at a smaller scale (about 0.3 of a pixel per megabyte, against one pixel in the upper group), so the two groups are not comparable with each other; compare bars within a group. Two things stand out. First, JDK 27 with its default header needs the smallest container in both groups. At the default 25% heap it fits in 1152 MB where JDK 26 needs 1536 MB, a container 25% smaller; at 75% it fits in 400 MB against 448 MB, 11% smaller. The default percentage gives the bigger saving because every megabyte of heap you save is worth four megabytes of container. Second, the middle bar, JDK 27 with compact headers switched off, sits between the other two. Its live set is identical to JDK 26’s (320 MB), and it still fits in a smaller container, so part of the improvement is not the header at all: something in JDK 27 (plausibly G1) copes with a tighter heap than JDK 26 did, for a reason I did not track down. The header accounts for the step from the middle bar to the green one (1280 to 1152 MB, and 432 to 400 MB). The limits tested are the ones in the table and no others, so the true minimum for each JVM lies somewhere between its first passing row and the failing row below it. The table also shows that there are three different ways to fail, and they need different responses.
Three failures that look alike from outside. java-OOM is the Java heap running out: the process is alive, the request fails with HTTP 500, an OutOfMemoryError is thrown, and you know what happened. killed is 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-25s is 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 heap OutOfMemoryError in the other.
The same configuration can end in more than one of these ways. To check that 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

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.
Default collector, measured (cgroup limits, JVM’s own answer) JDK 25JDK 26JDK 27 1 CPU, 1 GB Serial Serial G1 2 CPUs, 1 GB not run Serial G1 2 CPUs, several GB G1 G1 G1 Orange cells are the containers whose collector changes when you move to JDK 27.
The grid is the whole behavioural change: the orange cells are the pods that used to run Serial and now run G1, and every other cell is unchanged. If your services run with two or more CPUs and generous memory (the largest case I tested was 2 CPUs with several gigabytes), you should not notice JEP 523 at all. If some of them run in small pods, you have a new collector in them, and it is fair to ask what it costs. The answer, measured on the same catalog service in a 1 GB, one-CPU container with a 512 MB heap, has two parts.
== 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

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:-UseCompressedClassPointers prints 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.
The same object, laid out by each JVM (10 pixels per byte; offsets from the transcript above) JDK 25 / 26 default: 40 bytes header 0-12 price id @16 stock sku pad JDK 27 default: 32 bytes header 0-8 id @8 price stock sku The thin blue sliver is the one-byte active flag; the dashed box after it is unused space the next field cannot use.
The picture is the offsets from the earlier transcript drawn to scale. Both layouts hold the same five fields. The difference is that every offset moved, and the total moved by 8 bytes. Anything that guesses the layout instead of asking the JVM (native code reading a field at a fixed offset, an agent that computes object sizes by adding 12, a tool that walks a heap dump on the assumption of a 12-byte header) is affected. Code that asks the JVM is not: the two tools this article used, 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

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).

The check: before you upgrade, grep your launch scripts and Dockerfiles for -XX:MaxRAM and -XX:-UseCompressedClassPointers, and run your test suite with the header flag both ways on the current LTS (-XX:+UseCompactObjectHeaders works 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 Java OutOfMemoryError if 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.
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.

Further reading

No Comments yet!

Leave a Reply

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