Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped
Java 27 reached GA on 15 September 2026. Every one of its nine JEPs run on real JDKs, plus the ten JEPs of Java 26 for anyone jumping from the 25 LTS: G1 becoming the default in a one-CPU container (26 vs 27), 8-byte object headers measured with JOL, the post-quantum TLS handshake, JFR redaction, preview APIs that stopped compiling, removed JVM flags, and the Java 26 recap.
You change the base image from amazoncorretto:26 to amazoncorretto:27. The build is green, the container starts, the health check passes, and nothing anywhere tells you that the garbage collector changed, that every object on your heap got smaller, or that your service’s outbound TLS handshake now opens with a message four times the size. Java 27 is a release where most of what matters is silent.
This article goes through the whole release: all nine JEPs, each one run rather than summarised, and then the ten JEPs of Java 26 for everyone who is going from the 25 LTS straight to 27 and never met them. Every number below comes from a real run on real JDK binaries. The code is in asmhatre/javademos, one script per demo, and every transcript quoted here is committed under docs/output/ — the sections link to the exact file, and to the chapter of the repository that goes deeper.
JEP
What changed
What you will notice
Where
523
G1 is the default collector everywhere
Silent. A one-CPU container that ran Serial now runs G1
Versions. Java 27 (Amazon Corretto 27+35, GA 2026-09-15), Java 26 (Corretto 26.0.2.1, GA 2026-03-17) and Java 25 LTS (Temurin 25.0.4.1+1), JOL 0.17, Docker images amazoncorretto:26 and amazoncorretto:27 so that only the JDK changes. Java 27 is not a long-term-support release; the next LTS is Java 29 (September 2027) per Oracle’s Java SE Support Roadmap. jdk.java.net/27 and the JEP pages on openjdk.org returned HTTP 403 when I tried to read them, so the JEP list was cross-checked against four secondary write-ups and then against the binaries themselves: the API of the 25, 26 and 27 JDKs was diffed class by class, which is how two API names in a secondary source were corrected.
Java 27 is not a long-term-support release, and that decides how much of this you should act on
Java ships every six months, and every second year one of those releases is designated long-term support. Long-term means the vendor keeps patching it for years; the releases in between are patched until the next one ships. The last LTS is 25, the next is 29. Everything between is a stepping stone that some teams run in production and many teams skip.
That matters for how to read what follows. If you run 25 and intend to stay until 29, this article is a preview of your next upgrade, and the useful output is the list of things to fix now so that the jump in 2027 is smaller. If you run 26 or 27 in production, you are on a clock: 26’s premier support window ends this month.
The picture is the whole support argument. Two long boxes and three short ones; the short ones are six months each. Later sections mark which changes are permanent parts of the platform (they will still be there in 29) and which are churn inside a release you may never run.
Version lanes 25 to 29 in the repository: chapter 12.
Your container’s garbage collector may have changed without a flag
A garbage collector is the part of the JVM that finds objects your program has stopped using and gives their memory back. HotSpot ships several, and each one is a trade between three costs: how long it pauses your program, how much total CPU it spends, and how much memory it wants. If you do not choose one, the JVM chooses for you. That choice is called ergonomics.
For years the rule was: on a “server-class” machine, pick G1; on anything smaller, pick Serial. Server-class meant at least two CPUs and about 2 GB of memory (the threshold in HotSpot’s source is 1792 MB; I read that, I did not re-derive it). A container started with --cpus=1 fell below the line and got Serial: one thread, stop-the-world, almost no bookkeeping, no background threads. JEP 523 deletes the line.
The diagram is the change in one glance, and the next experiment is how I checked it rather than trusting it. GcReport.java asks the JVM which collector it picked and, more usefully, why: HotSpot records the origin of every flag value, and ERGONOMIC means “the JVM decided”, as opposed to DEFAULT (built in) or a command-line origin (a person typed it).
HotSpotDiagnosticMXBean hs = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
for (String flag : new String[] {"UseG1GC", "UseSerialGC", "UseParallelGC"}) {
var opt = hs.getVMOption(flag);
System.out.printf("%-17s = %-5s (origin %s)%n", flag, opt.getValue(), opt.getOrigin());
}
Run in two containers with identical limits (--cpus=1 --memory=2g) that differ only in the JDK (transcript 01):
Same machine shape, different collector. The full matrix is three container sizes (01, 02): with two CPUs and 2 GB both releases pick G1; with two CPUs and only 1 GB, 26 picks Serial and 27 picks G1. So both halves of the old rule are gone, the CPU count and the memory limit. Naming a collector yourself still wins, and its origin then reads VM_CREATION (transcript 03).
What the switch costs a small container
G1 does more work than Serial, some of it on background threads, and on one CPU those threads compete with your application for the only core. Workload.java allocates 50,000 short-lived objects per batch, keeps about one per hundred, and reports wall time, collection count, the slowest single batch and peak resident memory. One CPU, 2 GB, first run of each configuration (transcript 04, three runs each, indicative and not a benchmark):
=== amazoncorretto:26 default run 1
wall time = 5980 ms
gc count / time = 1275 / 1831 ms
slowest batch = 115 ms
peak RSS = 557 MB
=== amazoncorretto:27 default run 1
wall time = 8196 ms
gc count / time = 140 / 1955 ms
slowest batch = 39 ms
peak RSS = 547 MB
=== amazoncorretto:27 -XX:+UseSerialGC run 1
wall time = 5306 ms
gc count / time = 1264 / 1669 ms
slowest batch = 125 ms
peak RSS = 557 MB
Read the shape rather than the digits. On one CPU, G1 ran about a third longer to do the same work than Serial did (8.2 to 8.4 seconds against 6.0 to 6.4 across three runs), and it did it in 140 collections instead of 1,275. The payoff is the slowest batch: 35 to 41 milliseconds against 115 to 142. G1 traded throughput for shorter worst-case stalls, which is exactly what it is designed to do, and on one core it pays for the trade with the same core your request threads want.
A short-lived process shows the other cost. Running only 400 batches (transcript 05), 26 finishes in about 0.53 seconds having touched about 102 MB; 27 with G1 takes about a second and touches about 492 MB, because G1 happily grows the heap toward the 512 MB default maximum instead of collecting early. That is the number that will surprise a memory-limited pod.
The fingerprint of this change. Same image, same limits, same code, and after the upgrade the container’s resident memory is several times higher and its throughput is lower, while nothing in the logs changed. Check -XX:+PrintFlagsFinal | grep -E 'Use(G1|Serial)GC' before you suspect your own code. Two suspects I tested and cleared or confirmed (transcript 06): 27 also changed the heap free ratios G1 uses (MinHeapFreeRatio 40 to 0, MaxHeapFreeRatio 70 to 100), but restoring the old values changed nothing measurable; capping the heap with -Xmx256m cut peak memory from about 492 MB to about 294 MB at the same speed.
What to do about it depends on which side of the trade you sit. If your service is throughput-bound on one core, pin the old behaviour with -XX:+UseSerialGC; the opt-out works exactly as before. If you are happy with G1, set a heap ceiling (-Xmx or -XX:MaxRAMPercentage) so that “a quarter of the container” is a decision you made rather than a default you inherited.
The old and new rule, with every container size tested: chapter 2.
Also in this release: -XX:InitiatingHeapOccupancyPercent is deprecated in favour of -XX:G1IHOP (see the small breakages).
Every object got lighter: the header shrank from 12 bytes to 8
Every object on the Java heap starts with a header, bookkeeping the JVM needs to manage it. It holds a mark word (lock state, identity hash code, the object’s age for the collector) and a pointer to the object’s class. On JDK 26 with default settings that is 8 bytes 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. JEP 534 makes that the default in 27.
Because the JVM aligns every object to 8 bytes, saving 4 bytes does not always save 4 bytes. Whether an object shrinks depends on where its fields fall relative to those 8-byte boundaries, and the easiest way to see it is one object drawn out.
The numbers in the picture are the argument: 12 + 4 + 4 = 20, which the JVM pads to 24; 8 + 4 + 4 = 16, which needs no padding. The claim is checked with JOL, the Java Object Layout tool, run by object-headers.sh against HeaderDemo.java on each JDK. Here is the real layout of the class, first on 26 and then on 27 (transcript 10, transcript 11):
/** Two ints. The smallest object that is not empty. */
static class Point {
int x;
int y;
}
System.out.println(ClassLayout.parseClass(Point.class).toPrintable());
=== Point { int x; int y; }
HeaderDemo$Point object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 int Point.x N/A
16 4 int Point.y N/A
20 4 (object alignment gap)
Instance size: 24 bytes
=== Point { int x; int y; }
HeaderDemo$Point object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 int Point.x N/A
12 4 int Point.y N/A
Instance size: 16 bytes
Across the six things I measured (summary, transcript 15), the pattern is consistent: Object goes from 16 bytes to 8, Point from 24 to 16, a boxed Long from 24 to 16, an int[3] from 32 to 24, while a boxed Integer stays at 16 and the four-field Order class stays at 32, because padding was already absorbing the difference. What it adds up to for a million objects:
1,000,000 boxed Long values in an ArrayList : 28,861,992 bytes
1,000,000 boxed Long values in an ArrayList : 20,861,984 bytes
1,000,000 boxed Integer values in an ArrayList : 20,861,992 bytes
1,000,000 boxed Integer values in an ArrayList : 20,861,984 bytes
That is a million Longs in a list going from about 28.9 MB to about 20.9 MB, and a million Integers not moving at all. Your heap will not shrink by “four bytes times the number of objects”. It shrinks by the number of objects that were sitting just past an alignment boundary, which is a property of your data, not of the JVM.
Two things that bite. First, JOL 0.17 cannot inspect a record: it fails with can’t get field offset on a record class (transcript 13), and the workaround -Djol.magicFieldOffset=true prints a layout but shows the header as N/A (transcript 14). Second, anything that hard-codes 12 bytes — an off-heap size estimator, a capacity-planning constant, a test that asserts Instance size — is now wrong by default. -XX:-UseCompactObjectHeaders restores the old layout exactly (transcript 12), and -XX:+UseCompressedClassPointers is no longer an option at all: the JVM prints Ignoring option UseCompressedClassPointers; support was removed in 27.0.
All the layouts, the opt-out run and the JOL record failure: chapter 3.
The first TLS packet your JVM sends is now four times bigger
When a TLS client and server meet, they must agree on a secret key without ever sending it. That agreement is called key exchange, and today it is done with elliptic-curve mathematics (the x25519 curve, in practice). A sufficiently large quantum computer could break that mathematics, and an attacker can record encrypted traffic today and decrypt it years later, a threat usually called harvest now, decrypt later.
JEP 527 defends against that by default. The JDK 27 TLS 1.3 client now offers X25519MLKEM768 first: an ordinary x25519 exchange and an ML-KEM-768 exchange (a post-quantum scheme) run together, with the two results combined, so an attacker has to break both. If one of them turns out to be weak, the other still protects the session.
I started a loopback TLS server on one JDK and connected from a client on the other, for all four pairings, with handshake debugging on (tls-pq.sh, TlsPeer.java, transcript 30). The two that matter:
Two 27 peers negotiate the hybrid group. A 27 client talking to a 26 server offers it, the older server does not know it, and the two quietly settle on plain x25519. Nothing fails and nothing warns. That is the design, and it is also why you will not notice unless you look.
The cost is in the first packet
The hybrid key share alone is over a kilobyte (an ML-KEM-768 public key is 1,184 bytes), and it travels in the very first message the client sends. ClientHelloSize.java captures that record (transcript 31):
=== JDK 26
java.version = 26.0.2.1
record type = 0x16 (0x16 = handshake)
ClientHello record = 417 bytes (+5 byte record header)
fits one TCP segment = yes (1460-byte MSS on a 1500-byte MTU)
=== JDK 27
java.version = 27
record type = 0x16 (0x16 = handshake)
ClientHello record = 1573 bytes (+5 byte record header)
fits one TCP segment = NO (1460-byte MSS on a 1500-byte MTU)
=== JDK 27 with -Djdk.tls.namedGroups=x25519,secp256r1 (post-quantum group removed)
java.version = 27
record type = 0x16 (0x16 = handshake)
ClientHello record = 408 bytes (+5 byte record header)
fits one TCP segment = yes (1460-byte MSS on a 1500-byte MTU)
What I did not observe, and the switch you would reach for. A 1,573-byte ClientHello no longer fits in one TCP segment on a normal 1500-byte network, and it is a known failure pattern that some middleboxes mishandle a first message that spans segments. On the loopback interface used here there are no middleboxes, so I could not reproduce a failure and I am not claiming one. If you meet a handshake that stalls only from 27 clients, the opt-out is one system property, -Djdk.tls.namedGroups=x25519,secp256r1, which brings the client back to 408 bytes and plain x25519 (transcript 32). Test through your real network path before you roll 27 out to the edge.
The four-way matrix, the byte accounting and the opt-out: chapter 4.
The JEP text, for the exact groups and the fallback rules: JEP 527.
The preview features moved again, and code written for 26 will not compile
A preview feature is a language or library feature that is finished enough to ship and not finished enough to promise. You have to opt in with --enable-preview both when you compile and when you run, and the class files you produce are tied to the exact JDK release that compiled them. The point of the mechanism is that the feature can change between releases if the feedback says it should.
Three of the four features that were previewing in 26 changed their API in 27, and the fourth is a language feature whose rules tightened. That is the mechanism working as intended, and it is also why a preview API does not belong in a library you publish. The rest of this section is one demo per feature, each in three parts: what the 27 code looks like, what it prints, and what the 26 version of the same code says when the 27 compiler reads it.
Feature
In 27
What broke coming from 26
Structured concurrency (JEP 533)
seventh preview
StructuredTaskScope and Joiner gained a third type parameter; FailedException and TimeoutException are gone
Lazy constants (JEP 531)
third preview
orElse and isInitialized removed; Set.ofLazy added
Primitive types in patterns (JEP 532)
fifth preview
no source break in my 26 code; the dominance rules apply to the new labels
PEM encodings (JEP 538)
third preview
DEREncodable renamed BinaryEncodable; withFactory gone; PEM.content() now returns byte[]
Structured concurrency: the exception you throw is now a type parameter
Structured concurrency treats a group of concurrent subtasks as one unit of work: you fork them inside a scope, wait for all of them with join(), and if you leave the scope every subtask is finished or cancelled. Nothing outlives the block that started it. What 27 adds is a third type parameter that names the exception join() throws, so a scope can throw your exception type. This is StructuredDemo.java, and the concept in full is in Structured Concurrency in Java (which targets the 25/26 API; the shape below replaces what it shows for 27).
/** New in 27: hand the joiner a function and join() throws YOUR type. */
static void yourOwnExceptionType() throws InterruptedException {
System.out.println("--- allSuccessfulOrThrow(Function): join() throws the exception type you choose");
try (var scope = StructuredTaskScope.open(Joiner.<String, OrderFailed>allSuccessfulOrThrow(OrderFailed::new))) {
scope.fork(StructuredDemo::fetchPrice);
scope.fork(StructuredDemo::failingCall);
scope.join(); // declared: throws OrderFailed, InterruptedException
Check.that(false, "unreachable");
} catch (OrderFailed e) {
System.out.println("caught " + e.getClass().getSimpleName() + ": " + e.getMessage());
Check.that(e.getCause() instanceof IllegalStateException, "the mapped exception wraps the original failure");
}
}
--- allSuccessfulOrThrow(): results as a List, failures as ExecutionException
results = [price=42, stock=7]
CHECK ok : join() returns the subtask results in fork order
caught java.util.concurrent.ExecutionException with cause java.lang.IllegalStateException: inventory service is down
CHECK ok : a failing subtask surfaces as ExecutionException(cause)
--- allSuccessfulOrThrow(Function): join() throws the exception type you choose
caught OrderFailed: order failed: inventory service is down
CHECK ok : the mapped exception wraps the original failure
--- withTimeout(...): what happens when the scope runs out of time
caught java.util.concurrent.ExecutionException
cause: java.util.concurrent.StructuredTaskScope$CancelledByTimeoutException
CHECK ok : a timeout is reported as CancelledByTimeoutException
The last three lines are the timeout case: a scope that runs out of time surfaces as an ExecutionException whose cause is the new CancelledByTimeoutException. Each CHECK ok line is an assertion in the demo, so the run (transcript 21) goes red if a claim stops being true. Now the 26 version of the same idea (StructuredScope26.java), read by the 27 compiler:
$ javac --enable-preview --release 27 StructuredScope26.java (JDK 27)
StructuredScope26.java:12: error: wrong number of type arguments; required 3
try (StructuredTaskScope<String, List<String>> scope =
^
StructuredScope26.java:17: error: cannot find symbol
} catch (StructuredTaskScope.FailedException e) {
^
symbol: class FailedException
location: interface StructuredTaskScope
StructuredScope26.java:19: error: cannot find symbol
} catch (StructuredTaskScope.TimeoutException e) {
^
symbol: class TimeoutException
location: interface StructuredTaskScope
3 errors
exit=1
Three errors, all of them the same change seen from three places (transcript). Migrating is mechanical: add the third type argument, and replace the two removed exception classes with ExecutionException and CancelledByTimeoutException.
Lazy constants: compute it once, on first use, and let the JVM treat it as final
A lazy constant holds a value that is computed the first time somebody asks for it, exactly once even if many threads ask at the same instant, and after that the JVM is allowed to treat it like a final field. It replaces the holder-class idiom and the double-checked-locking field that every Java developer has written at least once. LazyDemo.java:
static final LazyConstant<Settings> SETTINGS = LazyConstant.of(LazyDemo::loadSettings);
List<String> squares = List.ofLazy(5, i -> {
listComputations.incrementAndGet();
return "square(" + i + ")=" + (i * i);
});
--- a lazy constant computes once, on first use
toString before first get(): jdk.internal.lang.LazyConstantImpl@...[computing function=LazyDemo$$Lambda/0x...@...]
(loading settings on main)
toString after first get(): jdk.internal.lang.LazyConstantImpl@...[Settings[region=ap-south-1, poolSize=16]]
CHECK ok : get() returns the same instance every time
CHECK ok : the supplier ran exactly once
--- and once even when 64 threads race for it
CHECK ok : 64 racing threads triggered exactly one initialisation
--- List.ofLazy: each element is computed on first access
computed so far: 0
squares.get(3) = square(3)=9
squares.get(3) = square(3)=9 (second read)
CHECK ok : only element 3 was ever computed
The CHECK lines are the point: the supplier ran once even though 64 virtual threads raced for it, and List.ofLazy computed only element 3 after get(3) (transcript 22). What 27 changed: orElse(...) and isInitialized() no longer exist, and Set.ofLazy(Set, Predicate) joined List.ofLazy and Map.ofLazy. The 26 code that used the removed methods (Lazy26.java) says:
$ javac --enable-preview --release 27 Lazy26.java (JDK 27)
Lazy26.java:12: error: cannot find symbol
if (!GREETING.isInitialized()) {
^
symbol: method isInitialized()
location: variable GREETING of type LazyConstant<String>
Lazy26.java:13: error: cannot find symbol
System.out.println("not yet: " + GREETING.orElse("<unset>"));
^
symbol: method orElse(String)
location: variable GREETING of type LazyConstant<String>
2 errors
exit=1
Primitive types in patterns: instanceof becomes an exactness test
Until now a pattern could name only a reference type. With this preview it can name a primitive type, and the test is can this value be converted to that type without losing information? It is not a cast. PrimitivePatterns.java:
int small = 100, big = 300;
boolean a = small instanceof byte;
boolean b = big instanceof byte;
static String describe(Object o) {
return switch (o) {
case int i when i > 1000 -> "a large int " + i;
case int i -> "an int " + i;
case long l -> "a long " + l;
case double d -> "a double " + d;
case String s -> "a String of length " + s.length();
default -> "something else: " + o;
};
}
--- instanceof <primitive>: an exact-conversion test, not a cast
100 instanceof byte = true
300 instanceof byte = false (a byte holds -128..127)
CHECK ok : 100 converts to byte exactly, 300 does not
--- switch on an Object: an Integer matches 'case int'
describe(Integer 7) = an int 7
describe(Integer 5000) = a large int 5000
describe(Long 7) = a long 7
describe(Double 2.5) = a double 2.5
describe(String hello) = a String of length 5
describe(Character x) = something else: x
CHECK ok : an Integer 5000 matches case int with a guard
100 instanceof byte is true and 300 instanceof byte is false because a byte holds −128 to 127; an Integer in an Object switch matches case int, guards and all (transcript 23). The compile error you will meet is a dominance error: put case Integer i first and the later case int can never match (real message).
PEM: one call to read or write the text format every key file uses
PEM is the -----BEGIN ...----- text format on every certificate and key file on a Linux box. Before this preview, reading or writing one from Java meant hand-rolled Base64 and header strings. PemDemo.java round-trips public, private and password-protected keys and asserts each; here is the encrypted case and the trap I fell into while writing it.
--- encrypted private key
-----BEGIN ENCRYPTED PRIVATE KEY-----
CHECK ok : withEncryption produces ENCRYPTED PRIVATE KEY
decoded with no password: javax.crypto.EncryptedPrivateKeyInfo
CHECK ok : without a password you get the still-encrypted structure back
CHECK ok : with the password you get the original private key
--- PEM(type, byte[]) does NOT Base64-encode: the bytes go in as they are
-----BEGIN ANKURM DEMO-----
hello, pem
-----END ANKURM DEMO-----
CHECK ok : raw bytes appear verbatim between the header and footer
The trap.new PEM(type, byte[]) does not Base64-encode. The bytes you pass are taken to be the Base64 text already and are written between the header and footer as they are; the transcript (transcript 24) shows hello, pem in clear inside a PEM block. Base64-encode first, pass that, and content() returns the Base64 text while decode() returns your payload. This cost a failed assertion while writing the companion repository. The other 27 changes for PEM: DEREncodable is now BinaryEncodable, PEMDecoder.withFactory(Provider) is gone, and PEM.content() returns byte[] rather than String (three compile errors).
Do not put a preview API in a library. Three of the four features above changed their API between two consecutive releases, and a class file compiled with --enable-preview will not load on a different JDK at all. An application you build and run on one JDK is fine. A library that exposes or uses a preview API is a compatibility bug you are shipping to everyone downstream. Chapters 5 to 8 of the repository have the complete demos.
The 26 to 27 public-API diff that found every rename: transcript 51.
JFR stops writing your passwords into recordings
Java Flight Recorder is the JVM’s built-in black box: it records what the process was doing so you can analyse it later. To make that analysis possible it also records the process’s launch arguments, system properties and environment variables, and those routinely contain passwords and API tokens. Recordings get attached to support tickets. JEP 536 makes the recorder redact values that look like secrets, by default.
jfr-redaction.sh starts a process with -Dapi.token=abc123, --password=hunter2 and DB_PASSWORD=hunter2 in its environment, and prints what reaches the file (transcript 40). JDK 26, then JDK 27 with no options:
=== jdk26-default
jvmArguments = "-Dapi.token=abc123 -Dregion=ap-south-1 -XX:StartFlightRecording:filename=<file>"
javaArguments = "Idle --password=hunter2 --user=ankur"
key = "region" value = "ap-south-1"
key = "api.token" value = "abc123"
key = "DB_PASSWORD" value = "hunter2"
=== jdk27-default
jvmArguments = "[REDACTED] -Dregion=ap-south-1 -XX:StartFlightRecording:filename=<file>"
javaArguments = "Idle [REDACTED] --user=ankur"
key = "region" value = "ap-south-1"
key = "api.token" value = "[REDACTED]"
key = "DB_PASSWORD" value = "[REDACTED]"
Arguments and property values that match the built-in patterns are replaced with [REDACTED]; ordinary settings such as region are left alone. You can change the patterns with -XX:FlightRecorderOptions:redact-argument=...,redact-key=..., and here is where I found a trap.
A list you supply replaces the defaults. With redact-argument=--user*,redact-key=region the transcript shows -Dapi.token=abc123, --password=hunter2 and DB_PASSWORD=hunter2 back in the clear: naming your own patterns switched the built-in ones off for everything you did not list. Prefix your list with + (redact-argument=+--user*,redact-key=+region) to extend the defaults instead of replacing them. none disables redaction entirely.
The Vector API is still incubating, and the API did not move
The Vector API lets you write explicit SIMD code (one instruction on many numbers at once) in Java. In 27 it is in its twelfth incubation, JEP 537. The interesting fact for an upgrade is negative: the same class file, compiled once, runs on 26 and 27 unchanged, because between them the only public change is that VectorOperators went from abstract to final (transcript 50). The demo (VectorDemo.java, transcript 25) computes a dot product with and without vectors and asserts the answers agree:
java.version = 27
preferred species = Species[float, 16, S_512_BIT] (16 float lanes, machine dependent)
scalar dot = 749999.6
vector dot = 749999.6
CHECK ok : vector and scalar dot products agree to within 0.1%
The preferred vector width is machine dependent (512 bits here), so treat that line as an example of the format. For how the API works, when it beats the compiler’s own auto-vectorisation, and real benchmarks, read Java Vector API (JEP 537); nothing in it needs revisiting for 27.
The small breakages: flags that stop the JVM, and switches that stopped working
These are not JEPs, so they are easy to miss in a release summary, and they are the ones that cause a 2 a.m. page. other-changes.sh runs java <option> -version on 26 and on 27 for each option that changed (transcript 60). The most important one:
=== -noverify
JDK 26:
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.
OpenJDK 64-Bit Server VM Corretto-26.0.2.11.1 (build 26.0.2.1+11-FR, mixed mode, sharing)
JDK 27:
Unrecognized option: -noverify
Error: Could not create the Java Virtual Machine.
On 26 those flags produce a deprecation warning and the JVM starts. On 27 the JVM will not start at all. Old flags like these live in Dockerfiles, systemd units and JAVA_TOOL_OPTIONS long after anyone remembers why they were added, so search for them before you upgrade, not after. The complete table:
Option or property
JDK 26
JDK 27
-noverify, -Xverify:none
deprecation warning
JVM refuses to start
-noclassgc
deprecation warning
JVM refuses to start
-XX:+UseCompressedClassPointers
deprecated in 25
ignored: “support was removed in 27.0”
-XX:InitiatingHeapOccupancyPercent
accepted
deprecated, use -XX:G1IHOP
-XX:+UseGraalJIT (experimental)
JVMCI error
Unrecognized VM option
-Djdk.lang.Process.launchMechanism=VFORK
deprecation banner
removed, silently switches to FORK
-Djava.locale.useOldISOCodes=true
works, deprecated
ignored: Locale("iw").language is he
The last two rows are behaviour changes rather than startup failures (transcript 61). A little new API also arrived in 27 — Math.acosh, asinh and atanh, String.encodedLength(Charset) to get a string’s encoded size without allocating the byte array, BigDecimal.rootn, KeyStore.getCreationInstant — each exercised once in Api27.java (transcript 62).
Every option, both JDKs, and the diff that found the new API: chapter 10.
The Java 26 changes you skipped
Java 26 was released on 17 March 2026 and is, like 27, not a long-term-support release. If you run the 25 LTS you go from 25 to 27 in one step and meet 26’s changes and 27’s together. There were ten JEPs. Four of them are previews or incubations that reappear in 27 (structured concurrency, lazy constants, PEM, primitive patterns) and one is the Vector API, all covered above. The other five change behaviour and each was run here, one at a time.
Everything in this section is produced by recap26.sh and lands in transcripts 70 to 75.
JEP 500: final means final
Java has always let reflection overwrite a final field: call setAccessible(true), then setInt. Serialisation libraries, mocking frameworks and dependency-injection containers rely on it. Java 26 starts to close the door. FinalFieldMutation.java does exactly the reflective write in the diagram below.
The diagram shows why the platform wants this to stop even when the write “works”: final int port = 8080 is a constant variable, so javac copied the literal 8080 into port(), and nothing you write to the field afterwards can reach that copy. The runs (transcript 70):
=== JDK 25 (class compiled --release 25)
field reads : 9090
port() returns : 8080
=== JDK 26, defaults
WARNING: Final field port in class FinalFieldMutation$Config has been mutated reflectively by class FinalFieldMutation in unnamed module @<hash> (file:<repo>/build/recap26/26/)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
field reads : 9090
port() returns : 8080
=== JDK 26, --illegal-final-field-mutation=deny (what a future default will look like)
setInt refused : class FinalFieldMutation (in unnamed module @<hash>) cannot set final field FinalFieldMutation$Config.port (in unnamed module @<hash>), unnamed module @<hash> is not allowed to mutate final fields
On 25 it is silent. On 26 it warns once, names the class that did it, and tells you the opt-in: --enable-final-field-mutation=ALL-UNNAMED. --illegal-final-field-mutation takes allow, warn, debug (the warning plus a stack trace) or deny, and under deny the write throws IllegalAccessException. The JVM also records a jdk.FinalFieldMutation JFR event whenever a mutation is permitted, with the declaring class and the field name, so you can find the offenders in a running system without changing a flag. Run your test suite once with deny on 26 or later; that is where the surprises live.
JEP 504: the Applet API is gone
java.applet.*, javax.swing.JApplet and one java.beans overload were removed. Almost nobody writes an applet, but plenty of old code still imports Applet from a utility class. AppletGone.java compiles on 25 with a warning and fails on 26; the same transcript (transcript 73) shows a second removal that is not part of that JEP, Thread.stop(), which has thrown UnsupportedOperationException since Java 20 and is now simply not there:
=== recap26/src/broken/AppletGone.java
javac (JDK 25, --release 25): exit 0
<repo>/recap26/src/broken/AppletGone.java:5: warning: [removal] Applet in java.applet has been deprecated and marked for removal
public class AppletGone extends Applet {
^
1 warning
javac (JDK 26, --release 26): exit 1
<repo>/recap26/src/broken/AppletGone.java:3: error: package java.applet does not exist
import java.applet.Applet;
^
<repo>/recap26/src/broken/AppletGone.java:5: error: cannot find symbol
public class AppletGone extends Applet {
^
symbol: class Applet
<repo>/recap26/src/broken/AppletGone.java:6: error: init() in AppletGone does not override or implement a method from a supertype
@Override public void init() { System.out.println("applet init"); }
^
3 errors
JEP 516: the ahead-of-time cache learns to work with every collector
Java 24 and 25 added an ahead-of-time cache: you run your application once to record what it loads, build a cache file, and later start from it, skipping class parsing and linking and, in some configurations, starting with pre-built heap objects such as the module graph. Until 26 the heap-object part needed G1. The workflow is three commands (-XX:AOTMode=record, -XX:AOTMode=create, then -XX:AOTCache=...), scripted in recap26.sh. I built a cache under G1 and under ZGC on 25, 26 and 27, and ran it under Serial, G1 and ZGC, reading the JVM’s own log to see whether it accepted the cache and whether it used the heap objects (transcript 71).
The diagram is the finding, and it is narrower than the headline “any GC”. The cache records whether compressed object pointers were on, and ZGC runs without them, so a cache built in one group cannot be loaded in the other. What 26 fixes is that a ZGC cache now includes the heap objects. The rows that show it:
JDK 25 cache built under Z run under Z -> cache used, heap objects NOT used (full module graph: disabled)
JDK 26 cache built under Z run under Z -> heap objects used (full module graph: enabled)
JDK 26 cache built under G1 run under Z -> unusable: saved state of UseCompressedOops and UseCompressedClassPointers is different from runtime
The startup effect is measurable and, on this shared two-CPU machine, indicative only (transcript 72): with ZGC, JDK 25 barely moves (about 83 to 81 ms) because it cannot use the heap objects, while 26 and 27 go from about 62 to about 40 ms; with G1 all three go from about 60 to about 35 ms. In practice: build the cache on the JDK and under the collector you deploy with, and rebuild it whenever either changes.
JEP 517: HTTP/3 in the JDK HTTP client
java.net.http gains HttpClient.Version.HTTP_3 and a discovery option with three modes: ANY, ALT_SVC and HTTP_3_URI_ONLY. I cannot show you an HTTP/3 exchange: the machine that produced these transcripts has no outbound UDP, and HTTP/3 runs over QUIC, which is UDP. What can be checked anywhere is what happens when the server does not speak it (Http3Fallback.java, transcript 75):
client asked for : HTTP_3
server answered : HTTP_1_1 200 hello
HTTP_3_URI_ONLY : java.net.http.HttpConnectTimeoutException: quic handshake timeout
A client that prefers HTTP/3 talking to a server that only speaks HTTP/1.1 falls back and says so in response.version(). A request pinned to HTTP_3_URI_ONLY does not fall back, and here it times out in the QUIC handshake, which is what you would see against any server that has no HTTP/3 listener.
JEP 522, and the API that arrived without a JEP
JEP 522 reduces synchronisation between application threads and G1’s threads to improve throughput. I did not benchmark it, so treat it as a claim from the JEP, and note that it makes 26 a fairer G1 baseline than 25 when you compare. Some genuinely useful API also arrived in 26 with no JEP, found by diffing the two JDKs (transcript 52) and asserted in Api26.java (transcript 74):
CHECK ok : "STRASSE".equalsFoldCase("stra" + sharp s + "e") is true (full case folding)
CHECK ok : equalsIgnoreCase says false for the same pair (simple folding only)
CHECK ok : "apple".compareToFoldCase("BANANA") < 0
CHECK ok : sorted with String.UNICODE_CASEFOLD_ORDER: [A, b, B, c]
CHECK ok : UUID.ofEpochMillis(..).version() == 7
CHECK ok : the first 48 bits are the timestamp: 018bcfe5-6800-...
CHECK ok : try-with-resources on Process (implements Closeable in 26)
String.equalsFoldCase and compareToFoldCase do full Unicode case folding without allocating a lowercased copy, and the first two lines above are the reason to prefer them: equalsIgnoreCase uses simple folding and says the German pair is different. UUID.ofEpochMillis builds a version-7 (time-ordered) UUID for a given instant, and Process is now Closeable. Also new: MemoryMXBean.getTotalGcCpuTime(), ByteOrder as an enum, HPKEParameterSpec for hybrid public-key encryption, and Unicode 17 blocks in Character.UnicodeBlock.
The recap in full, including every option and both JFR event configurations: chapter 11.
In the order that stops a service from starting first:
Grep for removed flags.-noverify, -Xverify:none and -noclassgc in Dockerfiles, JAVA_TOOL_OPTIONS, systemd units and start scripts.
Choose your collector in small containers. If nobody names one, 27 gives you G1 on one CPU. Decide on purpose and set a heap ceiling.
Run the tests once with --illegal-final-field-mutation=deny on 26 or later.
Compile on 26 and 27. Applets and Thread.stop() are gone; preview code has changed shape.
Test TLS through your real network. Keep -Djdk.tls.namedGroups=x25519,secp256r1 in your pocket.
Search for hard-coded object sizes and off-heap estimators that assume a 12-byte header.
Check custom JFR redaction lists for the missing +.
Rebuild any AOT cache per JDK and per collector.
Decide whether to move at all. See the next callout.
The same list with links to the evidence for each item is chapter 13 of the repository.
Should you move to 27 at all? Probably not in production, unless you have a reason. Java 27 is a stepping stone: its premier support ends in March 2027, six months after it shipped, and the next LTS is 29 in September 2027. The honest plan for most teams is to stay on 25, run the checks above against 27 in CI now so that the jump to 29 is small, and treat 27 as a preview of it. Move earlier only if you specifically want something here — the smaller heap from compact headers, redacted JFR files, the post-quantum handshake — and accept that you will be upgrading again in six months. Everything on this page was run on Corretto 26.0.2.1 and 27+35; a different vendor’s build may differ in the details, especially the GC ergonomics and the timing numbers.
No Comments yet!