Skip to main content

Java: From 8 to 21

· 18 min read
Linh Nguyen
T-90MS Main Battle Tank

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:

Details

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).

warning

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:

https://www.baeldung.com/java-11-new-features

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:

https://openjdk.org/jeps/378

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) {}
warning

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, or non-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.

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 of SequencedCollection for Set that preserve encounter order. It ensures uniqueness of elements while maintaining order.

  • SequencedMap: Designed for Map 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 (as SequencedCollection)

  • LinkedHashSet (as SequencedSet)

  • TreeMap, LinkedHashMap (as SequencedMap)

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.

What's 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.

Optimized for I/O tasks

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.

note

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!