Java 21 Features (LTS)
Java 21 Features (LTS)
Java 21, released in September 2023, is the fourth Long-Term Support release, following Java 8, Java 11, and Java 17. It is where the sealed classes and records finalized in Java 17 actually pay off — pattern matching for switch and record patterns, both finalized in this release, let a switch expression deconstruct a sealed hierarchy directly, with the compiler checking that every case is covered.
What Changed in Java 21?
| Feature | What It Adds |
|---|---|
Pattern matching for switch | case labels that match a value's runtime type, with compiler-checked exhaustiveness over a sealed hierarchy |
| Record patterns | Deconstructing a record's components directly inside a pattern, including nested records |
| Virtual threads | Lightweight, JVM-managed threads for high-throughput concurrent code |
| Sequenced Collections | Uniform getFirst(), getLast(), addFirst(), addLast(), and reversed() across List, Deque, LinkedHashSet, and LinkedHashMap |
| Generational ZGC | ZGC becomes generational by default, improving pause times further |
| String templates (preview) | Embedding expressions directly inside a string literal |
| Unnamed patterns and variables (preview) | An underscore placeholder for a pattern component or variable that is never used |
| Structured concurrency (preview) | Treats a group of related virtual-thread tasks as a single unit of work |
Why Java 21 Mattered
The payoff is easiest to see as a direct mapping — one switch case per permitted subtype of the sealed hierarchy this series' Java 17 article introduced, with the compiler checking that every branch is covered.
sealed interface Shape permits Circle, Square
switch (shape) {
case Circle(double radius) -> ... // one arrow per permitted type
case Square(double side) -> ... // compiler verifies both are covered
} // no default needed - and none allowed to be missing
Interviewers listening for depth here want to hear that exhaustiveness is a compile-time guarantee tied directly to the sealed hierarchy — add a third permitted shape later, and every switch over that hierarchy that forgot to handle it stops compiling, rather than failing silently at runtime.
The Java 17 article in this series modeled a shape hierarchy with a sealed interface and records, but still had to check each case with an instanceof chain and throw manually for anything unaccounted for.
1// File: BeforeJava21.java
2
3public class BeforeJava21 {
4
5 sealed interface Shape permits Circle, Square {}
6 record Circle(double radius) implements Shape {}
7 record Square(double side) implements Shape {}
8
9 static double area(Shape shape) {
10 if (shape instanceof Circle c) {
11 return Math.PI * c.radius() * c.radius();
12 } else if (shape instanceof Square s) {
13 return s.side() * s.side();
14 }
15 throw new IllegalStateException("Unknown shape");
16 }
17
18 public static void main(String[] args) {
19 Shape circle = new Circle(2.0);
20 System.out.println("Circle area: " + area(circle));
21 }
22}Output:
Circle area: 12.566370614359172
Pattern matching for switch, combined with record patterns, replaces that chain with a switch the compiler checks for completeness on its own — no default, and no manual throw for a case that can never actually happen.
1// File: AfterJava21.java
2
3public class AfterJava21 {
4
5 sealed interface Shape permits Circle, Square {}
6 record Circle(double radius) implements Shape {}
7 record Square(double side) implements Shape {}
8
9 static double area(Shape shape) {
10 return switch (shape) {
11 case Circle(double radius) -> Math.PI * radius * radius;
12 case Square(double side) -> side * side;
13 };
14 }
15
16 public static void main(String[] args) {
17 Shape circle = new Circle(2.0);
18 System.out.println("Circle area: " + area(circle));
19 }
20}Output:
Circle area: 12.566370614359172
Because Shape is sealed with exactly two permitted implementations, and both are covered here, the compiler treats this switch as exhaustive — leaving out a case is a compile error, not a bug waiting to surface in production.
A Tour of Java 21's Core Features
Pattern Matching for switch
A case label can now match against a value's runtime type directly, optionally binding a variable, and a when clause can attach a further condition to a specific case.
1// File: GuardedPatternExample.java
2
3public class GuardedPatternExample {
4
5 sealed interface Shape permits Circle {}
6 record Circle(double radius) implements Shape {}
7
8 static String classify(Shape shape) {
9 return switch (shape) {
10 case Circle c when c.radius() > 5 -> "Large circle";
11 case Circle c -> "Small circle";
12 };
13 }
14
15 public static void main(String[] args) {
16 System.out.println(classify(new Circle(10.0)));
17 System.out.println(classify(new Circle(2.0)));
18 }
19}Output:
Large circle
Small circle
The unconditional case Circle c after the guarded one is what keeps this exhaustive — a guarded pattern alone never counts toward exhaustiveness, since the compiler cannot know in advance whether the guard's condition will hold.
Record Patterns
A record pattern deconstructs a record's components directly inside instanceof or a switch case, and record patterns can nest inside one another to reach into a record that itself contains other records.
1// File: RecordPatternExample.java
2
3public class RecordPatternExample {
4
5 record Point(int x, int y) {}
6 record Line(Point start, Point end) {}
7
8 public static void main(String[] args) {
9 Object obj = new Line(new Point(0, 0), new Point(3, 4));
10
11 if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
12 double length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
13 System.out.println("Line length: " + length);
14 }
15 }
16}Output:
Line length: 5.0
The nested pattern pulls x1, y1, x2, and y2 straight out of the two Point records inside Line, with no intermediate line.start().x()-style accessor chains needed at all.
Sequenced Collections
Before Java 21, getting the first or last element of a List meant list.get(0) and list.get(list.size() - 1), and there was no common interface tying that idea together across List, Deque, LinkedHashSet, and LinkedHashMap. The new SequencedCollection, SequencedSet, and SequencedMap interfaces give all of them the same getFirst(), getLast(), addFirst(), addLast(), and reversed() methods.
1// File: SequencedCollectionExample.java
2import java.util.*;
3
4public class SequencedCollectionExample {
5 public static void main(String[] args) {
6 List<String> queue = new ArrayList<>(List.of("first", "second", "third"));
7
8 System.out.println("getFirst(): " + queue.getFirst());
9 System.out.println("getLast(): " + queue.getLast());
10
11 queue.addFirst("zeroth");
12 queue.addLast("fourth");
13 System.out.println("After adds: " + queue);
14
15 List<String> reversed = queue.reversed();
16 System.out.println("Reversed view: " + reversed);
17 }
18}Output:
getFirst(): first
getLast(): third
After adds: [zeroth, first, second, third, fourth]
Reversed view: [fourth, third, second, first, zeroth]
reversed() returns a live view backed by the same underlying data, not a copy — changes to queue are reflected in reversed and vice versa.
Virtual Threads
Virtual threads are lightweight threads managed by the JVM rather than mapped one-to-one onto an OS thread, letting a server handle a very large number of concurrent blocking operations without exhausting OS threads. This series covers virtual threads in full, including how they interact with thread pools and blocking I/O, in its dedicated Virtual Threads article.
Other Java 21 Additions
Several more JEPs shipped in Java 21 as preview features, meaning their exact syntax was still subject to change and they required an --enable-preview flag to use. String templates let an expression be embedded directly inside a string literal instead of concatenating pieces by hand. Unnamed patterns and variables introduce _ as a placeholder for a pattern component or a caught exception variable that the code never actually uses. Structured concurrency treats a group of related virtual-thread subtasks as a single unit that succeeds or fails together, rather than as independently tracked threads. Generational ZGC, by contrast, is a finalized production feature — it splits the heap into young and old generations under ZGC's low-pause-time collector, but its effect is only observable through GC behavior under real load, not through a simple program's printed output.
Real-World Example
Building on the same ticket-booking domain from this series' Java 17 article, the booking outcome logic is rewritten here using pattern matching for switch and record patterns — including a guarded case that gives large-group bookings a distinct message.
1// File: BookingResult.java
2
3public sealed interface BookingResult
4 permits BookingResult.Confirmed, BookingResult.Waitlisted, BookingResult.Failed {
5
6 record Confirmed(String bookingId, int seatCount) implements BookingResult {}
7 record Waitlisted(int position) implements BookingResult {}
8 record Failed(String reason) implements BookingResult {}
9}1// File: BookingService.java
2
3public class BookingService {
4
5 public BookingResult book(int requestedSeats, int availableSeats) {
6 if (requestedSeats <= 0) {
7 return new BookingResult.Failed("Requested seat count must be positive");
8 }
9 if (requestedSeats <= availableSeats) {
10 String bookingId = "BMS-" + requestedSeats + "-" + availableSeats;
11 return new BookingResult.Confirmed(bookingId, requestedSeats);
12 }
13 int waitlistPosition = requestedSeats - availableSeats;
14 return new BookingResult.Waitlisted(waitlistPosition);
15 }
16}1// File: BookingResultDemo.java
2
3public class BookingResultDemo {
4
5 static String describe(BookingResult result) {
6 return switch (result) {
7 case BookingResult.Confirmed(String bookingId, int seatCount) when seatCount >= 5 ->
8 "Large group confirmed as " + bookingId + " for " + seatCount + " seat(s)";
9 case BookingResult.Confirmed(String bookingId, int seatCount) ->
10 "Confirmed as " + bookingId + " for " + seatCount + " seat(s)";
11 case BookingResult.Waitlisted(int position) ->
12 "Waitlisted at position " + position;
13 case BookingResult.Failed(String reason) ->
14 "Failed: " + reason;
15 };
16 }
17
18 public static void main(String[] args) {
19 BookingService service = new BookingService();
20
21 BookingResult confirmed = service.book(2, 5);
22 BookingResult largeGroup = service.book(6, 10);
23 BookingResult waitlisted = service.book(6, 5);
24 BookingResult failed = service.book(0, 5);
25
26 System.out.println(describe(confirmed));
27 System.out.println(describe(largeGroup));
28 System.out.println(describe(waitlisted));
29 System.out.println(describe(failed));
30 }
31}Output:
Confirmed as BMS-2-5 for 2 seat(s)
Large group confirmed as BMS-6-10 for 6 seat(s)
Waitlisted at position 1
Failed: Requested seat count must be positive
A mistake that appears often in fresher pull requests is stacking several unrelated conditions into one guarded case — case Confirmed(...) when seatCount >= 5 && bookingId.startsWith("BMS") && ... — until the guard is doing the job a separate validation method should be doing. Keeping each guard to a single, clearly named condition, exactly as seatCount >= 5 does here, is what keeps a pattern-matching switch readable instead of turning into a dense wall of boolean logic.
Combining Java 21 Features With Each Other
Pattern matching for switch and record patterns are designed to be used together, exactly as BookingResultDemo does above — the switch selects the case, and the record pattern deconstructs that case's data in the same line. Both features build directly on the sealed classes and records finalized in Java 17, which is why this article reuses the same BookingResult hierarchy rather than introducing an unrelated one. Sequenced Collections pair naturally with virtual threads and structured concurrency, since a queue of pending tasks processed with getFirst() and removeFirst() is a common shape for work handed off across virtual threads.
Best Practices
Prefer pattern matching for switch over an instanceof chain whenever the value being checked is a sealed type — the compiler-checked exhaustiveness catches a missing case at compile time instead of at runtime.
Keep when guards to one clear condition per case, moving anything more complex into a named method the guard calls, so the switch itself stays easy to scan.
Reach for a nested record pattern only when it genuinely improves readability — deconstructing two or three levels deep in one pattern, as Line(Point(...), Point(...)) does here, is fine, but a very deeply nested pattern is often clearer split into smaller steps.
Use getFirst()/getLast()/addFirst()/addLast() from Sequenced Collections in new code instead of the older get(0) / get(size() - 1) idioms, since the new methods work identically across List, Deque, and LinkedHashSet without needing to remember which index trick applies to which type.
Common Mistakes
Assuming a single guarded pattern makes a switch exhaustive on its own does not compile — a when clause can be false, so the compiler always requires an unconditional pattern (or an explicit default) to cover whatever the guard does not.
1// This does not compile - a guard alone is never exhaustive
2sealed interface Shape permits Circle {}
3record Circle(double radius) implements Shape {}
4
5static String classify(Shape shape) {
6 return switch (shape) {
7 case Circle c when c.radius() > 5 -> "Large circle";
8 };
9}Assuming a pattern-matching switch handles a null selector automatically is a second, genuinely runtime-demonstrable mistake — without an explicit case null label, a null selector still throws a NullPointerException, exactly as a traditional switch always has.
1// File: SwitchNullMistake.java
2
3public class SwitchNullMistake {
4
5 sealed interface Shape permits Circle {}
6 record Circle(double radius) implements Shape {}
7
8 static String classify(Shape shape) {
9 return switch (shape) {
10 case Circle c -> "Circle with radius " + c.radius();
11 };
12 }
13
14 public static void main(String[] args) {
15 try {
16 classify(null);
17 } catch (NullPointerException e) {
18 System.out.println("NullPointerException: switch has no case null branch");
19 }
20 }
21}Output:
NullPointerException: switch has no case null branch
Adding case null -> ... explicitly is the only way to make a pattern-matching switch handle a null selector without throwing.
Interview Questions
Q1. What are the headline features finalized in Java 21?
Pattern matching for switch and record patterns are the two headline language features, both building directly on the sealed classes and records finalized in Java 17. Virtual threads and Sequenced Collections are the other two major finalized features in this release. Interviewers use this as an opening question mainly to see whether you connect Java 21 back to Java 17 rather than listing features in isolation.
Q2. How does pattern matching for switch differ from a traditional switch statement?
A traditional switch matches on exact constant values — an int, a String, an enum constant. Pattern matching for switch matches on a value's runtime type, can bind a variable to the matched value in the same case, can deconstruct a record's components via a record pattern, and can attach a when guard for a further condition. The deeper nuance being tested is whether you can explain compiler-checked exhaustiveness, not just the new syntax.
Q3. What is a record pattern, and can it be nested?
A record pattern deconstructs a record's components directly inside instanceof or a switch case, extracting each component into its own variable without calling accessor methods manually. Record patterns can nest, so a pattern can reach into a record that itself contains other records in a single expression, as Line(Point(int x1, int y1), Point(int x2, int y2)) does in this article. Product-based interviewers often ask you to write a nested pattern live, so understanding the syntax by hand matters more than reciting the definition.
Q4. Does the compiler require a default branch when switching over a sealed interface with pattern matching?
No, as long as every permitted subtype of the sealed interface is covered by an unconditional case. If a guarded case is used, an unconditional case for that same type is still required for the switch to be considered exhaustive, since a guard alone can never guarantee every value is handled. This is one of the most commonly missed nuances in interviews, since candidates often assume any guarded case is enough on its own.
Q5. What happens when a pattern-matching switch's selector expression is null and there is no case null label?
It throws a NullPointerException at the point the switch is evaluated, exactly as a traditional switch always has. Adding an explicit case null -> branch is required to handle a null selector without an exception — interviewers ask this specifically because it contradicts the common assumption that pattern matching handles null automatically.
Q6. What are Sequenced Collections, and what problem do they solve?
SequencedCollection, SequencedSet, and SequencedMap are new interfaces that give List, Deque, LinkedHashSet, and LinkedHashMap a common set of methods — getFirst(), getLast(), addFirst(), addLast(), and reversed() — for working with the first and last elements of an ordered collection, replacing type-specific idioms like list.get(list.size() - 1). The nuance worth mentioning is that HashSet and HashMap do not gain these methods, since they have no defined encounter order.
Q7. Is String Templates a finalized feature in Java 21?
No, it shipped as a preview feature in Java 21, requiring the --enable-preview flag, and its exact syntax was still subject to change in later releases. Knowing which Java 21 features are preview versus final is exactly what separates candidates who have run the code from those who only read a changelog.
FAQs
Is Java 21 an LTS release?
Yes, Java 21 is the fourth Long-Term Support release, following Java 8, Java 11, and Java 17.
Does a guarded pattern (when clause) count toward switch exhaustiveness on its own?
No. A when guard can evaluate to false, so the compiler never treats a guarded case as covering its type by itself — an unconditional case for that type, or a default, is still required.
Do all existing collections automatically support getFirst()/getLast()?
Only ordered ones. List, Deque (and its implementations like ArrayDeque and LinkedList), LinkedHashSet, and LinkedHashMap-backed views all gained the Sequenced Collections methods. HashSet and HashMap, which have no defined encounter order, do not implement these interfaces.
What is a MatchException, and when is it thrown?
MatchException is thrown at runtime in the rare case where a pattern-matching switch was exhaustive at compile time based on the sealed hierarchy known then, but no pattern actually matches the value encountered at runtime — something that can only happen if separately compiled code introduces a new implementation the original switch was never compiled against.
Are Virtual Threads covered in this article?
Only briefly. This series covers virtual threads in full detail, including how they interact with thread pools, executors, and blocking I/O, in its dedicated Virtual Threads article.
Can record patterns be used with instanceof, not just switch?
Yes. RecordPatternExample in this article uses a record pattern directly inside an instanceof check; the same nested-deconstruction syntax works identically inside a switch case.
Is Structured Concurrency ready for production use in Java 21?
No, it shipped as a preview feature in Java 21, requiring --enable-preview, with its API still expected to evolve before a final release.
Summary
Java 21 is where the modeling tools Java 17 introduced actually pay off — pattern matching for switch and record patterns turn a sealed hierarchy of records into a switch the compiler checks for completeness, replacing the instanceof chains this series' own Java 17 article relied on. Sequenced Collections close a long-standing gap by giving List, Deque, LinkedHashSet, and LinkedHashMap a shared, uniform way to work with first and last elements, and virtual threads change the calculus for how much concurrent blocking work a single JVM can handle.
The habit worth carrying forward from this article's booking-result rewrite is reaching for pattern matching for switch the moment a sealed hierarchy is involved, keeping each when guard to one clear condition, and never assuming a guarded case alone satisfies exhaustiveness or handles a null selector.
What to Read Next
Learn how var lets Java infer a variable's type for you.