Java 21: the LTS that made threads cheap
Java 21 is the long-term support release where virtual threads finally became permanent. Here is what it actually changes, with code you can run, explained so a junior developer or a student can follow along.
Java 21 is the long-term support release, out September 19, 2023. It is the first LTS on the new two year cycle, and the version most teams moved to from Java 17 or Java 11. This post shows what it actually changes, with code you can run. If you are a junior developer or a student, this is written for you.
1. What "LTS" means, and why it matters#
Java ships a new version every six months, but most get only six months of support. Every two years, one version is marked Long-Term Support (LTS) and gets years of security fixes. Java 21 is an LTS, with Premier Support until September 2028. The previous LTS was Java 17 (September 2021); the next is Java 25 (September 2027). Java 22, 23 and 24 are short-term releases. Teams that run production systems pick an LTS and stay on it.
A feature is final when it is permanent and safe to use. A preview feature is runnable but can still change, so do not use it in production. An incubator feature is even earlier. I label every preview and incubator feature below.
2. The big picture#
Java 21 has one headline feature, virtual threads, and it is a big one. The rest of the release is the moment several features that previewed during Java 17 to 20 finally become permanent: record patterns, pattern matching for switch, and sequenced collections. If you are coming from Java 17, you are not learning a new language. You are picking up the features that make everyday code shorter and servers cheaper to run.
3. Virtual threads (JEP 444)#
The headline. A virtual thread is a cheap thread managed by the JVM instead of the operating system, so you can have a thread per request instead of a thread pool.
Before, a server handled each request on a platform thread from a fixed pool. When all 100 threads were busy, the 101st request waited. You tuned the pool size, you worried about blocking, and you wrote async code to get around it.
Before (Java 17):
try (var executor = Executors.newFixedThreadPool(100)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
// handle one request
});
}
}
After (Java 21):
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
// handle one request
});
}
}
The code looks almost the same, but the behaviour is different. A virtual thread is so cheap that you can create one per task, even 10,000 of them, and the JVM schedules them onto a small number of platform threads. When a virtual thread blocks on a network call or a database query, the JVM parks it and runs another one. You write simple blocking code, the way you always did, and it scales.
The shortest form is one line:
Thread.ofVirtual().start(() -> {
// do the work
});
Use it for servers, for anything that waits on a network or a database, and for any code that fans out into many small tasks. Do not use it for CPU heavy work, a virtual thread is no faster than a platform thread at crunching numbers. And do not pool virtual threads, creating one is cheap enough that a pool just adds overhead.
4. Record patterns (JEP 440)#
You can take a record apart into its parts right inside an instanceof check or a switch, instead of calling each accessor by hand.
Before (Java 17):
record Point(int x, int y) {}
if (obj instanceof Point p) {
int x = p.x();
int y = p.y();
System.out.println(x + ", " + y);
}
After (Java 21):
if (obj instanceof Point(int x, int y)) {
System.out.println(x + ", " + y);
}
The pattern Point(int x, int y) pulls the two components straight out of the record. It works in a switch too, and it nests, so Point(Line(Point(int x1, int y1), Point(int x2, int y2))) is valid. Use it whenever you check a type and then immediately read its fields.
5. Pattern matching for switch (JEP 441)#
A switch can now match on the type of the value, with guards and a null case, and the compiler checks that you covered every possibility.
Before (Java 17):
String result;
if (obj instanceof String s) {
result = "String: " + s;
} else if (obj instanceof Integer i) {
result = "Integer: " + i;
} else {
result = "Something else";
}
After (Java 21):
String result = switch (obj) {
case String s -> "String: " + s;
case Integer i -> "Integer: " + i;
case null -> "It is null";
default -> "Something else";
};
Three things to notice. First, the switch returns a value, so it is an expression, not a statement. Second, case null is a real case now, before it a null fell through to default or threw a NullPointerException. Third, the compiler checks exhaustiveness, so if you switch over a sealed type and miss a subtype, it refuses to compile instead of failing at runtime.
Add a guard with when:
String label = switch (obj) {
case String s when s.length() > 10 -> "A long string";
case String s -> "A short string";
case Integer i -> "A number";
default -> "Something else";
};
Use it anywhere you have a chain of instanceof checks. It is shorter, and the compiler catches the case you forgot.
6. Sequenced collections (JEP 431)#
Every ordered collection now has the same methods for its first and last elements, so you stop doing index math.
Before (Java 17):
List<String> list = List.of("a", "b", "c");
String first = list.get(0);
String last = list.get(list.size() - 1);
After (Java 21):
String first = list.getFirst();
String last = list.getLast();
List<String> reversed = list.reversed();
List, Set and Map all got getFirst(), getLast(), addFirst(), addLast() and reversed(). The methods work on the collection type you already have, so a LinkedHashSet and an ArrayList behave the same way. reversed() returns a view, not a copy, so changes to one show up in the other. Use it whenever you reach for index 0 or size() - 1.
7. Generational ZGC (JEP 439)#
The ZGC garbage collector now splits objects into young and old generations, so it collects short lived objects faster and uses less memory. There is no code to write. It is a JVM flag, and generational mode is the default when you turn ZGC on:
java -XX:+UseZGC -jar app.jar
ZGC aims for pause times of a few milliseconds even with a huge heap. Generational mode makes it cheaper, because most objects die young and never need to be moved to the old generation. Use it for large servers where a long garbage collection pause would be visible to users.
8. Key encapsulation mechanism API (JEP 452)#
A standard way for two sides to agree on a secret key without ever sending the key itself. This is the building block for post quantum safe cryptography, the kind that stays secure even against a quantum computer.
KEM kem = KEM.getInstance("DHKEM");
KeyPairGenerator kpg = KeyPairGenerator.getInstance("X25519");
KeyPair kp = kpg.generateKeyPair();
// Sender: wrap a fresh secret key for the receiver
KEM.Encapsulator sender = kem.newEncapsulator(kp.getPublic());
KEM.Encapsulated enc = sender.encapsulate();
SecretKey sharedSecret = enc.key();
byte[] ciphertext = enc.encapsulation();
// Receiver: unwrap it with the private key
KEM.Decapsulator receiver = kem.newDecapsulator(kp.getPrivate());
SecretKey sameSecret = receiver.decapsulate(ciphertext);
The sender creates a random secret key, wraps it so only the receiver can open it, and sends the wrapped version. The receiver unwraps it and gets the same secret key. Neither side ever sends the key in the clear. You will not use this directly in most applications, but the libraries you use for TLS and secure messaging will.
9. String templates (JEP 430, preview)#
You can embed values directly inside a string, instead of concatenating or using String.format. This one is a preview, so it needs --enable-preview, and you should know it did not survive.
String name = "Asha";
String message = STR."Hello, \{name}!";
It went through a second preview in Java 22 and was withdrawn in Java 23, so it is not in Java 25. The idea is still good, and a replacement may come back in a later release, but do not build on it today.
10. Structured concurrency (JEP 453, preview)#
A group of subtasks runs inside a scope, and the scope waits for all of them and cancels the rest if one fails, so you stop managing threads by hand. This one is also a preview, and it became final in Java 25 with a slightly different API.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser());
Subtask<Integer> orders = scope.fork(() -> fetchOrders());
scope.join().throwIfFailed();
// use user.get() and orders.get()
}
The point is that the lifetime of the subtasks is tied to the scope. When the scope closes, every subtask is done or cancelled, so nothing leaks. Pair it with virtual threads: fork a virtual thread per subtask, and the scope keeps them all under control. If you want the final version, read the Java 25 post, where it is JEP 505.
11. What Java 21 picks up from 18, 19, and 20#
Most of Java 21 is the moment features from the short-term releases become permanent. Virtual threads previewed in Java 19 and 20. Record patterns previewed in Java 19 and 20. Pattern matching for switch previewed in Java 17, 18, 19 and 20. Structured concurrency was an incubator in Java 19 and 20, and a preview in 21. Scoped values were an incubator in Java 20, and a preview in 21 (JEP 446); they became final in Java 25.
New in Java 21 itself: sequenced collections, generational ZGC, the KEM API, and the string templates and unnamed classes previews.
12. The removal: what to watch when you upgrade#
Two things to know if you are moving from Java 17. First, the Windows 32-bit x86 port is deprecated for removal (JEP 449). It was removed in Java 25, so if you still run on 32-bit Windows, plan the move now.
Second, Java 21 starts warning when an agent is loaded into a running JVM (JEP 451). In later releases that becomes an error unless you pass -XX:+EnableDynamicAgentLoading. If you use a profiling or monitoring agent that attaches at runtime, check that it supports the flag.
13. Should you upgrade#
If you are on Java 17, yes. Java 21 is the LTS, it is supported until September 2028, and virtual threads alone are worth the move for any server. If you are on Java 11 or Java 8, the jump is bigger, and the Java 17 and Java 11 posts in this series cover the steps in between.
The next post in this series is Java 17, the LTS that most teams upgraded to from Java 8 or 11. Read the Java 25 post for the newest LTS, and the Java 17 post for the one before this.
14. The series#
- Java 8: the release that changed Java forever
- Java 11: the LTS that made teams leave Java 8
- Java 17: the LTS that locked the language down
- Java 21: virtual threads, record patterns, and pattern matching for switch (this post)
- Java 25: the new LTS, explained for everyone
