Java 21 LTS Features
Java 21, released in September 2023, is the most feature-rich LTS release since Java 8. It graduates several preview features into production-ready APIs, introduces a fundamentally new concurrency model, and adds powerful language constructs that make everyday Java code more expressive and safe.
Note: Java 21 is the current LTS release and the recommended version for production use. It will receive free support updates until at least September 2028.
What Landed in Java 21
Here is a quick overview of the finalized (non-preview) features covered on this page:
| JEP | Feature | Category |
|---|---|---|
| JEP 444 | Virtual Threads | Concurrency |
| JEP 441 | Pattern Matching for switch | Language |
| JEP 440 | Record Patterns | Language |
| JEP 431 | Sequenced Collections | Library |
| JEP 439 | Generational ZGC | Runtime |
| JEP 430 | String Templates (preview) | Language |
| JEP 453 | Structured Concurrency (preview) | Concurrency |
| JEP 446 | Scoped Values (preview) | Concurrency |
Preview features require --enable-preview at compile and runtime. The finalized features work out of the box.
Virtual Threads (JEP 444)
Virtual Threads are the headline feature of Java 21. They let you write plain, sequential blocking code that scales to hundreds of thousands of concurrent tasks — without reactive programming, callbacks, or manual CompletableFuture chains.
The core idea: virtual threads are lightweight threads managed by the JVM rather than the OS. Blocking a virtual thread (on I/O, Thread.sleep(), or a lock) does not waste an OS thread — the JVM suspends the virtual thread, frees its carrier thread, and resumes it later.
import java.util.concurrent.Executors;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// newVirtualThreadPerTaskExecutor is the easiest migration path
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
exec.submit(() -> {
Thread.sleep(100); // blocking is fine — no OS thread wasted
System.out.println("Task " + taskId + " on " + Thread.currentThread());
return null;
});
}
} // auto-closes, waits for all tasks
}
}
You can also create a single virtual thread directly:
Thread vt = Thread.ofVirtual().name("my-vt").start(() ->
System.out.println("Is virtual? " + Thread.currentThread().isVirtual())
);
vt.join();
Output:
Is virtual? true
Tip: Virtual threads shine on I/O-bound workloads (database queries, HTTP calls, file reads). For pure CPU-bound computation, stick with platform threads or a
ForkJoinPool.
See the full Virtual Threads (Project Loom) page for carrier threads, pinning pitfalls, and migration guidance.
Pattern Matching for switch (JEP 441)
Switch pattern matching, previewed across Java 17–20, is now fully standard. You can match any object against type patterns in a switch expression or statement, with optional when guards and null handling.
static String format(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "Negative: " + i;
case Integer i -> "Positive integer: " + i;
case Double d -> String.format("Double: %.2f", d);
case String s -> "String[" + s.length() + "]: " + s;
case null -> "(null)";
default -> "Other: " + obj.getClass().getSimpleName();
};
}
public class Main {
public static void main(String[] args) {
System.out.println(format(-5));
System.out.println(format(3.14));
System.out.println(format("hello"));
System.out.println(format(null));
}
}
Output:
Negative: -5
Double: 3.14
String[5]: hello
(null)
When the switch selector is a sealed class or interface, the compiler verifies exhaustiveness — you do not need a default if every permitted subtype is covered:
sealed interface Expr permits Num, Add {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
static int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
// No default — compiler confirms all cases covered
};
}
Warning: Pattern cases are matched top-to-bottom. Place more specific patterns before broader ones, or the compiler will reject the unreachable arms.
Record Patterns (JEP 440)
Record patterns let you destructure a record directly inside instanceof or switch, binding the components in one step:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
static void describePoint(Object obj) {
if (obj instanceof Point(int x, int y)) {
System.out.printf("Point at (%d, %d)%n", x, y);
}
}
// Nested record patterns — destructure two levels deep
static void describeLine(Object obj) {
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
System.out.printf("Line from (%d,%d) to (%d,%d)%n", x1, y1, x2, y2);
}
}
public class Main {
public static void main(String[] args) {
describePoint(new Point(3, 7));
describeLine(new Line(new Point(0, 0), new Point(5, 10)));
}
}
Output:
Point at (3, 7)
Line from (0,0) to (5,10)
Combined with a sealed type and switch, record patterns replace long if-instanceof-cast chains with clean, type-safe dispatch. See Pattern Matching for the complete guide including when guards and dominance rules.
Sequenced Collections (JEP 431)
Before Java 21, there was no consistent way to access the first or last element of a collection. List had get(0) and get(list.size()-1). LinkedHashSet had no direct first/last API at all. Deque had peekFirst()/peekLast(). Every type was different.
Java 21 adds three new interfaces to java.util:
| Interface | Extends | Adds |
|---|---|---|
SequencedCollection<E> | Collection<E> | getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast(), reversed() |
SequencedSet<E> | Set<E>, SequencedCollection<E> | (inherits all above) |
SequencedMap<K,V> | Map<K,V> | firstEntry(), lastEntry(), putFirst(), putLast(), sequencedKeySet(), sequencedValues(), sequencedEntrySet(), reversed() |
Using SequencedCollection
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
public class SequencedDemo {
public static void main(String[] args) {
// List implements SequencedCollection
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
System.out.println("First: " + list.getFirst()); // a
System.out.println("Last: " + list.getLast()); // c
list.addFirst("z");
System.out.println("After addFirst: " + list); // [z, a, b, c]
// reversed() returns a view — no copy made
System.out.println("Reversed: " + list.reversed()); // [c, b, a, z]
// LinkedHashSet also implements SequencedSet
var set = new LinkedHashSet<>(List.of("x", "y", "z"));
System.out.println("Set first: " + set.getFirst()); // x
System.out.println("Set last: " + set.getLast()); // z
}
}
Output:
First: a
Last: c
After addFirst: [z, a, b, c]
Reversed: [c, b, a, z]
Set first: x
Set last: z
Using SequencedMap
import java.util.LinkedHashMap;
public class SequencedMapDemo {
public static void main(String[] args) {
var map = new LinkedHashMap<String, Integer>();
map.put("one", 1);
map.put("two", 2);
map.put("three", 3);
System.out.println("First entry: " + map.firstEntry()); // one=1
System.out.println("Last entry: " + map.lastEntry()); // three=3
// reversed() gives a reversed view
map.reversed().forEach((k, v) -> System.out.println(k + "=" + v));
}
}
Output:
First entry: one=1
Last entry: three=3
three=3
two=2
one=1
Tip:
reversed()on bothSequencedCollectionandSequencedMapreturns a live view backed by the original. Mutating the original is reflected in the reversed view and vice versa.
Generational ZGC (JEP 439)
ZGC has been production-ready since Java 15, but Java 21 gives it a significant upgrade: Generational ZGC. It now tracks young and old object generations separately, which dramatically reduces the amount of work needed per GC cycle.
Enable it with:
java -XX:+UseZGC -XX:+ZGenerational MyApp
Note: In Java 21, Generational ZGC is opt-in. Starting from Java 23, it became the default ZGC mode. The non-generational ZGC mode is deprecated and will eventually be removed.
The practical impact: lower memory overhead, shorter pauses, and better throughput for applications with high object allocation rates — typical for web servers and data processing pipelines. See Garbage Collection Deep-Dive for the full picture.
String Templates (JEP 430 — Preview)
String Templates extend Java’s string literals with template expressions that can embed logic inline, processed by a template processor. They are safer than String.format and more powerful than interpolation in other languages.
// Requires --enable-preview in Java 21
import static java.lang.StringTemplate.STR;
public class StringTemplateDemo {
public static void main(String[] args) {
String name = "Alice";
int score = 98;
String msg = STR."Hello, \{name}! Your score is \{score}.";
System.out.println(msg);
// Expressions (not just variables) are supported
String result = STR."2 + 2 = \{2 + 2}";
System.out.println(result);
}
}
Output:
Hello, Alice! Your score is 98.
2 + 2 = 4
The STR processor simply interpolates and returns a String. You can write custom processors (e.g., one that safely builds SQL without injection risks). This is why string templates are more than syntactic sugar.
Warning: String Templates are a preview feature in Java 21. The API changed significantly in Java 22 and was eventually withdrawn in Java 23 for redesign. Do not use
STRin production code until the feature is re-finalized.
Structured Concurrency (JEP 453 — Preview)
Structured Concurrency treats a group of related tasks as a single unit of work. If any task fails, the others are cancelled automatically — no leaked threads, no manual bookkeeping.
// Requires --enable-preview
import java.util.concurrent.StructuredTaskScope;
public class StructuredDemo {
static String fetchUser(int id) throws InterruptedException {
Thread.sleep(50);
return "User-" + id;
}
static int fetchOrderCount(int id) throws InterruptedException {
Thread.sleep(30);
return 5;
}
public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> fetchUser(1));
var orderTask = scope.fork(() -> fetchOrderCount(1));
scope.join(); // wait for both subtasks
scope.throwIfFailed(); // re-throw if either threw
System.out.println(userTask.get() + " has " + orderTask.get() + " orders");
}
}
}
Output:
User-1 has 5 orders
If fetchUser throws, the scope immediately cancels fetchOrderCount and propagates the exception. The lifetime of the subtasks is strictly nested within the try block — this is the “structured” part.
Scoped Values (JEP 446 — Preview)
Scoped Values are the modern, safer alternative to ThreadLocal for passing context through a call tree — especially with virtual threads.
// Requires --enable-preview
public class ScopedValueDemo {
static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
static void processRequest() {
System.out.println("Processing for: " + CURRENT_USER.get());
}
public static void main(String[] args) {
ScopedValue.where(CURRENT_USER, "alice").run(() -> {
processRequest(); // sees "alice"
ScopedValue.where(CURRENT_USER, "bob").run(() -> {
processRequest(); // sees "bob" — inner scope shadows outer
});
processRequest(); // back to "alice"
});
}
}
Output:
Processing for: alice
Processing for: bob
Processing for: alice
Unlike ThreadLocal, scoped values are:
- Immutable within a scope — no accidental mutation from child threads
- Automatically cleaned up when the scope exits
- Efficiently inherited by virtual threads forked inside
StructuredTaskScope
Under the Hood
Virtual Threads and the Carrier Model
Virtual threads run on top of a small ForkJoinPool of platform threads called carriers. When a virtual thread blocks, the JVM unmounts it from its carrier (saving its stack to the heap as a lightweight object), freeing the carrier to run another virtual thread. When the blocking operation completes, the virtual thread is rescheduled onto any available carrier. This “park on heap, run anywhere” design is what makes millions of concurrent virtual threads practical.
Pattern Switch Dispatch
Pattern-matching switch compiles to an invokedynamic instruction backed by java.lang.runtime.SwitchBootstraps. The bootstrap generates an efficient dispatch table the first time the switch executes; subsequent calls are fast. Record pattern deconstruction compiles to regular accessor method calls (x(), y(), etc.) that the JIT can inline to zero-overhead field reads.
Sequenced Collections in the Type Hierarchy
SequencedCollection is inserted into the existing hierarchy without breaking backward compatibility. List, Deque, LinkedHashSet, and SortedSet all now extend it. The Collections.unmodifiableList() / synchronizedList() wrappers were updated to delegate the new methods, so existing code using those wrappers gets the new API for free.
Generational ZGC — Why Generations Help
Most objects die young (the generational hypothesis). A generational collector focuses most of its work on the young generation, which is small and fast to collect. ZGC’s original design collected all objects at every cycle, which was thorough but wasteful. Generational ZGC separates the heap into a young region and an old region, collecting the young region far more frequently. This reduces CPU overhead and memory retained by the GC itself, cutting typical GC CPU usage by 50% or more compared to non-generational ZGC.
Java 21 Feature Comparison Table
| Feature | Status in Java 21 | Finalized In |
|---|---|---|
| Virtual Threads | Final | Java 21 |
| Pattern Matching for switch | Final | Java 21 |
| Record Patterns | Final | Java 21 |
| Sequenced Collections | Final | Java 21 |
| Generational ZGC | Final (opt-in) | Java 21 |
| Structured Concurrency | Preview | Java 21 (evolving) |
| Scoped Values | Preview | Java 21 (evolving) |
| String Templates | Preview | Java 21 (withdrawn in 23) |
Unnamed Patterns & Variables (_) | Preview | Java 22 |
Related Topics
- Virtual Threads (Project Loom) — the full deep-dive into carriers, pinning, structured concurrency, and migration tips
- Pattern Matching — complete guide to type patterns, record patterns, guarded patterns, and exhaustiveness rules
- Records — understand records before using record patterns in switch expressions
- Sealed Classes — sealed hierarchies unlock exhaustive switch expressions with no
defaultneeded - Java 17 LTS Features — the previous LTS where sealed classes and records were finalized
- Garbage Collection Deep-Dive — understand ZGC generations, G1, and how to choose the right collector