This article is intended for experienced Java programmers who want to update their Java knowledge with the new features introduced in Java 21.
Java 21, released in September 2023, is a Long-Term Support (LTS), and today, in 2025 still is a recommended Java version for development applications based on Java.
Why to use new versions of Java? Why Java 21?
Java 21 introduces features that improve developer productivity, code readability, and application performance.
In my experience, many companies and developers just upgrade the Java version of a project, but don’t refactor the source code to take advantage of the new benefits that the new Java version brings.
Be proactive and refactor your code to make your application and source code perform better.
The Java 21 ride begins! Are you ready?
Java 21 brings several new features
The most important new features in Java 21 are:
- Virtual Threads
- Record Patterns
- Pattern Matching for Switch
- Generational ZGC
- String Literal — String Templates
- Sequenced Collections
Virtual Threads
Probably, the most important feature in Java 21 are Virtual Threads.
Using Virtual Threads, introduced in Java 21, the concurrent API has better performance. Today, we have microservices architecture, server application scales, and that will cause the number of threads to grow. A main goal of virtual threads is to enable scalability of server applications written in the simple thread-per-request style.
In Java 21 the basic concurrence model of Java is unchanged and the Stream API is still the preferred way to process large data sets in parallel.
For example, executor with a Virtual thread you can create like this:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
This means that refactoring and switching to using Virtual Threads is very easy.
In the picture bellow is shown comparison.
The difference is very obvious when we have 1_000_000 tasks.
- FixedThreadPool (1_000_000 tasks) = 33 min 20 sec
- CachedThreadPool (1_000_000 tasks) = 1 min 26 sec
- VirtualThreadPerTask (1_000_000 tasks) = 14 sec
On this link is my Full Article from where you can read details about Virtual Threads in Java 21:
https://medium.com/@milan.karajovic.rs/virtual-threads-in-java-21-comparing-with-cached-thread-and-fixed-thread-726f81ec47fa
The Source Code is on my GitHub about Virtual Threads in Java 21:
https://github.com/Milan-Karajovic/comparing-threads-Java21-with-Virtual-Threads
Record Patterns
About the Record:
Records are a special type of class that save us a lot of boilerplate code. They are considered “data carriers” and are immutable.
These components become final instance variables and accessory methods having the same names as the components are provided automatically.
In addition, a (canonical) constructor, toString(), equals() and hashCode() methods are also generated.
Without Record before Java 16:
public final class Student{
private final String name;
private final Integer age;
public Student(String name, Integer age) {
this.name = name;
this.age = age;
}
public String name() {
return name;
}
public Integer age() {
return age;
}
// also toString(), equals() and hashCode() generated
}
With Record in Java 16:
public record Student(String name, Integer age) {}
About Patterns & Pattern Matching
Before Java 16:
if(obj instanceof String) {
String s = (String)obj;
System.out.println(s.toUpperCase());
}
After Java 16:
if(obj instanceof String s) {
System.out.println(s.toUpperCase());
}
From Java 16 Pattern Matching is done at runtime.
If the pattern matches, then the instance of expression is true and the pattern variable ‘s’ now refers to whatever ‘obj’ refers to.
Record Pattern in Java 21
A Record Pattern does two things for us:
- Checks if an object passes the instance of test.
- Disaggregates the record instance into its components Record patterns support nesting.
Student(String name, Integer age) is a record pattern which does two things:
- Tests whether the object is of type Person (as usual)
- Extract the records components by invoking the component accessor methods on our behalf.
public record Student (String name, Integer age) {}
Before Java 21:
if (obj instanceof Student s) {
String name = s.name();
Integer age = s.age();
System.out.println(name + "; " + age);
}
Now in Java 21:
if (obj instanceof Student(String name, Integer age)) {
System.out.println(name + "; " + age);
}
A Record Pattern does two things for us:
- Checks if an object passes the instance of test.
- Disaggregates the record instance into its components
Pattern Matching:
public static void patternMatching(Object obj){
if(obj instanceof Person person ){
System.out.println("Pattern Matching: "+person.name() + "; "+ person.age());
} else if(obj instanceof Pair pair){
System.out.println("Pattern Matching: "+pair.x() + "; "+ pair.y());
}
}
Non Nesting:
public static void nonNesting(Object obj){
if(obj instanceof Person(String s, Integer nAge)){
System.out.println(s + "; "+ nAge);
}
}
Nesting:
public static void nesting(CourseHolders cHolder){
if (cHolder instanceof CourseHolders(FacultyEmploees employ1, FacultyEmploees employ2)) {
System.out.println(employ1.person() + "; " + employ1.title());
System.out.println(employ2.person() + "; " + employ2.title());
}
if (cHolder instanceof CourseHolders(FacultyEmploees(Person p1, CollegeTitle title), var employ2)) {
System.out.println(p1.name() + "; "+p1.age() + "; "+title.name());
System.out.println(employ2.person());
}
}
Pattern Matching For Switch
Switch is a very natural fit for pattern matching. Recall that pattern matching removes the need for the instanceof and cast idiom.
Other changes, such as the when clause, were motivated by the desire to separate the case labels, patterns and conditional logic from the business logic.
From Java 14, yield is introduced to support switch expressions, allowing a case block to return a value (replaces the older break with value syntax)
Case label now can be:
- null
- byte
- short
- int
- char
- Byte
- Short
- Integer
- Character
- String
- enum
- reference type
Pattern Matching For Switch:
sealed class Vehicle permits Car, Truck, Motorcycle{}
final class Car extends Vehicle{}
final class Truck extends Vehicle{}
final class Motorcycle extends Vehicle {
private int numWheels;
Motorcycle(int numWheels){
this.numWheels = numWheels;
}
public int getNumWheels() {return numWheels;}
}
public class PatternMatchingForSwitch {
public static void patternMatchingSwitch(Vehicle v) {
System.out.println(
switch (v) {
case Car b -> "It's a Car";
case Truck t -> "It's a Truck";
case Motorcycle c when c.getNumWheels() == 2 -> "It's a Classic Motorcycle: ";
case Motorcycle c when c.getNumWheels() == 3 -> "It's a Trike Motorcycle: ";
case null, default -> "Invalid type";
}
);
}
}
Selector Expression Type Extended:
record R(){}
enum E{FIRST, THE_BEST}
public class SelectorExpressionTypeExtended {
public static void main(String[] args) {
selectorType("Milan Karajovic"); selectorType(new R());
selectorType(E.FIRST); selectorType(null);
selectorType(new double[]{2.1, 3.5}); selectorType(2);
}
public static void selectorType(Object obj){
System.out.println(
switch(obj){
case String s1 -> "String";
case R r -> "Record";
case E.FIRST -> "Enum First";
case E.THE_BEST -> "Enum The Best";
case null -> "null";
case double[] array -> "double array";
default -> "others";
}
);
}
}
Z Garbage Collector (ZGC)
Z Garbage Collector (ZGC) is introduced in Java 12 and made production-ready in Java 14. It is more improved in Java 21
In Java 21 ZGC is more improved and now is used for ultra-low-latency applications. ZGC enhances Java’s low-latency garbage collector by introducing generational memory management. It separates objects into young and old generations, optimizing memory allocation and collection for better performance.
Why it is practical?
Generational ZGC is ideal for latency-sensitive applications, such as real-time systems or microservices, where pauses during garbage collection must be minimized. It improves throughput and reduces memory overhead.
How to use?
We can enable Generational ZGC with the following JVM flags:
-XX:+UseZGC -XX:+ZGenerational
Benefits:
- Low Latency: Sub-millisecond pause times for garbage collection.
- Scalability: Handles large heaps efficiently.
- Ease of Use: Minimal configuration required for most applications.
String Literal (String Template)
Java offers several mechanisms for composing strings with string literals and expressions. These are:
- String concatenation
- StringBuilder class,
- String class format() method
- MessageFormat class.
Java 21 introduces as preview feature (explicitly enable preview feature: — enable-preview)(regularly included from Java 22) String Templates. These complement Java’s existing string literals and text blocks by coupling literal text with template expressions and template processors to produce the desired results.
Example:
String firstName = "Milan";
String lastName = "Karajovic";
String person = STR."{ \"firstName\": \"\{firstName}\", \"lastName\": \{lastName} }";
System.out.println(person);
STR is Template processor
String templates reduce errors in string construction, such as SQL injection or formatting issues, and improve code readability. They’re ideal for generating dynamic content like JSON, HTML, or SQL queries.
Sequenced Collections
APIs are defined for accessing first/last elements and also for processing elements in reverse order.
New interfaces are defined for:
- Sequenced Collections
- Sequenced Sets
- Sequenced Maps
Why Sequenced Collections?
This feature standardizes collection operations, making code more consistent across different collection types. It’s particularly useful for algorithms that rely on ordered data, such as queues or stacks.
Sequenced Collection:
public interface SequencedCollection<E> extends Collection<E> {
// new method
SequencedCollection<E> reversed();
// methods promoted from Deque
default void addFirst(E e);
default void addLast(E e);
default E getFirst();
default E getLast();
default E removeFirst();
default E removeLast();
}
Sequenced Set:
public interface SequencedSet<E> extends SequencedCollection<E>, Set<E> {
// new method
SequencedSet<E> reversed();
}
Sequenced Map:
public interface SequencedMap<K, V> extends Map<K, V> {
// new methods
SequencedMap<K, V> reversed();
default SequencedSet<K> sequencedKeySet();
default SequencedCollection<V> sequencedValues();
default SequencedSet<Map.Entry<K, V>> sequencedEntrySet();
default V putFirst(K k, V v);
default V putLast(K k, V v);
// methods promoted from NavigableMap
default Map.Entry<K,V> firstEntry();
default Map.Entry<K,V> lastEntry();
default Map.Entry<K,V> pollFirstEntry();
default Map.Entry<K,V> pollLastEntry();
}
Benefits:
- Consistency: Uniform methods across ordered collections.
- Simplicity: Intuitive methods for common operations like accessing the first or last element.
- Flexibility: Works with existing collection implementations.
Contact and support
author: Milan Karajovic
Portfolio: milan.karajovic.rs
Follow me on LinkedIn: https://lnkd.in/e3cH854Q
Top comments (0)