Java 21 LTS: Virtual Threads, Record Patterns, and More
Back to Blog

Java 21 LTS: Virtual Threads, Record Patterns, and More

7 min read
Read in Deutsch

Java 21 LTS: Virtual Threads, Record Patterns, and More

On September 19, Oracle released Java 21 -- and we are convinced: this is the most important Java release in at least a decade. Java 21 is the next Long-Term Support release after Java 17, which we covered in detail two years ago in our Java 17 article. The two-year LTS cycle that began with Java 17 (September 2021) thus delivers the next release with long-term support right on schedule.

At encircle360, we have been intensively testing Virtual Threads since the preview in Java 19 and using them in prototypes. The fact that they are now production-ready fundamentally changes the rules for server-side Java. But let's take it step by step.

Why This LTS Release Is Special

Between Java 17 and Java 21 lie three years of feature development across interim releases 18 through 21. For teams that rely on LTS versions, the cumulative feature set is enormous. But one feature stands out: Virtual Threads (JEP 444) are finally finalized after two preview rounds. No more --enable-preview, no restrictions -- production-ready and fully supported.

On top of that come finalized features like Record Patterns, Pattern Matching for switch, and the new Sequenced Collections, which together noticeably modernize Java as a language. For anyone working with Java 17 as the baseline after migrating to Spring Boot 3, Java 21 delivers the next massive boost.

Virtual Threads: Rethinking the Concurrency Model

Virtual Threads (JEP 444) are the flagship feature of Java 21 and the reason this release has the Java world excited. The core idea: instead of heavyweight platform threads that each occupy an OS thread, the JVM now offers lightweight threads managed by the runtime itself.

The result is impressively simple to use:

// Start a single Virtual Thread
Thread.startVirtualThread(() -> {
    System.out.println("Hello from a Virtual Thread!");
});

// With the new ExecutorService: one thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        })
    );
}

This example starts 10,000 concurrent tasks -- each with its own Virtual Thread. With platform threads, this would either be impossible or require a massive thread pool. Virtual Threads consume only a few kilobytes of memory and are automatically mapped to a small number of carrier threads.

For typical server applications that spend a lot of time waiting on I/O -- database queries, HTTP calls, file access -- this means a dramatic increase in possible concurrency. Instead of tuning thread pools and thinking about reactive frameworks, you simply write blocking code and let the JVM handle the scaling.

What particularly excites us: Spring Boot 3.2, which will be released in a few weeks, brings native support for Virtual Threads. A single property (spring.threads.virtual.enabled=true) is all it takes to switch the embedded Tomcat to Virtual Threads. The ecosystem is catching up, and fast.

Record Patterns: Destructuring for Records

Record Patterns (JEP 440) extend the pattern matching that began with instanceof in Java 16, which we presented in our Java 17 article, with the ability to decompose records directly into their components:

record Point(int x, int y) {}
record Line(Point start, Point end) {}

// Record Pattern in instanceof
if (obj instanceof Point(int x, int y)) {
    System.out.println("Point at " + x + ", " + y);
}

// Nested Record Patterns
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
    double length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
    System.out.println("Line length: " + length);
}

Nested destructuring is particularly powerful: in a single pattern match, you can navigate through multiple levels of records and use the values directly as local variables. This not only eliminates boilerplate but makes the intent of the code significantly clearer.

Pattern Matching for switch: Finally Final

Pattern Matching in switch expressions (JEP 441) has been available as a preview since Java 17 and has received refinements over four releases. In Java 21, it is finally finalized -- no more --enable-preview needed. Combined with Record Patterns and Sealed Classes, it produces expressive, compact code:

sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

static String describe(Shape shape) {
    return switch (shape) {
        case Circle(double r) when r > 100 ->
            "Large circle with radius " + r;
        case Circle(double r) ->
            "Circle with radius " + r;
        case Rectangle(double w, double h) ->
            "Rectangle: " + w + " x " + h;
    };
}

