Java 27 features: Compact Object Headers by default, JFR redaction & post-quantum TLS!

The last time I did a proper JEP-by-JEP writeup, records were the shiny new preview toy and we were all arguing about whether switch expressions would ever feel natural (they do). Fast forward to today: JDK 27 is past Rampdown and into the Release Candidate phase, the feature set has been frozen at nine JEPs since June 4th, and GA lands on September 15, 2026. So I installed the early-access build on my MacBook (via SDKMAN, obviously; I'm a lazy developer) and spent an evening putting the interesting bits through their paces, receipts included:

$ sdk install java 27.ea.33-open
$ java -version
openjdk version "27-ea" 2026-09-15
OpenJDK Runtime Environment (build 27-ea+33-2320)

And here's the plot twist, a shocking betrayal of this blog's tagline: almost everything worked on the first try. Even the preview stuff. My lone compile error of the evening turned out to be a feature, not a bug (more on that later). I was almost disappointed!

Here's the punchline up front: JDK 27 is not a flashy release, and that's exactly why it's a great one. Four of the nine JEPs are final, and all four are of the "the JDK quietly does the right thing without asking" variety: smaller object headers by default, G1 everywhere, secrets scrubbed from flight recordings, and post-quantum key exchange in TLS. Zero code changes required for any of them. The remaining five are previews and incubators still cooking, including two long-distance runners we'll tease affectionately later (a seventh preview and a TWELFTH incubator, I kid you not).

In this post, we'll walk through all nine JEPs, finals first, plus a couple of smaller tidbits that don't get their own JEP but might still bite. Let's dig in!

The finals: free lunch, four courses

JEP 534: Compact Object Headers by Default

This one has been on a long journey: experimental in JDK 24, production-ready in JDK 25, and now, in JDK 27, it's simply the default. Every object on the heap carries a header, and until now that header was 96 bits on 64-bit platforms (with the default compressed class pointers; 128 bits without): a Mark Word (locking, GC age, identity hash) plus a Class Word (a pointer to the class metadata). Compact headers merge the two into a single 64-bit word.

Why should we care?! Because Java heaps are full of small objects. A header shrinking from 12 to 8 bytes sounds like nothing until it's multiplied by the hundred million OrderLine and Optional instances sitting in a typical service heap. The numbers back it up: JEP 534 reports 22% less heap and 8% less CPU time on SPECjbb2015 (plus 15% fewer garbage collections), and Project Lilliput's early adopters were already reporting live data shrinking by 10-20% back in the JDK 24 experimental days.

If, like me, half of your day job is staring at containerized JVMs with memory limits that someone picked in 2021 and nobody dares to touch, this is free money. Same -Xmx, more actual data. Nothing to enable, nothing to tune. Don't take my word for it, ask the JVM:

$ java -XX:+PrintFlagsFinal -version | grep -i compactobject
     bool UseCompactObjectHeaders                  = true    {product lp64_product} {default}

There's an escape hatch if something misbehaves:

java -XX:-UseCompactObjectHeaders MyApp

But note that the flag is on its way out; the plan is to eventually remove the legacy layout entirely. Enjoy the opt-out while it lasts :)

What's actually inside an object header ?!

Time to get the microscope out. In the legacy layout, every object starts with two words. The first is the 64-bit mark word, the object's runtime state: the low 2 bits hold the lock state, a few more bits track the GC age (how many young collections the object has survived), and the identity hash code lands in there once it's computed. The second is the class word, a compressed 32-bit pointer to the class metadata, telling the JVM what the object actually IS.

Don't take my word for it either; this is exactly what JOL (Java Object Layout, the OpenJDK tool built for this kind of snooping) is for. Take the tiniest possible class:

public class Pair {
    int a;
    int b;
}

Grab jol-cli from Maven Central (org.openjdk.jol:jol-cli:0.17, the "full" jar) and point it at the class, first with the legacy layout:

