← Janaka Academy

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.

Java 27 · 15 Sep 2026 20 releases 5 LTS Every example runs

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.

VersionGASupportWhat it is remembered for
Java 8March 2014LTSThe language learns functions.
Java 9September 20176 monthsModules, jshell and a tidier standard library.
Java 10March 20186 monthsvar, and the six-month cadence begins.
Java 11September 2018LTSThe first LTS of the new era.
Java 12March 20196 monthsSwitch expressions arrive as a preview.
Java 13September 20196 monthsText blocks, and yield in switch.
Java 14March 20206 monthsSwitch expressions final, helpful NullPointerExceptions.
Java 15September 20206 monthsText blocks final, sealed classes previewed, ZGC production-ready.
Java 16March 20216 monthsRecords and instanceof patterns are final.
Java 17September 2021LTSSealed classes final: the LTS that made pattern matching worthwhile.
Java 18March 20226 monthsUTF-8 everywhere, and a web server in the JDK.
Java 19September 20226 monthsVirtual threads appear.
Java 20March 20236 monthsRecord patterns learn to nest.
Java 21September 2023LTSVirtual threads, pattern matching and sequenced collections: the modern baseline.
Java 22March 20246 monthsNative code without JNI, and unnamed variables.
Java 23September 20246 monthsMarkdown in Javadoc, primitive patterns previewed, ZGC generational by default.
Java 24March 20256 monthsStream gatherers final, ahead-of-time class loading, post-quantum cryptography.
Java 25September 2025LTSThe current LTS: simpler entry points, scoped values, flexible constructors.
Java 26March 20266 monthsHTTP/3, lazy constants, and 'final means final'.
Java 27September 20266 monthsPost-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.

Java 8

March 2014 LTS timeline ↑

The language learns functions.

LambdasStreamsOptionalDefault methodsjava.timeCompletableFutureNashornPermGen removed

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.

Java 9

September 2017 timeline ↑

Modules, jshell and a tidier standard library.

Module system (JPMS)jshellList.of / Map.ofPrivate interface methodsStream takeWhile / dropWhileOptional.ifPresentOrElseProcess APIMulti-release JARsG1 default

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.

Java 10

March 2018 timeline ↑

var, and the six-month cadence begins.

Local-variable type inference (var)Collectors.toUnmodifiableListOptional.orElseThrow()Application CDSParallel full GC for G1Container awareness

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.

Java 11

September 2018 LTS timeline ↑

The first LTS of the new era.

HttpClient (standard)String: isBlank, lines, strip, repeatFiles.readString / writeStringRun a single file with javavar in lambda parametersZGC (experimental)Flight Recorder open-sourcedJava EE and CORBA modules removed

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.

Java 12

March 2019 timeline ↑

Switch expressions arrive as a preview.

Switch expressions (preview)Collectors.teeingString.indent / transformCompact number formattingShenandoah GC (experimental)JVM Constants APIMicrobenchmark suite (JMH)

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.

Java 13

September 2019 timeline ↑

Text blocks, and yield in switch.

Text blocks (preview)Switch expressions: yield (2nd preview)Dynamic CDS archivesZGC returns unused memorySocket API reimplemented

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.

Java 14

March 2020 timeline ↑

Switch expressions final, helpful NullPointerExceptions.

Switch expressions (final)Helpful NullPointerExceptionsRecords (preview)Pattern matching for instanceof (preview)JFR event streamingjpackage (incubator)CMS removed

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.

Java 15

September 2020 timeline ↑

Text blocks final, sealed classes previewed, ZGC production-ready.

Text blocks (final)Sealed classes (preview)Hidden classesEdDSA signaturesZGC and Shenandoah productionRecords (2nd preview)Nashorn removedBiased locking disabled

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.

Java 16

March 2021 timeline ↑

Records and instanceof patterns are final.

Records (final)Pattern matching for instanceof (final)Stream.toList()Stream.mapMultijpackageUnix-domain socketsStrong encapsulation by defaultVector API (incubator)Foreign linker (incubator)

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.

Java 17

September 2021 LTS timeline ↑

