Java: From 8 to 21
A non-exhaustive, comprehensive list of some of the most notable features from JDK 8 to JDK 11.
TL;DR
This is what we have from JDK 8 to JDK 21:
A Tribute to the Legendary JDK 8
JDK 8 is a revolutionary release of Java programming language. Many years have passed, but none of the new version could manage to achieve the groundbreaking scale JDK 8 once brought. Let's take a look at what JDK 8 have brought us:
Details
-
Lambda expression, backed by a functional interface (an interface with just one single abstract method)
-
Stream API, making data transformations more concise and less tedious to write
-
New modern Date & Time API, in
java.time
package. -
New
CompletableFuture<T>
for async/await-like business. -
Optional<T>
, a new way to reduce cognitive complexity for long nullness check:
class City {
int id;
String name;
boolean isStatemanagement;
}
class Address {
String line1;
String line2;
City city;
}
class Employee {
int id;
String name;
Address address;
}
class CityUtils {
private CityUtils() {
}
// old way
// tedious to write but without creating intermediate objects
public void printValidEmployeeCity(Employee employee) {
if (employee != null
&& employee.address != null
&& employee.address.city != null
&& employee.address.city.isStatemanagement) {
System.out.println(employee.address.city);
}
}
// new way
// reduce cognitive complexity, at the cost of performance
public void printValidEmployeeCity2(Employee employee) {
Optional.ofNullable(employee)
.map(e -> e.address)
.map(a -> a.city)
.filter(c -> c.isStatemanagement)
.ifPresent(System.out::println);
}
}
But eventually, a language has to move on, to bring more advanced features or to enhance the performance. Let's take a deep dive in the new features we are having up unti JDK 21!
JDK 9 (September 2017)
Collection Factory methods
Creating immutable lists, sets or maps has never been easy:
Details
public enum Role {
ROLE_ADMIN,
ROLE_POWER_USER,
ROLE_USER,
ROLE_INVALID
}
// Create an immutable list of integer
var list = List.of(1, 2, 3, 4, 5);
// Create an immutable set of enums
var powerRoles = Set.of(Role.ROLE_ADMIN, Role.ROLE_POWER_USER);
// Create an immutable map of Integer-String
// Support up to 10 pairs of key-value
var map1 = Map.of(1, "A", 2, "B", 3, "C");
// Create an immutable map of Integer-String
// Using Map.Entry varargs
var map2 = Map.ofEntries(Map.entry(1, "A"), Map.entry(2, "B"), Map.entry(3, "C"));
Try-with-resource for Effectively Final Variables
Try-with-resource can now be used for any variable that can be determined as effective final (do not change after initialization inside the scope):
Details
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ResourceManager {
// Java 8
public static void java8TryWithResources() {
try (var executorService = Executors.newVirtualThreadPerTaskExecutor()) {
// Do your things here
}
}
// Java 9+
public static void java9TryWithResources() {
var executorService = getExecutorService();
try (executorService) {
// Do your things here
}
}
private static ExecutorService getExecutorService() {
return Executors.newSingleThreadExecutor();
}
}
private
Methods inside Interfaces
You can now have private
methods inside interface, acting as supporting methods for other ones.
Details
public interface MyInterface {
// other methods
// String#join is available since JDK 8
private String sentence(String... words) {
return String.join(words, " ");
}
}
JDK 10 (March 2018)
var
Keyword for Type Inference
Without var
keyword
Details
public void beforeJdk10() {
// Can be very long and confusing
Map<String, List<Integer>> map =
Map.of(
Map.ofEntries("A", List.of(1, 2)),
Map.entry("B", List.of(3, 4)),
Map.entry("C", List.of(5, 6, 7)));
System.out.println(map);
}
And with var
keyword
Details
public void sinceJdk10() {
var map =
Map.of(
Map.ofEntries("A", List.of(1, 2)),
Map.entry("B", List.of(3, 4)),
Map.entry("C", List.of(5, 6, 7)));
System.out.println(map);
}
Using var
keyword can make your code more concise, but remember to name your local variable meaningfully -- without IDE supports, code reviewers may find it hard to determine the correct data type (unlikely, even knowing data types explicitly won't help much in some cases).
Check your team's coding convention. I once worked with a team that downright despises var
keyword (they even somehow created a sonar rule (?) that outright rejected the usage of var
keyword during build). Make sure that you and other members follow a universal coding guideline, so that while other may use var
, others will use explicitly data types.
Also, pay attention to this caveat:
// The data type of list1 is java.util.List
List<Integer> list1 = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8));
// The data type of list2 is java.util.ArrayList
// var keyword doesn't support type parameter like var<Integer> here
var list2 = new ArrayList<Integer>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8));
A minor inconvenience, but sometimes, you have to pay attention to, especially when you do the code refactoring (for example, the auto-generated returning type is java.util.ArrayList
, but you want java.util.List
instead).
JDK 11 (September 2018)
There aren't many notable features in JDK 11 (at least, according to my preferences), aside from what were already contained from JDK 8 to JDK 10.
Check the Baeldung article here for more info:
JDK 14 (March 2020)
Enhanced Switch Expressions
Before JDK 14, this is the old school way of writing switch case
:
Details
public int getDaysOfMonth(Month month, int year) {
switch (month) {
case APRIL:
case JUNE:
case SEPTEMBER:
case NOVEMBER:
return 30;
case FEBRUARY:
return isLeapYear(year) ? 29 : 28;
default:
return 31;
}
}
Now, the enhanced switch case
:
Details
public int getDaysOfMonth(Month month, int year) {
return switch (month) {
case APRIL, JUNE, SEPTEMBER, NOVEMBER -> 30;
case FEBRUARY -> isLeapYear(year) ? 29 : 28;
default -> 31;
};
}
FYI, the supporting boolean isLeapYear(int)
method:
Details
private static boolean isLeapYear(int year) {
// simple way to determine if a year is a leap year
return year % 400 == 0 || (year & 3) == 0 && year % 100 != 0;
}
(year & 3) == 0
is another way to check if a number is divisible by 4.
Much, much more concise and expressive. Finally, an old relic from C/C++ has been put to rest (not to mention the ugly and unnecessary break
every time).
For more complex case expressions, you will be using yield
keyword to return the value and break from case block:
Details
public int getDaysOfMonth(Month month, int year) {
return switch (month) {
case APRIL, JUNE, SEPTEMBER, NOVEMBER -> 30;
case FEBRUARY -> {
if (isLeapYear(year)) {
log.info("Year {} is a leap year", year);
yield 29;
}
log.info("Year {} is a normal year", year);
yield 28;
}
default -> 31;
};
}
Think of yield
is like a mini return
, instead of quitting the method, it quits the case block.
JDK 15 (September 2020)
Text Block
Before JDK 15, this is how you write a String that may span through many lines or with ugly escape characters, like this:
var uglyJson = "{\n"
+ " \"status\": \"ok\",\n"
+ " \"message\": \"object created\",\n"
+ " \"additional\": {\n"
+ " \"id\": \"33663e80-3262-4197-aeb4-381a5447bd84\"\n"
+ " }\n"
+ "}\n";
The arrival of JDK 15 finally gives us the Text Block, finally making our String beautiful again:
var blyatfulJson =
"""
{
"status": "ok",
"message": "object created",
"additional": {
"id": "33663e80-3262-4197-aeb4-381a5447bd84"
}
}
""";
For the complete flavor, read the JEP 378 for more information:
JDK 16 (March 2021)
Pattern Matching for instanceof
We have this simple example of our classes:
Details
class Vehicle {
String name;
}
class Tank extends Vehicle {
double tankCaliber;
}
class Motorbike extends Vehicle {
double engineDisplacementVolume;
}
Without the new pattern matching, we have to use the old way:
Details
public void printInfo(Vehicle vehicle) {
if (vehicle instanceof Tank) {
var tank = (Tank) vehicle;
System.out.println("This is a tank with the gun caliber of " + tank.tankCaliber);
}
if (vehicle instanceof Motorbike) {
var motorbike = (Motorbike) vehicle;
System.out.println("The motorbike with engine displacement volume of "
+ motorbike.engineDisplacementVolume);
}
throw new UnsupportedOperationException("Vehicle " + vehicle.getClass().getName() + " not supported");
}
And now, pattern matching relieves us from the pain of manual casting:
Details
public void printInfo(Vehicle vehicle) {
if (vehicle instanceof Tank tank) {
System.out.println("This is a tank with the gun caliber of " + tank.tankCaliber);
}
if (vehicle instanceof Motorbike motorbike) {
System.out.println("The motorbike with engine displacement volume of "
+ motorbike.engineDisplacementVolume);
}
throw new UnsupportedOperationException("Vehicle " + vehicle.getClass().getName() + " not supported");
}
Well, good news for people who wishes pattern matching to be available on Java after having a taste at Kotlin!
Java Records
Say goodbye to this old school style of POJOs:
public class Employee {
private int id;
private String name;
private java.time.LocalDate birthDate;
}
And now, time to welcome the newest hero, Java Record:
// One single line to sweep away all your worries
public record Employee(int id, String name, java.time.LocalDate birthDate) {}
Java Records are inherently final
, meaning they cannot be subclassed, and they implicitly extend java.lang.Record
, which prevents them from extending other classes (though they can still implement interfaces). If your business logic requires class inheritance hierarchies, you should use regular classes instead of Records. Additionally, all fields within Records are implicitly final, making them immutable. For scenarios requiring mutable state or field modifications after object creation, traditional classes with non-final fields are the appropriate choice.
JDK 17 (September 2021)
Sealed class
Sometimes we face a design dilemma: either we allow our classes to be subclassed indefinitely, or we prevent subclassing entirely with final
. But what if we need something in between? What if we want to allow only specific, predetermined subclasses? For instance, we might want Vehicle
to be extendable by Car
, Tank
, and Ship
(basically a vehicle that moves on water), but not by unrelated classes like Human
or DatabaseConnection
.
This is where sealed classes
come to the rescue!
This is basically what you need to write for sealed classes:
Details
sealed class ParentClass permits SubClass, NonSealedSubClass {
}
final class SubClass extends ParentClass {
}
non-sealed class NonSealedSubClass extends ParentClass {
}
Key Characteristics:
-
Parent classes must explicitly declare their permitted subclasses using the
permits
clause. -
All permitted subclasses must be declared as either
final
,sealed
, ornon-sealed
.
When sealed classes were introduced in JDK 17, they were primarily designed for library maintainers who needed precise control over class hierarchies. However, it wasn't until JDK 21 that sealed classes became significantly more useful for everyday developers, thanks to enhanced pattern matching capabilities (which we'll explore in the next section).
Compilation Module Restrictions
Sealed classes can only work within a single compilation unit to maintain the integrity of their strict hierarchy. This means:
-
If the sealed class and its permitted subclasses are in the same package, they must be in the same source file or the same package within the same module.
-
For larger applications using modules, all classes in the sealed hierarchy must reside within the same module.
-
This restriction prevents external code from corrupting the sealed hierarchy by adding unauthorized subclasses, which would break the exhaustiveness guarantees that sealed classes provide.
-
The compiler enforces these boundaries, ensuring that the "sealed" contract cannot be violated by code outside the compilation unit.
Refer to those JEPs for a complete picture of sealed classes feature:
Finally, JDK 21 (Sep 2023)
And now, we come to the current latest JDK with LTS, as this time of writing (2025-08-01), and the adoption of next JDK with LTS (25) will probably take a while.
Finally, Pattern Matching for switch
Expressions
First, let's create an example:
Details
// When all permitted subclasses are defined within the same
// source file as the sealed class, you can omit the `permits`
// clause entirely. The compiler will automatically infer the
// permitted subclasses from the declarations in the file.
// Talk about being less verbose, jeez...
public sealed class Vehicle {
String name;
static final class Tank extends Vehicle {
double gunCaliber;
}
static final class Ship extends Vehicle {
double waterDisplacement;
}
static final class Airplane extends Vehicle {
double takeOffSpeed;
}
}
And now, finally, the pattern matching for switch
expression:
Details
public static void printInfo(Vehicle vehicle) {
var name = vehicle.name;
switch (vehicle) {
case Tank tank ->
System.out.printf(
"This tank (%s) hurts, with the caliber of %s mm%n", name, tank.gunCaliber);
case Ship ship ->
System.out.printf(
"This beauty, called %s can displace about %s tons of water%n",
name, ship.waterDisplacement);
case Airplane airplane ->
System.out.printf(
"We need to reach at least %s kmh to be able to take off this baby named %s%n",
airplane.takeOffSpeed, name);
default -> throw new IllegalStateException("Unexpected vehicle type: " + vehicle);
}
}
It is much more fun to write using this enhanced switch
expression than to do something like this (even with the enhanced instanceof
checks):
Details
public static void printInfo(Vehicle vehicle) {
var name = vehicle.name;
if (vehicle instanceof Tank tank) {
System.out.printf(
"This tank (%s) hurts, with the caliber of %s mm%n", name, tank.gunCaliber);
return;
}
if (vehicle instanceof Ship ship) {
System.out.printf(
"This beauty, called %s can displace about %s tons of water%n",
name, ship.waterDisplacement);
return;
}
if (vehicle instanceof Airplane airplane) {
System.out.printf(
"We need to reach at least %s kmh to be able to take off this baby named %s%n",
airplane.takeOffSpeed, name);
return;
}
throw new IllegalStateException("Unexpected vehicle type: " + vehicle);
}
Still, if your project cannot use JDK 21, this could be the least tedious way for the time being.
Record Pattern
Record patterns are a powerful feature introduced in Java to simplify the deconstruction of data stored in records. They can be used directly in switch
expressions and instanceof
checks to extract values in a more concise and readable way.
Consider the following simple record:
record Point(double x, double y) {}
instanceof
Checks
- Without Record patterns
Details
public void displayInstanceOf(Object object) {
if (object instanceof Point p) {
System.out.printf("Point with x = %s, y = %s%n".formatted(p.x(), p.y()));
return;
}
if (object instanceof Number number) {
System.out.printf("Number %s%n".formatted(number));
return;
}
System.out.println(object);
}
- With Record patterns
Details
public void displayInstanceOf2(Object object) {
if (object instanceof Point(var x, var y)) {
System.out.printf("Point with x = %s, y = %s%n".formatted(x, y));
return;
}
if (object instanceof Number number) {
System.out.printf("Number %s%n".formatted(number));
return;
}
System.out.println(object);
}
Enhanced switch
Expression
- Without Record patterns
Details
public void displaySwitchExpression(Object object) {
switch (object) {
case Point point ->
System.out.printf("Point with x = %s, y = %s%n".formatted(point.x(), point.y()));
case Number number -> System.out.printf("Number %s%n".formatted(number));
default -> System.out.println(object);
}
}
- With Record patterns
Details
public void displaySwitchExpression2(Object object) {
switch (object) {
case Point(var x, var y) -> System.out.printf("Point with x = %s, y = %s%n".formatted(x, y));
case Number number -> System.out.printf("Number %s%n".formatted(number));
default -> System.out.println(object);
}
}
While these changes may seem subtle, they contribute to improved readability and reduced boilerplate, especially in more complex scenarios.
Sequenced Collections
JDK 21 introduces a new interface hierarchy that enhances the existing Collection interfaces by explicitly modeling collections with a defined encounter order: the order in which elements are visited during iteration.
The core interfaces are:
-
SequencedCollection
: A base interface for all collections (all lists or some sets) that maintain a predictable order of elements. It defines methods to access, add, and remove elements from both ends of the sequence. -
SequencedSet
: A specialized subinterface ofSequencedCollection
forSet
that preserve encounter order. It ensures uniqueness of elements while maintaining order. -
SequencedMap
: Designed forMap
implementations that maintain a consistent ordering of entries. It defines methods for accessing and manipulating entries based on their position in the sequence.
Examples of existing classes that now implement these interfaces include:
-
ArrayList
,LinkedList
(asSequencedCollection
) -
LinkedHashSet
(asSequencedSet
) -
TreeMap
,LinkedHashMap
(asSequencedMap
)
This enhancement unifies and simplifies interaction with ordered collections across the Java standard library, providing a more consistent and expressive API.
Let's take a look at the methods those interfaces brought
From SequencedCollection
:
Details
// Returns a reversed view of the collection without modifying the original.
SequencedCollection<E> reversed();
// Inserts the specified element at the beginning of the collection.
void addFirst(E e);
// Appends the specified element to the end of the collection.
void addLast(E e);
// Retrieves (but does not remove) the first element.
// Equivalent to list.get(0) for a List like ArrayList.
E getFirst();
// Retrieves (but does not remove) the last element.
// Equivalent to list.get(list.size() - 1) for a List like ArrayList.
E getLast();
// Removes and returns the first element of the collection.
E removeFirst();
// Removes and returns the last element of the collection.
E removeLast();
From SequencedSet
:
Note that SequencedSet
extends SequencedCollection
, so all methods in SequencedCollection
are available to SequencedSet
.
Details
// Overrides SequencedCollection<E>.reversed() with a covariant return type.
// Ensures the reversed view of a SequencedSet remains a SequencedSet,
// preserving both order and set semantics.
SequencedSet<E> reversed();
And finally, SequencedMap
:
Details
// Returns a reversed view of this map with entries in reverse encounter order.
SequencedMap<K, V> reversed();
// Returns the first entry in the map, according to encounter order.
Map.Entry<K, V> firstEntry();
// Returns the last entry in the map, according to encounter order.
Map.Entry<K, V> lastEntry();
// Removes and returns the first entry in the map.
Map.Entry<K, V> pollFirstEntry();
// Removes and returns the last entry in the map.
Map.Entry<K, V> pollLastEntry();
// Inserts a mapping as the first entry in the map.
V putFirst(K k, V v);
// Inserts a mapping as the last entry in the map.
V putLast(K k, V v);
// Returns a sequenced view of the map’s keys as a set.
SequencedSet<K> sequencedKeySet();
// Returns a sequenced view of the map’s values as a collection.
SequencedCollection<V> sequencedValues();
// Returns a sequenced view of the map’s entries as a set.
SequencedSet<Map.Entry<K, V>> sequencedEntrySet();
For a full picture of the hierarchy, take a look at this diagram:
Details
+--------------------+
| Iterable<E> |
+--------------------+
|
v
+--------------------+
| Collection<E> |
+--------------------+
|
+--------------+--------------+
| |
v v
+------------------------+ +-----------------+
| SequencedCollection<E> | | Set<E> |
+------------------------+ +-----------------+
| |
v v
+-------------+ +---------------------+
| List<E> | | SequencedSet<E> |
+-------------+ +---------------------+
|
v
+---------------------+
| LinkedHashSet<E> |
+---------------------+
+-----------------------+
| Map<K, V> |
+-----------------------+
|
v
+-------------------------+
| SequencedMap<K, V> |
+-------------------------+
|
+---------------+----------------+
| |
v v
+---------------------+ +---------------------+
| LinkedHashMap<K,V>| | TreeMap<K,V> |
+---------------------+ +---------------------+
Virtual Threads
TL;DR
If you don't want to read, then just watch this great explanation by José Paumard and skip the rest of this section:
Explanation Video by José Paumard
If you are still curious, then continue reading!
The Boring "What is Virtual Thread?" Question
Chances are you've heard about Virtual Threads. So, what are they?
To briefly sum it up: they're a way to significantly increase the throughput of your applications by leveraging the magic of parking and unparking.
A Virtual Thread will "park" on a Platform Thread (the carrier) and let that carrier do the actual work. When it hits an I/O operation (network requests, database queries, disk reads, etc...), the Virtual Thread will "unpark" itself from the carrier and hop back to the heap, preserving its stack trace. Once the I/O completes, the Virtual Thread is scheduled back onto a carrier thread -- which may or may not be the same one it started on.
That's basically how Virtual Threads work in JDK 21. You write code in a simple, imperative style and let the JVM handle the messy bits. Reactive programming still squeezes out more performance, sure -- but Virtual Threads offer a much simpler approach without sacrificing too much efficiency. A small pool of carrier threads can, in theory, handle millions of virtual threads without breaking a sweat.
Remember: Virtual Threads are meant for I/O-heavy tasks. If your workload is mostly CPU-bound, they're wasteful -- like wearing a raincoat on a sunny day, and people will question your choices.
Working with Virtual Threads
If you want to use Virtual Threads without relying on any frameworks, you have (theoretically) two main approaches:
- The direct approach
Details
class TestVirtualThread {
public static void main(String[] args) {
Thread.ofVirtual().unstarted(() -> System.out.println("Virtual Thread running...")).start();
}
}
Simple and straightforward. No ExecutorService
, no pooling -- just create a Virtual Thread, let it run, and let it terminate. Lightweight and clean.
- Using an
ExecutorService
Details
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
class TestVirtualThread {
public static void main(String[] args) {
try (var executorService = Executors.newVirtualThreadPerTaskExecutor()) {
executorService.submit(() -> System.out.println("Virtual Thread 1 running..."));
var withCallable =
executorService
.submit(
() -> {
System.out.println("Virtual Thread 2 running...");
return 10;
})
.get(5, TimeUnit.SECONDS);
System.out.println(withCallable);
}
}
}
This is a more scalable and flexible approach -- especially if you're submitting many tasks or want better control over their execution. newVirtualThreadPerTaskExecutor()
creates a fresh Virtual Thread for each submitted task, with no need to manage pooling yourself. The method name basically speaks for itself, without any hidden meaning.
In real-world applications, frameworks like Spring Boot will often handle Virtual Thread configuration for you behind the scenes. Just remember: Virtual Threads are great for I/O-bound tasks. Avoid using them for CPU-heavy workloads -- that's not what they're built for.
For example, Spring Boot 3.2+ gives you this gem in application.properties
:
spring.threads.virtual.enabled=true
And, JDK 25?
It will take years before enterprises finally adopt JDK 25. So stay tuned until then!