Java 21 Features
Java 21 is a landmark long-term-support release. Virtual threads make highly concurrent code simple, record patterns and pattern matching for switch make data handling safer, and sequenced collections tidy up everyday code. Learn each feature with clear explanations and runnable examples.
- Virtual ThreadsMillions of cheap threads
- Record PatternsDeconstruct data
- Switch PatternsType-safe branching
- Sequenced CollectionsgetFirst, getLast, reversed
All Java 21 Features at a Glance
The final features of Java 21, the previews you can try, and the platform changes since Java 17.
Virtual Threads
Millions of lightweight threads for blocking code.
Record Patterns
Deconstruct records directly into variables.
Pattern Matching for switch
Switch on types, with guards and null handling.
Sequenced Collections
First, last and reversed for every ordered collection.
New String, Math and Character Methods
Small additions that remove boilerplate.
Preview Features in Java 21
Unnamed variables, instance main methods and more.
JVM and Platform Changes
Generational ZGC, UTF-8 by default and a built-in web server.
Virtual Threads
Virtual threads (JEP 444) are the biggest change in Java 21. They are lightweight threads managed by the JVM instead of the operating system, so you can create one per task, even millions. When a virtual thread blocks on I/O, the JVM parks it and frees the carrier thread, giving high throughput with simple, readable blocking code.
- Create with
Thread.ofVirtual().start(...)orThread.startVirtualThread Executors.newVirtualThreadPerTaskExecutor(): one virtual thread per task- Best for I/O-bound work such as web requests and database calls
- Do not pool virtual threads; create a new one for each task
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// Start a single virtual thread
Thread vt = Thread.ofVirtual().name("worker-1").start(() -> {
Thread t = Thread.currentThread();
System.out.println("Running in " + t.getName()
+ ", virtual = " + t.isVirtual());
});
vt.join();
// One virtual thread per task: 10,000 tasks that each block 1 second
long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
results.add(executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // e.g. a slow I/O call
return id;
}));
}
long sum = 0;
for (Future<Integer> f : results) {
sum += f.get();
}
System.out.println("Tasks completed: " + results.size());
System.out.println("Sum of task ids: " + sum);
} // close() waits for all tasks to finish
long seconds = Duration.ofNanos(System.nanoTime() - start).toSeconds();
// With 10,000 platform threads this would need huge memory;
// virtual threads finish in about one second
System.out.println("Finished in under 5 seconds: " + (seconds < 5));
}
}
Running in worker-1, virtual = true Tasks completed: 10000 Sum of task ids: 49995000 Finished in under 5 seconds: true
Record Patterns
Record patterns (JEP 440) let you take a record apart while you test its type. Instead of matching a Point and then calling x() and y(), you write obj instanceof Point(int x, int y). Patterns can be nested to reach records inside records.
- Works in
instanceofand inswitch - Nested patterns:
Line(Point(var x1, var y1), Point p2) varcan be used for component types- Combine with
whenguards for extra conditions
public class RecordPatternDemo {
record Point(int x, int y) { }
record Line(Point start, Point end) { }
enum Color { RED, GREEN }
record ColoredPoint(Point point, Color color) { }
static void print(Object obj) {
// Before: instanceof, then call accessors one by one
// Java 21: deconstruct the record directly into variables
if (obj instanceof Point(int x, int y)) {
System.out.println("Point at x=" + x + ", y=" + y);
}
// Nested patterns reach inside records within records
if (obj instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
double length = Math.hypot(x2 - x1, y2 - y1);
System.out.println("Line of length " + length);
}
}
static String describe(Object obj) {
// Record patterns also work in switch
return switch (obj) {
case ColoredPoint(Point(int x, int y), Color c) when x == y ->
c + " point on the diagonal";
case ColoredPoint(Point p, Color c) -> c + " point " + p;
default -> "Not a colored point";
};
}
public static void main(String[] args) {
print(new Point(3, 4));
print(new Line(new Point(0, 0), new Point(3, 4)));
var red = new ColoredPoint(new Point(2, 2), Color.RED);
var green = new ColoredPoint(new Point(1, 5), Color.GREEN);
System.out.println(describe(red));
System.out.println(describe(green));
System.out.println(describe("hello"));
}
}
Point at x=3, y=4 Line of length 5.0 RED point on the diagonal GREEN point Point[x=1, y=5] Not a colored point
Pattern Matching for switch
Pattern matching for switch (JEP 441) lets a case match a type, not only a constant. Add a when guard for extra conditions and a case null to handle nulls. With a sealed hierarchy, the compiler checks that every subtype is covered, so no default is needed.
case Card c when c.amount() > 50_000 -> ...case nullinstead of a separate null check- Exhaustive for sealed types: adding a subtype breaks the build, not production
- More specific cases must come before general ones
public class SwitchPatternDemo {
sealed interface Payment permits Card, Upi, Cash { }
record Card(String number, double amount) implements Payment { }
record Upi(String id, double amount) implements Payment { }
record Cash(double amount) implements Payment { }
// No default needed: the compiler knows every Payment type
static String process(Payment payment) {
return switch (payment) {
case Card c when c.amount() > 50_000 ->
"Card payment needs OTP: " + c.amount();
case Card c -> "Card ending " + c.number().substring(12);
case Upi u -> "UPI to " + u.id();
case Cash cash -> "Cash " + cash.amount();
};
}
// Switch on any object, including a null case
static String format(Object obj) {
return switch (obj) {
case null -> "null value";
case Integer i when i < 0 -> "negative int " + i;
case Integer i -> "int " + i;
case String s when s.isBlank() -> "blank string";
case String s -> "string \"" + s + "\"";
case int[] arr -> "int array of " + arr.length;
default -> "other: " + obj.getClass().getSimpleName();
};
}
public static void main(String[] args) {
System.out.println(process(new Card("4111222233334444", 1_200)));
System.out.println(process(new Card("4111222233334444", 75_000)));
System.out.println(process(new Upi("asha@bank", 499)));
System.out.println(process(new Cash(100)));
Object[] values = { 42, -7, "Java 21", " ", new int[3], 2.5, null };
for (Object v : values) {
System.out.println(format(v));
}
}
}
Card ending 4444 Card payment needs OTP: 75000.0 UPI to asha@bank Cash 100.0 int 42 negative int -7 string "Java 21" blank string int array of 3 other: Double null value
Sequenced Collections
Java 21 (JEP 431) added the SequencedCollection, SequencedSet and SequencedMap interfaces. Lists, deques, LinkedHashSet, TreeSet, LinkedHashMap and TreeMap now share one API to access both ends and to get a reversed view.
getFirst(),getLast(),addFirst(),removeLast()reversed()returns a reversed view, not a copy- Maps:
firstEntry(),lastEntry(),putFirst() - Replaces
list.get(list.size() - 1)
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedMap;
import java.util.SequencedSet;
public class SequencedDemo {
public static void main(String[] args) {
// SequencedCollection: one API for first and last elements
List<String> list = new ArrayList<>(List.of("B", "C"));
list.addFirst("A");
list.addLast("D");
System.out.println("list : " + list);
System.out.println("getFirst() : " + list.getFirst());
System.out.println("getLast() : " + list.getLast());
System.out.println("reversed() : " + list.reversed());
// Before Java 21: list.get(list.size() - 1)
SequencedSet<Integer> set = new LinkedHashSet<>(List.of(10, 20, 30));
System.out.println("set first : " + set.getFirst());
System.out.println("removeLast : " + set.removeLast() + " -> " + set);
// SequencedMap: entries in a defined order
SequencedMap<String, Integer> scores = new LinkedHashMap<>();
scores.put("Ravi", 72);
scores.put("Meera", 91);
scores.putFirst("Asha", 85);
System.out.println("map : " + scores);
System.out.println("firstEntry : " + scores.firstEntry());
System.out.println("lastEntry : " + scores.lastEntry());
System.out.println("reversed : " + scores.reversed());
}
}
list : [A, B, C, D]
getFirst() : A
getLast() : D
reversed() : [D, C, B, A]
set first : 10
removeLast : 30 -> [10, 20]
map : {Asha=85, Ravi=72, Meera=91}
firstEntry : Asha=85
lastEntry : Meera=91
reversed : {Meera=91, Ravi=72, Asha=85}New String, Math and Character Methods
Java 21 also added handy methods to core classes. StringBuilder.repeat appends text several times, String.splitWithDelimiters keeps the separators, Math.clamp limits a value to a range and Character.isEmoji recognises emoji.
StringBuilder.repeat(CharSequence, int)String.indexOf(str, beginIndex, endIndex)Math.clamp(value, min, max)HashMap.newHashMap(n)(Java 19) creates a map sized for n entries
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
public class NewApiDemo {
public static void main(String[] args) {
// StringBuilder.repeat (Java 21)
StringBuilder sb = new StringBuilder("ab");
sb.repeat("-", 5).repeat('*', 3);
System.out.println("repeat() : " + sb);
// String.indexOf within a range (Java 21)
String text = "java,spring,kafka,java";
System.out.println("indexOf(range) : " + text.indexOf("java", 1, 22));
// String.splitWithDelimiters keeps the separators (Java 21)
String expr = "10+20-5";
System.out.println("splitWithDelims : "
+ Arrays.toString(expr.splitWithDelimiters("[+-]", 0)));
// Math.clamp keeps a value within a range (Java 21)
System.out.println("clamp(150,0,100) : " + Math.clamp(150, 0, 100));
System.out.println("clamp(-8,0,100) : " + Math.clamp(-8, 0, 100));
// Character.isEmoji (Java 21)
int rocket = "🚀".codePointAt(0);
System.out.println("isEmoji(rocket) : " + Character.isEmoji(rocket));
System.out.println("isEmoji('A') : " + Character.isEmoji('A'));
// HashMap.newHashMap sizes the map for n entries (Java 19)
Map<String, Integer> map = HashMap.newHashMap(100);
map.put("users", 100);
System.out.println("newHashMap(100) : " + map);
}
}
repeat() : ab-----***
indexOf(range) : 18
splitWithDelims : [10, +, 20, -, 5]
clamp(150,0,100) : 100
clamp(-8,0,100) : 0
isEmoji(rocket) : true
isEmoji('A') : false
newHashMap(100) : {users=100}Preview Features in Java 21
Java 21 also included preview features that you can try with --enable-preview. Unnamed patterns and variables (_) let you ignore values you don't need, and unnamed classes with an instance main make small programs much shorter. String templates, scoped values and structured concurrency were also previews.
_for unused variables and pattern components (final in Java 22)void main()without a class orstatic(final in Java 25)- String templates were previewed and later withdrawn
- Scoped values and structured concurrency for concurrent code
// Preview features in Java 21 (final in later releases).
// Run with: java --enable-preview --source 21 HelloPreview.java
import java.util.List;
record Order(String item, int qty) { }
// Unnamed class with an instance main method: no class, no static
void main() {
List<Object> orders = List.of(new Order("Book", 2), new Order("Pen", 5));
int total = 0;
for (Object o : orders) {
// Unnamed pattern: _ ignores the component we don't need
if (o instanceof Order(_, int qty)) {
total += qty;
}
}
System.out.println("Total quantity: " + total);
// Unnamed variable: _ for a value we never use
int count = 0;
for (var _ : orders) {
count++;
}
System.out.println("Orders: " + count);
}
$ java --enable-preview --source 21 HelloPreview.java Total quantity: 7 Orders: 2
JVM and Platform Changes
Since Java 17 the platform has improved too. ZGC became generational for better throughput, UTF-8 became the default charset everywhere (Java 18), and the JDK gained a simple web server, jwebserver. Check the deprecations below when upgrading.
- Generational ZGC: low pauses with less CPU and memory
- UTF-8 by default, so text files behave the same on every OS
jwebserverfor quick local testing- New KEM cryptography API; finalization deprecated for removal
# Generational ZGC (JEP 439): better throughput, low pauses
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
# Java 18: UTF-8 is the default charset on every OS
# (files, readers and writers no longer depend on the platform)
# Java 18: a simple web server for static files and testing
jwebserver --port 8000 --directory ./site
# Java 21: Key Encapsulation Mechanism API (javax.crypto.KEM)
# Java 21: warning when an agent is loaded into a running JVM
# Deprecated: finalization (Java 18), 32-bit Windows port (Java 21)
Beyond Java 21
Java keeps a six-month release cycle. These are the highlights of the releases after Java 21:
| Version | Key additions |
|---|---|
| Java 22 | Unnamed variables (_), Foreign Function & Memory API, multi-file source launch |
| Java 23 | Markdown documentation comments, primitive patterns (preview) |
| Java 24 | Stream gatherers, class-file API, virtual threads without pinning on synchronized |
| Java 25 (LTS) | Compact source files and instance main, flexible constructor bodies, scoped values |
| Java 26 | HTTP/3 support in the HTTP Client, further preview features |