Java, release by release: 8 to 27.
Twenty releases in twelve years, from lambdas to post-quantum TLS. For each one: what changed, why it mattered, and a program you can run. Written for engineers who know Java and want the whole arc in one sitting.
Timeline
Twelve years in one table.
Since Java 10 a release ships every March and September; an LTS every two years. Each version below links to its own section.
| Version | GA | Support | What it is remembered for |
|---|---|---|---|
| Java 8 | March 2014 | LTS | The language learns functions. |
| Java 9 | September 2017 | 6 months | Modules, jshell and a tidier standard library. |
| Java 10 | March 2018 | 6 months | var, and the six-month cadence begins. |
| Java 11 | September 2018 | LTS | The first LTS of the new era. |
| Java 12 | March 2019 | 6 months | Switch expressions arrive as a preview. |
| Java 13 | September 2019 | 6 months | Text blocks, and yield in switch. |
| Java 14 | March 2020 | 6 months | Switch expressions final, helpful NullPointerExceptions. |
| Java 15 | September 2020 | 6 months | Text blocks final, sealed classes previewed, ZGC production-ready. |
| Java 16 | March 2021 | 6 months | Records and instanceof patterns are final. |
| Java 17 | September 2021 | LTS | Sealed classes final: the LTS that made pattern matching worthwhile. |
| Java 18 | March 2022 | 6 months | UTF-8 everywhere, and a web server in the JDK. |
| Java 19 | September 2022 | 6 months | Virtual threads appear. |
| Java 20 | March 2023 | 6 months | Record patterns learn to nest. |
| Java 21 | September 2023 | LTS | Virtual threads, pattern matching and sequenced collections: the modern baseline. |
| Java 22 | March 2024 | 6 months | Native code without JNI, and unnamed variables. |
| Java 23 | September 2024 | 6 months | Markdown in Javadoc, primitive patterns previewed, ZGC generational by default. |
| Java 24 | March 2025 | 6 months | Stream gatherers final, ahead-of-time class loading, post-quantum cryptography. |
| Java 25 | September 2025 | LTS | The current LTS: simpler entry points, scoped values, flexible constructors. |
| Java 26 | March 2026 | 6 months | HTTP/3, lazy constants, and 'final means final'. |
| Java 27 | September 2026 | 6 months | Post-quantum TLS by default, G1 and compact headers everywhere. |
Before you start
How to run every example.
One JDK, all versions
Install Java 27 with SDKMAN and every example on this page runs on it, including the older ones, because Java keeps source compatibility. Examples marked preview need the flag shown under them.
sdk install java 27-tem # Eclipse Temurin build of Java 27
java -version
java Java21.java # single-file launch, no javac (Java 11+)
java --enable-preview --source 27 Java27.java # preview features
jshell # a REPL for trying one line at a time
How to read each section
- Chips list the JEPs that shipped, final and preview.
- The paragraph says what changed and why it was worth a release.
- The code is a complete program with the expected output in comments.
- In practice is the one thing to carry into production code.
Features previewed in one release and finalised later are shown in their final syntax, with the release that finalised them named.
The language learns functions.
Java 8 is the release everything after is measured against. Lambdas and method references gave the language first-class behaviour; the Streams API turned collection code from loops into pipelines; Optional made absence explicit; default methods let interfaces evolve without breaking implementations; and java.time finally replaced Date and Calendar with an immutable, timezone-correct model. A great deal of production Java still runs on this version, which is why every later feature is best understood as a refinement of what 8 started.
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
public class Java8 {
static final class Order {
final String customer; final double amount; final LocalDate placed;
Order(String c, double a, LocalDate p) { customer = c; amount = a; placed = p; }
}
public static void main(String[] args) {
List<Order> orders = Arrays.asList(
new Order("ACME", 120.0, LocalDate.of(2014, 3, 18)),
new Order("ACME", 80.5, LocalDate.of(2014, 4, 2)),
new Order("Globex", 42.0, LocalDate.of(2014, 4, 9)));
// Streams: group, then aggregate, in one readable pipeline
Map<String, Double> totalByCustomer = orders.stream()
.collect(Collectors.groupingBy(o -> o.customer, Collectors.summingDouble(o -> o.amount)));
System.out.println(totalByCustomer); // {ACME=200.5, Globex=42.0} (order may vary)
// Optional: the absence of a result is part of the type
Optional<Order> biggest = orders.stream().max(Comparator.comparingDouble(o -> o.amount));
biggest.map(o -> o.customer).ifPresent(System.out::println); // ACME
// java.time: immutable dates with real arithmetic
long days = ChronoUnit.DAYS.between(orders.get(0).placed, orders.get(2).placed);
System.out.println(days + " days between first and last order"); // 22
// CompletableFuture: compose async work without callbacks-in-callbacks
CompletableFuture<String> price = CompletableFuture.supplyAsync(() -> "CHF 12.50");
CompletableFuture<String> stock = CompletableFuture.supplyAsync(() -> "in stock");
System.out.println(price.thenCombine(stock, (p, s) -> p + ", " + s).join());
}
}
Runjavac Java8.java && java Java8
In practice. The habit that matters most from Java 8 is not the syntax but the shape: transform data with pure functions, keep state at the edges, and let the pipeline express intent. Everything from records to gatherers builds on that shape.
Modules, jshell and a tidier standard library.
Java 9 was three years in the making and its headline, the module system, is still the least used feature of the platform. Its lasting value is elsewhere: the JDK itself is modular, which is what allows jlink to build small custom runtimes; the collection factories removed a decade of Arrays.asList workarounds; and jshell gave Java a REPL for trying an API before writing a class around it.
import java.util.*;
import java.util.stream.*;
public class Java9 {
interface Greeter {
default String greet(String name) { return prefix() + name; }
private String prefix() { return "Hello, "; } // private interface methods
}
public static void main(String[] args) {
List<Integer> readings = List.of(3, 5, 8, 13, 2, 21); // immutable, null-hostile, compact
System.out.println(readings.stream().takeWhile(r -> r < 10).collect(Collectors.toList())); // [3, 5, 8]
System.out.println(Stream.iterate(1, i -> i < 100, i -> i * 2).collect(Collectors.toList())); // 1 … 64
Optional.of("cache hit").ifPresentOrElse(System.out::println, () -> System.out.println("miss"));
System.out.println(new Greeter() {}.greet("Java 9"));
System.out.println("pid " + ProcessHandle.current().pid()); // the new Process API
}
}
// module-info.java: the same code as an explicit module
module ch.janaka.inventory {
requires java.logging;
exports ch.janaka.inventory.api; // everything else stays internal, enforced by the JVM
}
Runjavac Java9.java && java Java9 # or: jshell, then paste any line
In practice. Most applications stay on the classpath, and that is fine. Where modules pay off is in libraries and in shipping: jlink --add-modules java.base,java.logging produces a runtime a fraction of the size of a full JDK, which is exactly what a container image wants.
var, and the six-month cadence begins.
Java 10 was the first release on the new six-month schedule, and it introduced var. The type is still static and still inferred by the compiler; what changes is how much of it you have to type. Less visibly, Java 10 made the JVM aware of container CPU and memory limits, which is the reason a Spring Boot service in Kubernetes stopped sizing its heap from the host machine.
import java.util.*;
import java.util.stream.Collectors;
public class Java10 {
public static void main(String[] args) {
var orders = new ArrayList<Map<String, Integer>>(); // inferred as ArrayList<Map<String,Integer>>
orders.add(Map.of("qty", 2));
orders.add(Map.of("qty", 5));
for (var order : orders) {
var qty = order.get("qty"); // int-typed Integer, still checked
System.out.println("qty " + qty);
}
var frozen = orders.stream().collect(Collectors.toUnmodifiableList());
System.out.println(frozen.size());
var first = orders.stream().findFirst().orElseThrow(); // no-arg orElseThrow() is new in 10
System.out.println(first);
}
}
Runjava -XX:+PrintFlagsFinal -version | grep UseContainerSupport # true: cgroup limits are respected
In practice. Use var when the right-hand side already says the type (new, a factory, a builder) and keep the explicit type when it would not be obvious from the line. That single rule keeps reviews calm.
The first LTS of the new era.
Java 11 is where most enterprises landed after 8, and it repaid the move with a standard HTTP client that speaks HTTP/2, a set of small string and file conveniences that remove a lot of Apache Commons, and the ability to run a single source file with java File.java. The removal of the Java EE modules (JAXB, JAX-WS, CORBA) was the migration pain everyone remembers; the fix was a dependency, not a rewrite.
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.util.function.BiFunction;
public class Java11 {
public static void main(String[] args) throws Exception {
var client = HttpClient.newHttpClient(); // HTTP/1.1 and HTTP/2, sync and async
var request = HttpRequest.newBuilder(URI.create("https://janaka.me/robots.txt")).GET().build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
response.body().lines().filter(l -> !l.isBlank()).map(String::strip).forEach(System.out::println);
var tmp = Files.createTempFile("java11", ".txt");
Files.writeString(tmp, "=".repeat(20) + "\nwritten with Files.writeString\n");
System.out.print(Files.readString(tmp));
BiFunction<Integer, Integer, Integer> add = (var a, var b) -> a + b; // var in lambda params
System.out.println(add.apply(20, 22));
}
}
Runjava Java11.java # single-file launch: no javac step
In practice. The single-file launcher is the fastest way to try an API or reproduce a bug: one file, no build. Every example on this page runs that way from Java 11 on.
Switch expressions arrive as a preview.
Java 12 introduced the preview mechanism in earnest: switch expressions appeared with the arrow form and would be refined for two more releases before becoming final in 14. Of the final features, Collectors.teeing is the one worth knowing, because it lets a single pass produce two aggregates and merge them.
import java.text.NumberFormat;
import java.util.*;
import java.util.stream.Collectors;
public class Java12 {
public static void main(String[] args) {
var latencies = List.of(120, 340, 95, 410, 230);
// teeing: two collectors, one pass, one merged result
var summary = latencies.stream().collect(Collectors.teeing(
Collectors.minBy(Integer::compare),
Collectors.averagingInt(Integer::intValue),
(min, avg) -> "min=" + min.orElseThrow() + "ms avg=" + avg + "ms"));
System.out.println(summary); // min=95ms avg=239.0ms
System.out.print("select *\nfrom orders\n".indent(4)); // indent every line
System.out.println(" trim me ".transform(String::strip) + "|");
var fmt = NumberFormat.getCompactNumberInstance(Locale.US, NumberFormat.Style.SHORT);
System.out.println(fmt.format(1_250_000)); // 1M
}
}
Runjava Java12.java
In practice. Preview features are complete but not yet frozen. They need --enable-preview at compile and run time, and their use in a shipped product is a decision, not an accident. The switch expression preview became final two releases later with the syntax below unchanged.
Text blocks, and yield in switch.
Java 13 is a small release with one big quality-of-life change: text blocks. SQL, JSON and HTML embedded in Java stopped being a wall of + "\n". The switch expression preview gained yield for blocks that need more than one statement. Both features are shown here in their final form, which is unchanged from the preview.
public class Java13 {
public static void main(String[] args) {
String json = """
{
"product": "nüchtern",
"madeIn": "Zug"
}
"""; // closing delimiter position sets the indentation
System.out.print(json);
String size = "M";
int weight = switch (size) {
case "S" -> 1;
case "M" -> 2;
default -> {
int base = 3;
yield base * 2; // yield returns a value from a block
}
};
System.out.println(weight);
}
}
Runjava Java13.java # on 13 itself: java --enable-preview --source 13 Java13.java
In practice. Text blocks are also the cleanest way to write a prompt for a language model inside Java code, which matters more now than it did in 2019.
Switch expressions final, helpful NullPointerExceptions.
Java 14 finalised switch expressions and previewed the two features that would change how data is modelled in Java: records and pattern matching. It also shipped the change that saves the most debugging hours per line of implementation: the JVM now tells you which reference was null.
public class Java14 {
enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }
static String[] names = new String[1];
public static void main(String[] args) {
Day d = Day.SAT;
int hours = switch (d) { // exhaustive over the enum, no break, no fall-through
case MON, TUE, WED, THU, FRI -> 8;
case SAT, SUN -> 0;
};
System.out.println(hours);
try {
System.out.println(names[0].length());
} catch (NullPointerException e) {
System.out.println(e.getMessage());
// Cannot invoke "String.length()" because "Java14.names[0]" is null
}
}
}
Runjava Java14.java # on 14 the message needs -XX:+ShowCodeDetailsInExceptionMessages; default since 15
In practice. JFR event streaming (jdk.jfr.consumer.RecordingStream) is the other quiet gift of this release: an application can subscribe to its own GC, allocation and lock events and export them to the metrics system without an agent.
Text blocks final, sealed classes previewed, ZGC production-ready.
Java 15 finalised text blocks and added formatted to String, made ZGC and Shenandoah production features, and added the Ed25519 signature scheme to the standard cryptography. Sealed classes arrived as a preview: a type can now say exactly which types may extend it, which is the missing half of pattern matching.
import java.security.*;
public class Java15 {
public static void main(String[] args) throws Exception {
String sql = """
select id, total
from orders
where customer = '%s'
""".formatted("ACME"); // formatted() is new in 15
System.out.print(sql);
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(kp.getPrivate());
sig.update("release notes".getBytes());
byte[] signature = sig.sign();
sig.initVerify(kp.getPublic());
sig.update("release notes".getBytes());
System.out.println("valid: " + sig.verify(signature)); // valid: true
}
}
Runjava Java15.java
In practice. ZGC becoming production-ready is the point where sub-10 ms pauses on large heaps stopped being a research topic. If a service has a latency SLO and a heap over a few gigabytes, -XX:+UseZGC is the first flag to try.
Records and instanceof patterns are final.
Java 16 is when modern Java data modelling became real: records give you an immutable, value-based class in one line, with equals, hashCode, toString and accessors generated and a compact constructor for validation. Pattern matching for instanceof removes the cast that followed every type check since 1995.
import java.util.*;
import java.util.stream.*;
public class Java16 {
record Point(int x, int y) {
Point { // compact constructor: validate, then assign
if (x < 0 || y < 0) throw new IllegalArgumentException("negative coordinate");
}
double distance() { return Math.hypot(x, y); }
}
public static void main(String[] args) {
Object o = new Point(3, 4);
if (o instanceof Point p && p.distance() == 5.0) { // bind and use in one expression
System.out.println("on the 5-circle: " + p); // on the 5-circle: Point[x=3, y=4]
}
List<Integer> evens = IntStream.rangeClosed(1, 10).boxed().filter(i -> i % 2 == 0).toList();
System.out.println(evens); // toList(): unmodifiable, no Collectors
List<String> expanded = Stream.of("a", "b")
.<String>mapMulti((s, sink) -> { sink.accept(s); sink.accept(s.toUpperCase()); })
.toList();
System.out.println(expanded); // [a, A, b, B]
System.out.println(new Point(1, 2).equals(new Point(1, 2))); // true: value semantics
}
}
Runjava Java16.java
In practice. Records are the right type for DTOs, events, configuration and every value that crosses a boundary. Add validation in the compact constructor and the object cannot exist in an invalid state, which removes a whole class of defensive checks downstream.
Sealed classes final: the LTS that made pattern matching worthwhile.
Java 17 is the LTS that Spring Boot 3 chose as its baseline, and the reason is sealed classes. A sealed interface plus records gives Java algebraic data types: the compiler knows every possible case, so a switch over the hierarchy can be checked for exhaustiveness. Pattern matching for switch appeared as a preview here and became final in 21; the example uses the final syntax.
import java.util.random.RandomGenerator;
public class Java17 {
sealed interface Payment permits Card, Twint, Invoice {} // the closed set of implementations
record Card(String last4) implements Payment {}
record Twint(String phone) implements Payment {}
record Invoice(int days) implements Payment {}
static String describe(Payment p) {
return switch (p) { // exhaustive: no default needed
case Card c -> "card ending " + c.last4();
case Twint t -> "TWINT " + t.phone();
case Invoice i -> "invoice, " + i.days() + " days";
};
}
public static void main(String[] args) {
System.out.println(describe(new Twint("+41 76 …")));
RandomGenerator rng = RandomGenerator.of("L64X128MixRandom"); // pluggable, modern generators
System.out.println(rng.nextInt(1, 7)); // a die roll
}
}
Runjava Java17.java # pattern switch is final on 21+; on 17: java --enable-preview --source 17 Java17.java
In practice. If a new implementation of Payment is added, every switch over it stops compiling until the case is handled. That is the property a regulated system wants: the compiler, not a code review, guarantees that a new case cannot be silently ignored.
UTF-8 everywhere, and a web server in the JDK.
Java 18 closed a thirty-year source of bugs by making UTF-8 the default charset on every operating system, so a file written on a developer's Mac reads the same on a Windows server. It also added a minimal static web server, useful for serving a build output or a documentation folder without installing anything.
public class Java18 {
/**
* Parses a Swiss postcode.
* {@snippet :
* int zug = Java18.parse("6300"); // @highlight substring="parse"
* }
*/
static int parse(String s) { return Integer.parseInt(s); }
public static void main(String[] args) {
System.out.println(java.nio.charset.Charset.defaultCharset()); // UTF-8, on every OS, since 18
System.out.println(parse("6300"));
}
}
Runjwebserver -p 8000 -d /path/to/site # then open http://localhost:8000/
In practice. @snippet turns the code in your Javadoc into something the compiler can check and the IDE can highlight, which is the difference between documentation that rots and documentation that is tested.
Virtual threads appear.
Java 19 previewed Project Loom's virtual threads: threads that cost almost nothing to create and block, scheduled by the JVM onto a small pool of carrier threads. The thread-per-request model that Java servers have used since the servlet era suddenly scales to hundreds of thousands of concurrent requests without a reactive rewrite. The API here is the final one from Java 21.
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class Java19 {
public static void main(String[] args) throws Exception {
long t0 = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; }))
.toList();
System.out.println(futures.size() + " tasks submitted");
} // close() waits for every task
System.out.println("10,000 one-second sleeps finished in "
+ (System.nanoTime() - t0) / 1_000_000 + " ms"); // roughly 1,0xx ms, not 10,000 s
}
}
Runjava Java19.java # final on 21+; on 19/20: java --enable-preview --source 19 Java19.java
In practice. Ten thousand platform threads would need gigabytes of stack and would be refused by most operating systems. Ten thousand virtual threads are a few megabytes. The code is the same blocking code you already know how to debug.
Record patterns learn to nest.
Java 20 was a consolidation release: every headline feature was a second or third preview. The interesting refinement was in record patterns, which can now nest, so a switch can take a whole object graph apart in one case label. Final in 21; shown in the final form.
public class Java20 {
record Point(int x, int y) {}
record Line(Point from, Point to) {}
static String describe(Object o) {
return switch (o) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) when x1 == x2
-> "vertical line at x=" + x1; // nested deconstruction plus a guard
case Line(Point from, Point to)
-> "line from " + from + " to " + to;
default -> "not a line";
};
}
public static void main(String[] args) {
System.out.println(describe(new Line(new Point(2, 0), new Point(2, 9))));
System.out.println(describe(new Line(new Point(0, 0), new Point(3, 4))));
}
}
Runjava Java20.java # final on 21+
In practice. Nested patterns are what make a sealed event hierarchy pleasant to consume: a handler can match on the event type and the shape of its payload in one place instead of a type check followed by three getters.
Virtual threads, pattern matching and sequenced collections: the modern baseline.
Java 21 is the release to target today if you are not yet on 25. Three multi-year projects landed as final in one LTS: virtual threads, record patterns and pattern matching for switch. Sequenced collections gave every ordered collection getFirst, getLast and reversed. Generational ZGC made the low-pause collector cheap on allocation-heavy workloads.
import java.util.*;
import java.util.concurrent.*;
public class Java21 {
sealed interface Event permits Deposit, Withdrawal {}
record Deposit(String account, long cents) implements Event {}
record Withdrawal(String account, long cents) implements Event {}
static String apply(Event e) {
return switch (e) {
case Deposit(var acc, var cents) -> "+" + cents + " to " + acc;
case Withdrawal(var acc, var cents) when cents > 100_000 -> "large withdrawal from " + acc + ", needs review";
case Withdrawal(var acc, var cents) -> "-" + cents + " from " + acc;
};
}
public static void main(String[] args) throws Exception {
var steps = new LinkedHashSet<>(List.of("open", "kyc", "funded"));
System.out.println(steps.getFirst() + " … " + steps.getLast()); // sequenced collections
System.out.println(steps.reversed()); // [funded, kyc, open]
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> a = pool.submit(() -> apply(new Deposit("CH-1", 5_000)));
Future<String> b = pool.submit(() -> apply(new Withdrawal("CH-1", 250_000)));
System.out.println(a.get());
System.out.println(b.get());
}
System.out.println(Thread.currentThread().isVirtual()); // false: main is a platform thread
}
}
Runjava Java21.java
In practice. In Spring Boot 3.2+, spring.threads.virtual.enabled=true switches Tomcat and the task executors to virtual threads. Keep an eye on synchronized blocks around blocking I/O: until Java 24 they pinned the carrier thread, so prefer ReentrantLock in hot paths.
Native code without JNI, and unnamed variables.
Java 22 finalised Project Panama's foreign function and memory API: Java can call a C function and manage off-heap memory with no JNI glue code, safely, with the lifetime of the memory tied to an Arena. The underscore became a real unnamed variable. And the source launcher learned to compile several files, so a small multi-file program runs without a build tool.
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.util.List;
public class Java22 {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle( // bind the C function strlen(const char*)
linker.defaultLookup().find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
try (Arena arena = Arena.ofConfined()) { // off-heap memory freed when the arena closes
MemorySegment str = arena.allocateFrom("Grüezi Zug");
long len = (long) strlen.invokeExact(str);
System.out.println(len); // 11: ü is two bytes in UTF-8
}
int errors = 0;
for (var _ : List.of(1, 2, 3)) errors++; // unnamed variable: we only count
System.out.println(errors);
record Pair(Object a, Object b) {}
Object o = new Pair("x", 42);
if (o instanceof Pair(String s, _)) System.out.println("first is " + s); // unnamed pattern
}
}
Runjava Java22.java # multi-file: java Main.java compiles the other .java files it references
In practice. FFM is how Java now talks to native tokenizers, vector-search libraries and GPU runtimes, which is why it matters for AI workloads on the JVM. The confined arena gives you deterministic release, something JNI never did.
Markdown in Javadoc, primitive patterns previewed, ZGC generational by default.
Java 23 let documentation comments be written in Markdown with a /// prefix, which removes most of the HTML from Javadoc. It also made generational mode the default for ZGC, previewed import module, and dropped string templates back to the drawing board rather than finalise a design the team was not happy with. That last decision is worth noting: the six-month cadence does not force features out.
/// Converts Swiss francs to rappen.
///
/// - Uses `long` to avoid floating point money.
/// - The inverse is [#toFrancs(long)].
///
/// @param chf amount in francs
/// @return amount in rappen
public class Java23 {
static long toRappen(long chf) { return chf * 100; }
static long toFrancs(long rappen) { return rappen / 100; }
public static void main(String[] args) {
System.out.println(toRappen(12) + " rappen");
}
}
Runjavadoc -d doc Java23.java && java Java23
In practice. The removal of string templates is the release's real lesson. A preview is a question put to the community; if the answer is no, the feature is withdrawn. Do not build a product on a preview unless you are prepared for that outcome.
Stream gatherers final, ahead-of-time class loading, post-quantum cryptography.
Java 24 is a large release. Gatherers give the Streams API custom intermediate operations, closing the last gap against what a hand-written loop can do. The ahead-of-time cache (Project Leyden's first delivery) records loaded and linked classes from a training run and replays them, cutting start-up time noticeably for frameworks like Spring. The quantum-resistant ML-KEM and ML-DSA algorithms arrived in the standard providers. And synchronized no longer pins virtual threads to their carriers, removing the main operational caveat of Java 21.
import java.util.*;
import java.util.stream.*;
public class Java24 {
public static void main(String[] args) {
var prices = List.of(10, 12, 11, 15, 14, 18);
// built-in gatherer: a sliding window, here a 3-point moving average
System.out.println(prices.stream()
.gather(Gatherers.windowSliding(3))
.map(w -> w.stream().mapToInt(Integer::intValue).average().orElse(0))
.toList()); // [11.0, 12.67, 13.33, 15.67]
// custom gatherer: emit only prices that beat the running maximum
Gatherer<Integer, int[], Integer> newHighs = Gatherer.ofSequential(
() -> new int[] { Integer.MIN_VALUE }, // state: the max so far
(state, price, downstream) -> {
if (price > state[0]) { state[0] = price; return downstream.push(price); }
return true; // keep going, emit nothing
});
System.out.println(prices.stream().gather(newHighs).toList()); // [10, 12, 15, 18]
}
}
# Ahead-of-time class loading and linking (JEP 483): train once, start fast every time
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -cp app.jar ch.janaka.App # training run
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -cp app.jar
java -XX:AOTCache=app.aot -cp app.jar ch.janaka.App # production run
Runjava Java24.java
In practice. A gatherer is the right tool when a stream needs state between elements: de-duplication with a window, rate limiting, run-length encoding, or anything you used to drop out to a loop for.
The current LTS: simpler entry points, scoped values, flexible constructors.
Java 25 is the LTS most vendors will support into the 2030s, and the one Spring Boot 4 recommends. It finalised the features that make Java approachable again: a program can be a single file with a bare void main(), import module java.base replaces a page of imports, and a constructor may validate its arguments before calling super(). Scoped values became the structured, immutable replacement for thread-locals, designed for virtual threads.
// Hello.java: a compact source file. No class declaration, no static, no String[] args.
import module java.base; // every exported package of java.base, one line
void main() {
var offices = List.of("Zug", "Zürich", "Basel");
IO.println("Offices: " + String.join(", ", offices)); // java.lang.IO: simple console I/O
}
public class Java25 {
// Scoped values: an immutable, inherited context, bound for the extent of a call
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
static String audit(String action) { return TENANT.get() + ": " + action; }
static class Account {
final long balanceCents;
Account(long balanceCents) { this.balanceCents = balanceCents; }
}
static class SavingsAccount extends Account {
SavingsAccount(String raw) {
long cents = Long.parseLong(raw); // flexible constructor bodies:
if (cents < 0) throw new IllegalArgumentException("negative opening balance");
super(cents); // statements before super() are allowed
}
}
public static void main(String[] args) throws Exception {
ScopedValue.where(TENANT, "acme").run(() -> System.out.println(audit("open account")));
System.out.println(TENANT.isBound()); // false: the binding ended with run()
System.out.println(new SavingsAccount("1250").balanceCents);
}
}
# AOT cache in one step (JEP 514), then reuse it
java -XX:AOTCacheOutput=app.aot -cp app.jar ch.janaka.App
java -XX:AOTCache=app.aot -cp app.jar ch.janaka.App
Runjava Hello.java && java Java25.java
In practice. Scoped values are the right way to carry a tenant, a trace ID or a principal through a request on virtual threads: the value cannot be changed by a callee, it is visible to child tasks in a structured scope, and it is gone when the scope ends. That is the property a multi-tenant banking service needs from its request context.
HTTP/3, lazy constants, and 'final means final'.
Java 26 added HTTP/3 to the client that has shipped since 11, so QUIC transport is one builder call away. It began the road to making final fields truly immutable: deep reflection that mutates a final field now produces a warning, with a command-line option to forbid it, in preparation for that becoming the default. Stable values were renamed lazy constants and refined: a constant initialised at most once, on first use, that the JIT can then treat as truly constant.
import java.net.URI;
import java.net.http.*;
public class Java26 {
public static void main(String[] args) throws Exception {
var client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3) // prefer HTTP/3, fall back transparently
.build();
var request = HttpRequest.newBuilder(URI.create("https://cloudflare-quic.com/")).GET().build();
for (int i = 1; i <= 2; i++) {
var response = client.send(request, HttpResponse.BodyHandlers.discarding());
System.out.println("request " + i + ": " + response.version());
// 1: HTTP_2 (learns the Alt-Svc header) 2: HTTP_3 (QUIC, once the server has advertised it)
}
}
}
import java.util.logging.Logger;
public class Java26Lazy {
// Lazy constant: created on first get(), exactly once, then constant-folded by the JIT
private static final LazyConstant<Logger> LOG =
LazyConstant.of(() -> Logger.getLogger("orders"));
public static void main(String[] args) {
LOG.get().info("first use creates the logger");
LOG.get().info("second use reuses it");
}
}
java Java26.java
java --enable-preview --source 26 Java26Lazy.java
java --illegal-final-field-mutation=deny -cp app.jar ch.janaka.App # find frameworks that still poke final fields
In practice. Run your test suite once with --illegal-final-field-mutation=deny. Anything that fails is a serialisation or mocking library relying on reflection into final fields, and it needs an upgrade before that setting becomes the default in a later release.
Post-quantum TLS by default, G1 and compact headers everywhere.
Java 27 reached general availability on 15 September 2026. Its final features are operational rather than syntactic, and all four matter to a regulated system. TLS 1.3 connections now negotiate the hybrid X25519MLKEM768 key exchange by default, so traffic captured today cannot be decrypted by a future quantum computer. G1 is the default collector on every machine, including the small containers that used to get Serial. Compact object headers, final in 25, are now on by default and shave roughly a tenth off heap usage. And Flight Recorder can redact secrets from the environment variables, system properties and arguments it records.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class Java27 {
record Quote(String price, String stock) {}
static String fetchPrice() throws InterruptedException { Thread.sleep(200); return "CHF 12.50"; }
static String fetchStock() throws InterruptedException { Thread.sleep(300); return "in stock"; }
public static void main(String[] args) throws Exception {
// Structured concurrency (preview): subtasks live and die with the scope
try (var scope = StructuredTaskScope.open()) {
Subtask<String> price = scope.fork(Java27::fetchPrice);
Subtask<String> stock = scope.fork(Java27::fetchStock);
scope.join(); // both succeed, or one fails, the other is cancelled, join throws
System.out.println(new Quote(price.get(), stock.get()));
}
System.out.println(bucket(503));
narrow(42L);
narrow(1L << 40);
}
// Primitive types in patterns (preview): switch and instanceof over int, long, double …
static String bucket(int status) {
return switch (status) {
case 200 -> "ok";
case int s when s >= 500 -> "server error " + s;
case int s -> "other " + s;
};
}
static void narrow(long id) {
if (id instanceof int small) System.out.println("fits in an int: " + small); // exact, no lossy cast
else System.out.println("needs a long: " + id);
}
}
# run the preview example
java --enable-preview --source 27 Java27.java
# defaults you get for free on 27
java -XX:+PrintFlagsFinal -version | grep -E 'UseG1GC|UseCompactObjectHeaders' # both true
# see the post-quantum key share in the handshake
java -Djavax.net.debug=ssl:handshake Java11.java 2>&1 | grep -i mlkem # X25519MLKEM768
# redact secrets before Flight Recorder writes them to disk
java -XX:FlightRecorderOptions:'redact-key=confidential;secret;*token*' \
-XX:StartFlightRecording:filename=app.jfr -cp app.jar ch.janaka.App
jfr print --events jdk.InitialSystemProperty app.jfr # values show as [REDACTED]
// Pin the key-exchange policy in code when a security standard requires it
var params = sslContext.getDefaultSSLParameters();
params.setNamedGroups(new String[] { "X25519MLKEM768", "SecP256r1MLKEM768", "x25519", "secp256r1" });
In practice. Nothing in this release needs a code change to benefit from it. Upgrade the runtime, check the GC and heap metrics, confirm the handshake shows a hybrid group, and add a redaction filter to any JFR recording that leaves the machine. That is a release note a bank's change board can approve.
Where to go next
Reading the arc.
Data
Records, sealed types, patterns
Java 14 to 21 gave the language algebraic data types. Model domain events as a sealed hierarchy of records and let the compiler check every switch.
Concurrency
Virtual threads, scoped values, structured scopes
Java 19 to 25 made blocking code scale and gave it a safe request context. Structured concurrency is the last piece, still in preview in 27.
Runtime
Start-up, footprint, security
Java 24 to 27 delivered AOT caches, compact headers, G1 everywhere and post-quantum TLS. These are upgrade wins that need no code change.
Continue with the Spring side of the story.
The companion guide follows Spring Boot from 1.0 to 4.x and shows what Spring AI 2.0 can do today, with the same working-example approach.