$ javac Pair.java
$ java -XX:-UseCompactObjectHeaders -cp .:jol-cli.jar org.openjdk.jol.Main internals Pair
Pair object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0x0105f778
 12   4    int Pair.a                    0
 16   4    int Pair.b                    0
 20   4        (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

A couple of interesting things here:

  • The mark word ends in ...01: that's the "unlocked" lock state in the low 2 bits, and "age: 0" means this poor object hasn't survived a single young GC yet.
  • The class word is right there at offset 8: 0x0105f778, the compressed pointer to Pair's metadata.
  • Do the math: 8 (mark) + 4 (class) + 4 + 4 (the ints) = 20 bytes, but objects align to 8 bytes, so a 4-byte gap pads it up to 24.

Now the same class on the JDK 27 default:

$ java -cp .:jol-cli.jar org.openjdk.jol.Main internals Pair
Pair object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0110540000000001 (Lilliput)
  8   4    int Pair.a                    0
 12   4    int Pair.b                    0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

Notice that the class word is simply gone. The class pointer got squeezed down to 22 bits and moved INTO the upper bits of the mark word (that's the non-zero high bits in 0x0110540000000001, while the low bits still say "unlocked"), and JOL literally annotates it "(Lilliput)", a nod to Project Lilliput, the OpenJDK project behind all of this. The fields slide up into the space the class word used to occupy, the alignment gap evaporates, and our two-int Pair drops from 24 bytes to 16. A third smaller! And in case you're wondering, the 31-bit identity hash still fits in the compact mark word, so System.identityHashCode() behaves exactly as before. The trade-off, per the JEP: a 22-bit compressed class pointer caps you at roughly 4 million distinct classes. Blow past that and class loading fails with a good old OutOfMemoryError; there's no automatic fallback, so an app that genuinely needs more has to opt out with -XX:-UseCompactObjectHeaders up front, while the flag still exists (if yours does, we need to talk).

That's the JEPs' abstract percentages made tangible: it's exactly this, multiplied by every object on the heap. Run JOL against that DTO you have a hundred million instances of and see what JDK 27 hands back for free.

JEP 523: Generational... I mean, G1 as Default GC in All Environments

Here's a fun bit of trivia: until now, the JVM would look at your machine and, if it saw a single CPU or less than 1792 MB of memory, quietly hand you the Serial GC instead of G1. Guess what a small Kubernetes pod with a 1-CPU limit and 512 MB of memory looks like to the JVM?! Exactly. A huge chunk of containerized Java out there has been running on the Serial collector without anyone deciding that on purpose.

JDK 27 ends that: G1 is now the default everywhere, regardless of how tiny the environment is. I simulated a sad little pod on my laptop to double-check:

$ java -XX:ActiveProcessorCount=1 -Xmx256m -Xlog:gc --version
[0.005s][info][gc] Using G1

One CPU, 256 MB of heap, and G1 shows up anyway. And voila! The rationale is that G1's throughput improvements in JDK 26 closed the gap with Serial while keeping G1's much better pause behavior. If Serial is genuinely what a workload needs (it does have the smallest footprint), it's still there:

java -XX:+UseSerialGC MyApp

One honest counterweight to the free-money framing: for pods running close to their memory limit, re-baseline the RSS after upgrading. G1 carries native overhead that Serial doesn't (remembered sets, concurrent marking and refinement threads, more GC bookkeeping), and combined with the resizing changes below, the memory profile after the upgrade won't match the old one. For genuinely tiny heaps, -XX:+UseSerialGC remains a perfectly legitimate deliberate choice; the difference is that from now on it IS a choice, not an accident.

A couple of related changes shipped alongside, and these are worth a note in your deployment runbooks:

  • The default MinHeapFreeRatio/MaxHeapFreeRatio moved from 40%/70% to 0%/100%, which effectively disables ratio-driven heap resizing by default. The old defaults could force pointless heap expansion and shrinkage after Full GCs (looking at you, apps that call System.gc() every five minutes); now the ratios stay out of G1's way unless we set them explicitly.
  • -XX:InitiatingHeapOccupancyPercent got renamed to the much friendlier -XX:G1IHOP.

About that rename: nothing breaks on day one, the old flag still works but nags:

$ java -XX:InitiatingHeapOccupancyPercent=45 --version
OpenJDK 64-Bit Server VM warning: Option InitiatingHeapOccupancyPercent was deprecated in version 27.0 and will likely be removed in a future release. Use option G1IHOP instead.

Still, grep those Helm charts anyway; deprecated flags have a habit of becoming removed flags exactly when we least expect it.

JEP 536: JFR In-Process Data Redaction

Long-time readers know I'll take any excuse to talk about continuous profiling, and this JEP removes one of the last serious objections to running JFR in production: recordings capture environment variables, system properties and program arguments, and those tend to contain, well, secrets. Handing a .jfr file to a colleague (or worse, attaching it to a ticket) has always carried a small "did I just leak the database password?" tax.

Starting with JDK 27, JFR redacts sensitive values inside the JVM, before they ever hit disk. Out of the box, any environment variable or system property whose name matches one of a dozen case-insensitive patterns gets its value replaced with [REDACTED]:

*api*key*      *auth*        *client*secret*  *credential*
*jaas*config*  *passphrase*  *passwd*         *password*
*private*key*  *pwd*         *secret*         *token*

Program arguments like --db-password hunter2 get the same treatment. And it's configurable, so we can teach it about our own naming conventions:

# Redact specific keys (replaces the defaults)
java -XX:FlightRecorderOptions:'redact-key=ACCESS_TOKEN;*keyStorePassword' MyApp

# Extend the defaults instead of replacing them (note the +)
java -XX:FlightRecorderOptions:'redact-key=+*confidential*' MyApp

# Live dangerously
java -XX:FlightRecorderOptions:'redact-key=none,redact-argument=none' MyApp

Worth mentioning that the first form (replacing the defaults) prints a [jfr,redact] warning banner at startup to remind us the default filters are gone; that's a nice guardrail, and the warning-free way to replace them is to spell it out with a none; prefix, as in redact-key=none;ACCESS_TOKEN;*keyStorePassword.

I had to see this one with my own eyes, so I started a recording with a few planted env vars (Sleepy.java being a tiny program that sleeps just long enough for JFR to do its thing):

$ KEBBOUR_API_KEY=hunter2 DB_PASSWORD=s3cret HARMLESS_VAR=hello \
  java -XX:StartFlightRecording:filename=demo.jfr Sleepy.java

Then dumped the startup events:

$ jfr print --events jdk.InitialEnvironmentVariable demo.jfr
  key = "HARMLESS_VAR"
  value = "hello"
  key = "DB_PASSWORD"
  value = "[REDACTED]"
  key = "KEBBOUR_API_KEY"
  value = "[REDACTED]"

Kebbour's secrets are safe, and the harmless stuff passes through untouched. Fun detail from the same recording: the *pwd* glob also caught the shell's OLDPWD variable, so my previous working directory is now classified information. Glob-based redaction is nothing if not enthusiastic :)

One scope check before we get carried away: this covers the startup events (env vars, system properties, JVM info, program arguments) and it's explicitly best-effort. It does NOT scrub application events, exception messages, or child-process command lines, so a .jfr is now safer, not sanitized. Still, small feature, huge deal for anyone shipping JFR recordings around. This is the kind of unglamorous engineering I love.

JEP 527: Post-Quantum Hybrid Key Exchange for TLS 1.3

The threat model here has a great name: "harvest now, decrypt later". An adversary records your encrypted traffic today, stores it, and waits for a quantum computer capable of breaking the elliptic-curve key exchange. Your data doesn't need to be secret in 2040 for that to be uncomfortable.

JDK 27's answer is hybrid key exchange for TLS 1.3: classic ECDHE combined with ML-KEM (the NIST-standardized post-quantum KEM that landed in the JDK a couple of releases ago). Both key exchanges run, both secrets get mixed in, and breaking the connection requires breaking both. If the quantum machines never materialize, we've lost a couple of kilobytes per handshake (the ML-KEM public key alone is 1184 bytes and the ciphertext another 1088, so this is not free, just cheap). If they do, we were covered years in advance. Worth mentioning that this JEP is about the key exchange only: certificate signatures stay classical for now, which is the right priority (it's the recorded traffic that needs protecting today), with post-quantum signatures as the next frontier.

The best part: there is literally nothing to do. Every TLS 1.3 handshake going through javax.net.ssl now prefers the hybrid group (X25519MLKEM768) by default: when the peer negotiates it, the key exchange is hybrid, and when it doesn't (TLS 1.2, a server that doesn't support it yet, or an app overriding the named groups), everything carries on classically like before. No flags, no code, no config. The JDK quietly does the right thing, again. Full transparency: this is the one JEP I couldn't meaningfully demo from my laptop, since it takes a server that speaks the hybrid groups on the other end. But that's precisely the point of the feature: by the time your endpoints support it, you'll already be covered without touching a single line.

The previews: still in the oven

Everything below needs the magic flag. On the EA build:

java --enable-preview Main.java

Now, about that plot twist from the intro. This blog's tagline is "Failure sucks but instructs", and I sat down fully expecting an evening of cryptic compiler errors, because that's how preview features and I usually get along. Instead, almost everything ran on the FIRST try. On an early-access build! My single compile error of the evening came from the structured concurrency example, and (plot twist inside the plot twist) the error turned out to be the feature; more on that below. A seventh preview and a third preview behaving like near-finished products is honestly the strongest endorsement I can give: these APIs have been cooking long enough. (Yes, I'm slightly disappointed. I had a whole failures section planned.)

Which brings us to...

JEP 531: Lazy Constants (third preview)

The idea: a constant that's computed at most once, on first use, with the JVM allowed to trust and constant-fold it afterwards. Think static final initialization semantics without paying for it at class-load time:

class OrderService {
    private static final LazyConstant<Validator> VALIDATOR =
        LazyConstant.of(OrderService::createValidator);

    void process(Order order) {
        VALIDATOR.get().validate(order); // computed once, on the first call
    }
}

Note that the static final root is not just style: the at-most-once lazy initialization is always guaranteed, but the constant-folding magic only kicks in when the JVM can reach the LazyConstant through a chain of fields it trusts, with static final being the canonical one.

To see the laziness with my own eyes, I wrote the smallest possible proof as a compact source file:

void main() {
    LazyConstant<String> greeting = LazyConstant.of(() -> {
        IO.println("(computing greeting now!)");
        return "salam";
    });
    IO.println("constant created, nothing computed yet");
    IO.println(greeting.get());
    IO.println(greeting.get());
}
constant created, nothing computed yet
(computing greeting now!)
salam
salam

A couple of interesting things here:

  • The supplier only runs on the first get(); the second one returns the memoized value without printing anything. Computed exactly once, as advertised.
  • LazyConstant needed NO import at all. That surprised me until I checked why: it lives directly in java.lang, like String and friends, so no import is needed anywhere, compact source file or not. Fancy neighborhood for a preview API!

There are collection flavors too: List.ofLazy(), Map.ofLazy(), and new in this round, Set.ofLazy(). The other changes in preview number three are removals: isInitialized() and orElse() are gone. The API is getting smaller and more opinionated with each round, and honestly that's the preview process working as designed.

JEP 532: Primitive Types in Patterns, instanceof, and switch (fifth preview)

Unchanged this round, which usually signals it's close to final. Primitives become first-class citizens in pattern matching:

String grade = switch (score) {
    case int s when s >= 90 -> "excellent";
    case int s when s >= 75 -> "good";
    default -> "fail";
};

The subtle (and clever) part is the exact-fit semantics: 100 instanceof byte is true because 100 fits losslessly in a byte, while 0.1 matches double but NOT float, because converting it to float loses precision. instanceof becomes a safe "does this value fit?" test instead of the cast-and-pray we all grew up with. I dropped both into a compact source file (void main() and IO.println, still feels like cheating) and launched it single-file style:

$ java --enable-preview PrimitivePatterns.java
87 => good
100 fits in a byte?  true
0.1 matches float?   false
0.1 matches double?  true

JEP 533: Structured Concurrency (SEVENTH preview)

Seventh. Preview ?! I can hear the jokes already, but hold on, there's a legitimate story here: the API has been through genuine redesigns, not rubber-stamped re-runs, and the maintainers clearly refuse to freeze something we'll all be stuck with for twenty years. The core shape has settled and it's lovely:

Response handle() throws InterruptedException, ExecutionException {
    try (var scope = StructuredTaskScope.open()) {
        var user  = scope.fork(() -> userService.find(id));
        var order = scope.fork(() -> orderService.find(id));
        scope.join();
        return new Response(user.get(), order.get());
    }
}

Fork subtasks, join, and the try-with-resources guarantees nothing outlives the scope: if one subtask fails, the siblings get cancelled. Concurrency with the readability of sequential code. I ran a toy version of the code above (a fake user service returning kebbour, a fake order service returning 42) and got exactly what I should:

user=kebbour order=42

Full disclosure: this is where my one compile error of the evening happened. My first version declared only InterruptedException, and javac promptly complained about an unreported ExecutionException. Which is delightful, because that IS the headline round-7 change: the custom unchecked FailedException is gone, and join() now throws the good old checked ExecutionException. I got corrected by the very feature I was writing about. Failure sucks but instructs, indeed. What changed in round seven:

  • FailedException is gone; failures now surface as the checked ExecutionException (as my compiler helpfully demonstrated above).
  • Joiner grew a third type parameter for exception types: Joiner<T, R, R_X>.
  • Timeouts are now configured with Configuration.withTimeout(Duration), and the awaitAll() joiner was removed.
  • A new open(cfg -> ...) overload for configuring the scope inline.

JEP 538: PEM Encodings of Cryptographic Objects (third preview)

If you've ever converted a PEM file into a Java PrivateKey, you know the ritual: strip the BEGIN/END lines, Base64-decode, feed a PKCS8EncodedKeySpec into a KeyFactory, and question your career choices somewhere in between. JDK 27 continues previewing the sane alternative:

PrivateKey key = PEMDecoder.of()
        .withDecryption(password)
        .decode(pemString, PrivateKey.class);

String pem = PEMEncoder.of()
        .withEncryption(password)
        .encodeToString(key);

This round is mostly class and method renames; the usage above is stable. Two lines to do what used to be a StackOverflow pilgrimage. Not bad!

JEP 537: Vector API (twelfth incubator)

Twelve incubator rounds. TWELVE! At this point the Vector API is less a feature and more a beloved recurring character. The reason is well known and completely reasonable: the API's final shape depends on Project Valhalla's value classes, and shipping it as final before that would bake in the wrong memory model forever. Word on the street is it might finally graduate to preview in JDK 28, once value classes land.

Meanwhile it works great, unchanged, behind --add-modules jdk.incubator.vector (needed at compile time AND run time; a single source-file-launcher invocation covers both):

import jdk.incubator.vector.*;

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

void scale(float[] a, float[] r, float factor) {
    int i = 0;
    for (; i < SPECIES.loopBound(a.length); i += SPECIES.length()) {
        FloatVector.fromArray(SPECIES, a, i)
                   .mul(factor)
                   .intoArray(r, i);
    }
    for (; i < a.length; i++) r[i] = a[i] * factor; // tail
}

One loop, explicit SIMD, and the JIT maps it to AVX/NEON instructions for the hardware it lands on.

Odds & ends

A few changes without their own JEP that are still worth knowing:

  • The ISO-8601 date-time formatters now accept short zone offsets like +02, not just +02:00. If you've ever fought a parser over exactly this, you know the pain :/
  • JVMCI, the JVM Compiler Interface that let projects like Graal plug in a Java-based JIT, has been removed after more than a decade of experimentation. GraalVM ships its own JVMCI anyway, so for us mortals this is invisible cleanup.
  • -XX:-UseCompressedClassPointers is obsoleted, a direct consequence of compact object headers becoming the norm.

Final Thoughts

My favorite kind of Java release: the headline features require nothing from us. Drop JDK 27 into a containerized fleet and the objects get smaller, the GC default finally makes sense for small pods, the flight recordings stop leaking startup secrets, and TLS 1.3 key exchanges go hybrid post-quantum wherever the other side plays along. All of it by default, all of it invisible. Meanwhile the preview pipeline (lazy constants, primitive patterns, structured concurrency) keeps maturing at its own deliberate pace, and I'd rather wait for a seventh preview than live with a rushed API for two decades.

The EA builds are at jdk.java.net/27, or one sdk install java 27.ea.33-open away if SDKMAN is your thing too; grab one and kick the tires before September 15. If anything here is off, tell me in the comments and I'll fix it. Exciting times!

Resources

  • https://openjdk.org/projects/jdk/27/
  • https://www.happycoders.eu/java/java-27-features/
  • https://adtmag.com/ (JDK 27 rampdown coverage, July 2026)
  • https://jdk.java.net/27/