Sealed classes final: the LTS that made pattern matching worthwhile.

Sealed classes (final)Pattern matching for switch (preview)RandomGenerator APIStrong encapsulation of JDK internalsAlways-strict floating pointContext-specific deserialization filtersApplet API deprecatedmacOS/AArch64 port

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.

Java 18

March 2022 timeline ↑

UTF-8 everywhere, and a web server in the JDK.

UTF-8 by defaultjwebserverCode snippets in Javadoc (@snippet)Reflection via method handlesInternet-address resolution SPIFinalization deprecatedVector API (3rd incubator)Foreign function & memory (2nd incubator)

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.

Java 19

September 2022 timeline ↑

Virtual threads appear.

Virtual threads (preview)Structured concurrency (incubator)Record patterns (preview)Pattern matching for switch (3rd preview)Foreign function & memory (preview)Vector API (4th incubator)Linux/RISC-V port

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.

Java 20

March 2023 timeline ↑

Record patterns learn to nest.

Record patterns (2nd preview)Scoped values (incubator)Pattern matching for switch (4th preview)Virtual threads (2nd preview)Structured concurrency (2nd incubator)Foreign function & memory (2nd preview)

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.

Java 21

September 2023 LTS timeline ↑

Virtual threads, pattern matching and sequenced collections: the modern baseline.

Virtual threads (final)Record patterns (final)Pattern matching for switch (final)Sequenced collectionsGenerational ZGCUnnamed patterns and variables (preview)Unnamed classes and instance main (preview)String templates (preview, later withdrawn)Structured concurrency (preview)Scoped values (preview)Key encapsulation mechanism API

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.

Java 22

March 2024 timeline ↑

Native code without JNI, and unnamed variables.

Foreign function & memory API (final)Unnamed variables and patterns (final)Launch multi-file source programsStatements before super() (preview)Stream gatherers (preview)Class-file API (preview)String templates (2nd preview)Implicitly declared classes (2nd preview)Region pinning for G1

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.

Java 23

September 2024 timeline ↑

Markdown in Javadoc, primitive patterns previewed, ZGC generational by default.

Markdown documentation commentsPrimitive types in patterns (preview)Module import declarations (preview)Flexible constructor bodies (2nd preview)Stream gatherers (2nd preview)Class-file API (2nd preview)Generational ZGC by defaultString templates droppedUnsafe memory-access methods deprecated

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.

Java 24

March 2025 timeline ↑

Stream gatherers final, ahead-of-time class loading, post-quantum cryptography.

Stream gatherers (final)Class-file API (final)Ahead-of-time class loading and linkingCompact object headers (experimental)ML-KEM and ML-DSA (post-quantum)Virtual threads: synchronize without pinningKey derivation function API (preview)Security Manager permanently disabledWindows 32-bit x86 port removed

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.

Java 25

September 2025 LTS timeline ↑

The current LTS: simpler entry points, scoped values, flexible constructors.

Compact source files and instance main methodsModule import declarationsFlexible constructor bodiesScoped values (final)Compact object headers (final)Generational ShenandoahAOT command-line ergonomics and method profilingJFR CPU-time profiling and method tracingKey derivation function APIStable values (preview)Structured concurrency (5th preview)Primitive types in patterns (3rd preview)PEM encodings (preview)32-bit x86 port removed

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.

Java 26

March 2026 timeline ↑

HTTP/3, lazy constants, and 'final means final'.

HTTP/3 for the HTTP Client APIPrepare to make final mean finalAOT object caching with any GCG1: less synchronization, more throughputLazy constants (2nd preview)Structured concurrency (6th preview)Primitive types in patterns (4th preview)PEM encodings (2nd preview)Vector API (11th incubator)Applet API removed

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.

Java 27

September 2026 timeline ↑

Post-quantum TLS by default, G1 and compact headers everywhere.

Post-quantum hybrid key exchange for TLS 1.3G1 the default GC in all environmentsCompact object headers by defaultJFR in-process data redactionLazy constants (3rd preview)Structured concurrency (7th preview)Primitive types in patterns (5th preview)PEM encodings (3rd preview)Vector API (12th incubator)

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.