Particularly noteworthy is the when clause (guarded patterns), which allows attaching additional conditions to a pattern. The compiler still checks exhaustiveness -- with Sealed Classes, no default branch is needed. This is pattern matching as you know it from functional languages, but fully integrated into Java.

Sequenced Collections: Order in the Collections Framework

Sequenced Collections (JEP 431) close a gap that has existed for years in the Java Collections Framework. Until now, there was no unified interface for collections that have a defined order. List has an order, LinkedHashSet does too, SortedSet as well -- but there was no common interface for it.

Java 21 introduces three new interfaces: SequencedCollection, SequencedSet, and SequencedMap.

// SequencedCollection: uniform access to first/last element
SequencedCollection<String> names = new ArrayList<>(List.of("Anna", "Ben", "Clara"));

String first = names.getFirst();     // "Anna"
String last = names.getLast();       // "Clara"

names.addFirst("Zara");             // Zara is inserted at the front
names.addLast("Daniel");            // Daniel is appended at the end

// Reversed view
SequencedCollection<String> reversed = names.reversed();
System.out.println(reversed.getFirst()); // "Daniel"

// Works the same with LinkedHashMap
SequencedMap<String, Integer> map = new LinkedHashMap<>();
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);

var firstEntry = map.firstEntry();   // a=1
var lastEntry = map.lastEntry();     // c=3
var reversedMap = map.reversed();

This sounds unspectacular but solves a real problem. How often have you written list.get(list.size() - 1) to get the last element, or resorted to ((TreeSet<T>) set).first()? The new interfaces are cleanly integrated into the existing hierarchy: List now extends SequencedCollection, SortedSet extends SequencedSet, and so on.

String Templates: A Preview

As a preview feature, Java 21 delivers String Templates via JEP 430 -- a type-safe alternative to string concatenation and String.format():

// Preview feature: --enable-preview required
String name = "encircle360";
int year = 2017;

String message = STR."Welcome to \{name}, founded \{year}!";
// -> "Welcome to encircle360, founded 2017!"

// Also with expressions
String info = STR."The company is \{2023 - year} years old.";

The STR template processor handles the embedded expressions in a type-safe manner at compile time. This is a significant improvement over String.format(), where type errors only surface at runtime. As a preview, the feature still needs to be activated with --enable-preview, but the direction is clear.

Unnamed Patterns and Variables (Preview)

Another preview feature (JEP 443) addresses a well-known annoyance: variables you must declare but never use. In Java 21, you can use the underscore for this:

// Only the record's name matters, not the details
if (obj instanceof Point(int x, _)) {
    System.out.println("x-coordinate: " + x);
}

// In try-with-resources, when the exception is not used
try {
    // ...
} catch (NumberFormatException _) {
    System.out.println("Invalid number format");
}

This improves readability and clearly signals which values are relevant and which are not.

Our Recommendation: Migrate Now

At encircle360, we updated our internal services to Java 21 in the week after the release. The migration from Java 17 is straightforward -- significantly easier than the jump from Java 11 to 17 was at the time. The biggest gains lie in Virtual Threads: for our I/O-heavy microservices, we were able to drastically simplify thread pool configurations.

A pragmatic migration plan:

  1. Install JDK 21 -- Eclipse Temurin or Amazon Corretto already offer ready-made builds
  2. Compile and run tests -- most projects build without changes
  3. Set source level to 21 -- in the compiler plugin of Maven or Gradle
  4. Evaluate Virtual Threads -- initially in non-critical services with Executors.newVirtualThreadPerTaskExecutor()
  5. Wait for Spring Boot 3.2 -- which brings native Virtual Thread support and makes the switch trivial

Java 21 is not just another LTS release. It is the release that provides the answer to why reactive frameworks may become unnecessary for most applications. Virtual Threads transform the concurrency model, Record Patterns and Pattern Matching for switch modernize the language, and Sequenced Collections clean up a decade-old design gap. This is the biggest step Java has taken in a single LTS release -- and we are thrilled.

Patrick Hütter

Written by

Patrick Hütter

Founder & Software Architect

Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.