Java 16 to 21 New Features
After looking into the new features in Java 16, let’s jump to the next LTS with Java 21. Instead of covering every intermediate release one by one, this post focuses on the big features you get when moving from Java 16 to Java 21.
JEP 409: Sealed Classes
This feature entered as preview in Java 15/16 and is now fully supported since Java 17.
A sealed class or interface can be extended or implemented only by those classes and interfaces permitted to do so:
public abstract sealed class Shape permits Circle, Rectangle, Square { }
public final class Circle extends Shape { }
public final class Rectangle extends Shape { }
public final class Square extends Shape { }
Previously, without permits, anything could extend Shape, so exhaustive handling usually needed a fallback:
int area(Shape shape) {
if (shape instanceof Circle c) {
return ...;
} else if (shape instanceof Rectangle r) {
return ...;
} else if (shape instanceof Square s) {
return ...;
}
throw new IllegalArgumentException("Unknown shape: " + shape);
}
With sealed hierarchies, the compiler knows the full set of subtypes, which pairs really well with pattern matching for switch (see below).
JEP 441: Pattern Matching for switch
Another super enhancement for day-to-day Java! Before Java 21, multi-type checks usually looked like a chain of if / else if:
static String formatter(Object obj) {
String formatted = "unknown";
if (obj instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (obj instanceof Long l) {
formatted = String.format("long %d", l);
} else if (obj instanceof Double d) {
formatted = String.format("double %f", d);
} else if (obj instanceof String s) {
formatted = String.format("String %s", s);
}
return formatted;
}
Now, we can simply use patterns directly in switch:
static String formatter(Object obj) {
return switch (obj) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> obj.toString();
};
}
We can also handle null inside the switch, and use guards with when:
static void test(Object obj) {
switch (obj) {
case String s when s.length() > 5 -> System.out.println("Long string: " + s);
case String s -> System.out.println("Short string: " + s);
case null -> System.out.println("Oops, null!");
default -> System.out.println("Something else");
}
}
JEP 440: Record Patterns
Records were finalized in Java 16, and Java 21 completes the story by letting us deconstruct them in instanceof and switch.
Before, we had to extract components manually:
record Point(int x, int y) { }
static void printSum(Object obj) {
if (obj instanceof Point p) {
int x = p.x();
int y = p.y();
System.out.println(x + y);
}
}
Now, we can do simply:
static void printSum(Object obj) {
if (obj instanceof Point(int x, int y)) {
System.out.println(x + y);
}
}
And nested deconstruction works great with switch:
record Point(int x, int y) { }
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point p, Color c) { }
static void printColor(Object obj) {
if (obj instanceof ColoredPoint(Point(int x, int y), Color c)) {
System.out.println(c + " at " + x + "," + y);
}
}
JEP 444: Virtual Threads
This is probably the biggest runtime change in the jump to Java 21.
Before, if we wanted massive concurrency with blocking I/O, we usually depended on a limited thread pool:
try (var executor = Executors.newFixedThreadPool(200)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
// blocking I/O
Thread.sleep(Duration.ofSeconds(1));
return "done";
});
}
}
Scaling that further meant more platform threads, more memory, and more operational pain.
Now, we can create cheap virtual threads instead:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return "done";
});
}
}
Or create one directly:
Thread.startVirtualThread(() -> {
System.out.println("Running on " + Thread.currentThread());
});
Virtual threads are still Threads from the API point of view, so existing code often keeps working. The big win is writing simple blocking style code that scales much better.
JEP 431: Sequenced Collections
Collections finally get a common API for “first” and “last” elements.
Before, depending on the type, we had to do different things:
List<String> list = List.of("a", "b", "c");
String first = list.get(0);
String last = list.get(list.size() - 1);
Deque<String> deque = new ArrayDeque<>(list);
String firstFromDeque = deque.getFirst();
String lastFromDeque = deque.getLast();
LinkedHashSet<String> set = new LinkedHashSet<>(list);
String firstFromSet = set.iterator().next(); // no clean "last"
Now, sequenced collections unify this:
SequencedCollection<String> list = new ArrayList<>(List.of("a", "b", "c"));
list.getFirst(); // "a"
list.getLast(); // "c"
list.addFirst("z");
list.addLast("y");
list.reversed(); // view in reverse order
There are also SequencedSet and SequencedMap with the same idea for encounter order.
JEP 439: Generational ZGC
ZGC becomes generational in Java 21. Young objects are collected more frequently, which usually means lower allocation pressure and better throughput while keeping the low-pause behavior that made ZGC attractive.
Enable it with:
-XX:+UseZGC -XX:+ZGenerational
JEP 403: Strongly Encapsulate JDK Internals
Since Java 17, strong encapsulation of JDK internals is the default. Accessing internal APIs like sun.misc.Unsafe (outside the supported critical paths) or other jdk.internal.* packages is much harder without explicit --add-opens flags.
If you are migrating from Java 16, this is one of the first things to check: dependencies that relied on deep reflection into JDK internals may need upgrades or JVM flags during the transition.
