Modern Java: An In-Depth Guide from Java 8 to Java 21

✨ Akiner Alkan
18 min readMay 7, 2024

--

Java, the versatile programming language, has been on a transformative journey. Starting with Java 8, exciting features changed how developers code. From nifty lambda expressions for better functions to the Stream API that made working with data easier, Java 8 was a game-changer. We’ll look through the updates up to Java 21, where sealed classes give you more control over classes, and records make creating data objects a breeze. Join to the journey as I break down these modern Java features with simple explanations and practical examples. Since this is going to be a long journey and the article will be quite lengthy, I suggest breaking it into smaller parts for your reading comfort. You can also take short breaks in between sections to make it easier to follow along.

Java 8

Lambda Expression

A lambda expression is a concise way to represent an anonymous function, which can be passed as an argument to other functions or used in functional programming paradigms. It allows you to define small, inline functions without explicitly creating a separate method. Lambda expressions are commonly used in languages that support functional programming, such as Java.

// Traditional approach using an anonymous inner class
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Hello, world!");
}
};
  // Using lambda expression
Runnable lambdaRunnable = () -> {
System.out.println("Hello, world!");
};

Stream API

The Stream API is a feature introduced in Java 8 that provides a more functional and declarative way to process collections (e.g., lists, arrays) of data. Streams allow you to perform various operations (like filtering, mapping, reducing) on the elements of a collection in a more concise and expressive manner.

List<String> fruits = Arrays.asList("apple", "banana", "orange", "grape", "kiwi");
        // Using Stream API to filter and transform elements
List<String> result = fruits.stream()
.filter(fruit -> fruit.length() > 5)
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(result); // Output: [BANANA, ORANGE]

Many possible scenarios can be implemented with Streams API. In this example, we create a stream from the fruits list, then filter out fruits with a length greater than 5 characters, convert the remaining elements to uppercase, and collect the result into a new list. The Stream API makes the code more readable and allows for chaining multiple operations.

Method Reference

Method reference is a feature that provides a shorthand syntax for referencing methods or constructors and using them as arguments to higher-order functions like lambdas or in the Stream API. It simplifies the code by allowing you to directly refer to a method by its name instead of providing a lambda expression that replicates the method’s behavior.

String[] words = {"apple", "banana", "cherry", "date", "elderberry"};

// Using explicit lambda expression for sorting
Arrays.sort(words, (a, b) -> a.compareToIgnoreCase(b));
        // Using method reference to sort the array
Arrays.sort(words, String::compareToIgnoreCase);

In this example, the explicit lambda expression (a, b) -> a.compareToIgnoreCase(b) is used for sorting the array. This is included alongside the method reference version to show the comparison between the two approaches.

Default Methods

Default methods are methods defined in an interface with an implementation. They allow you to add new methods to an interface without breaking compatibility with existing classes that implement the interface. Default methods provide a way to extend interfaces without forcing all implementing classes to provide implementations for the new methods.

interface Greeting {
// A default method in the interface
default void greet() {
System.out.println("Hello, from the interface!");
}
}
  class Person implements Greeting {
// No need to override the default greet() method
}
public class Main {
public static void main(String[] args) {
Person person = new Person();
person.greet(); // Will use the default method implementation
}
}

In this example, the Greeting interface defines a default method greet(). The Person class implements the Greeting interface but doesn’t need to provide its own implementation of the greet() method since it inherits the default implementation. The person.greet() call will use the default method from the interface.

Date and Time API

The Date and Time API, introduced in Java 8, provides a comprehensive way to handle date and time-related operations, addressing the limitations and complexities of the older java.util.Date and java.util.Calendar classes. It includes classes for representing dates, times, periods, durations, time zones, and more. Here’s a basic overview and example of using the Date and Time API:

import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
  public class DateTimeAPIExample {
public static void main(String[] args) {
// Current date, time, and date-time
LocalDate currentDate = LocalDate.now();
LocalTime currentTime = LocalTime.now();
LocalDateTime currentDateTime = LocalDateTime.now();
System.out.println("Current Date: " + currentDate);
System.out.println("Current Time: " + currentTime);
System.out.println("Current Date-Time: " + currentDateTime);
// Formatting and parsing
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String formattedDate = currentDate.format(dateFormatter);
System.out.println("Formatted Date: " + formattedDate);
String dateString = "2023-08-01";
LocalDate parsedDate = LocalDate.parse(dateString, dateFormatter);
System.out.println("Parsed Date: " + parsedDate);
}
}

In this example, we import classes from the java.time package to work with dates, times, and date-times. We demonstrate creating instances of LocalDate, LocalTime, and LocalDateTime, and then we format and parse a date using the DateTimeFormatter class.

The Java Date and Time API is designed to be more intuitive, immutable, and thread-safe compared to the older alternatives. It provides classes like ZonedDateTime, Duration, and Period for handling time zones, time durations, and date differences. The API helps avoid common pitfalls related to time calculations and daylight saving time adjustments that were problematic with the older date and time classes.

You can find more detailed examples as conversion from old API to new API from this nice explaining Baeldung page.

Java 9

Java Module System

The Java Module System, introduced in Java 9, is a fundamental change to the Java platform that allows you to create modular and more maintainable applications. It addresses issues related to strong encapsulation, classpath pollution, and versioning conflicts that have been problematic in the Java ecosystem.

Key Concepts:

Module: A module is a self-contained unit of code that encapsulates its implementation details, dependencies, and provides a clear API. Modules group related packages and resources together, providing a higher level of abstraction.

Module Descriptor (module-info.java): Each module has a module descriptor, specified in a file named module-info.java. This descriptor defines the module’s name, dependencies, exported packages, and other module-related settings.

Module Path: The module path is a new way of specifying dependencies for a Java application. It replaces the traditional classpath. Modules are resolved based on their dependencies and explicit requirements, which helps prevent classpath conflicts.

The Java Module System introduced several new keywords and concepts to support the module system’s functionality. These keywords are essential for defining module declarations and specifying relationships between modules, services, and packages within the Java Module System. They play a crucial role in ensuring proper encapsulation, dependency management, and isolation between modules in your Java applications. Here are the main keywords associated with the module system:

module: Used to declare a module and specify its name, dependencies, and 
other characteristics. The module declaration is done in a module-info.java
file.
requires: Specifies module dependencies. Modules that require other modules 
have access to the exported packages and public types of those modules.
exports: Specifies which packages are accessible to other modules. Exported
packages define the module's public API.
opens: Allows reflective access to a package. This is used when you want to
grant runtime access to a package's private members for certain modules.
uses: Declares that a module consumes a service. This keyword is used in
combination with the provides keyword to establish service relationships.
provides: Specifies service implementations provided by a module as part of
the Java Service Provider Interface (SPI).
with: Specifies the service loader implementation to be used when declaring
a service provider class in the provides directive.
to: Specifies the service provider class in the provides directive when
multiple providers are defined.

Let’s create an example to understand Java Module System futhermore. In the example consider:

com.socialnetwork.core represents the core module of your social networking 
application.
com.socialnetwork.external represents the module containing external 
dependencies.
com.socialnetwork.api represents the package containing your application's 
public API.
com.socialnetwork.notifications.reflective represents the package with 
reflective elements related to notifications.
com.socialnetwork.notificationhandler represents the module that handles 
notifications in a reflective manner.
com.socialnetwork.UserNotificationService is an interface for handling 
user notifications.
com.socialnetwork.providers.DefaultNotificationServiceProvider 
and com.socialnetwork.providers.SecondaryNotificationServiceProvider are
service provider classes for the UserNotificationService.
com.socialnetwork.providers.NotificationServiceLoader represents a loader 
for the UserNotificationService service.
// This declares a new module named   
module com.socialnetwork.core {
// This specifies that the core module requires external module as a dependency.
requires com.socialnetwork.external;
   // This exports the package api from core module, making its public API accessible to other modules.
exports com.socialnetwork.api;
// This opens the package reflective to reflective access by the notificationhandler module.
// This allows the latter module to access private members using reflection.
opens com.socialnetwork.notifications.reflective to com.socialnetwork.notificationhandler;

// This indicates core module uses the UserNotificationService service.
uses com.socialnetwork.UserNotificationService;
// This provides implementations of the UserNotificationService interface using the DefaultNotificationServiceProvider and SecondaryNotificationServiceProvider classes.
// It establishes a relationship where multiple providers offer implementations of the UserNotificationService service.
provides com.socialnetwork.UserNotificationService
with com.socialnetwork.providers.DefaultNotificationServiceProvider
to com.socialnetwork.providers.SecondaryNotificationServiceProvider;
}

This module-info.java snippet defines the structure, dependencies, exports, reflective access, service providers, and service consumers for the com.socialnetwork.core module. It illustrates how a core module can interact with other modules and provide services in a modular Java application.

When using the Java 9 Module system, developers define the dependencies and interactions between modules explicitly, rather than relying on implicit classpath dependencies as in previous versions of Java. It’s important to test the architecture to ensure that modules are structured and interacting correctly. This involves verifying that modules are encapsulated properly and that dependencies are managed effectively. To test this architecture and ensure proper handling of modules, you can utilize ArchUnit as an architecture testing framework.

Try-with-resources

The try-with-resources statement is used in Java to automatically close resources that are used within a try block. It simplifies resource management by ensuring that resources are closed properly, even in the presence of exceptions. Resources that can be used with try-with-resources must implement the AutoCloseable or java.io.Closeable interface.

In Java 9, the try-with-resources statement was enhanced to handle final variables declared in the try block.

Before Java 9:

try (Scanner scanner = new Scanner(new File("testRead.txt")); 
PrintWriter writer = new PrintWriter(new File("testWrite.txt"))) {
// ...
}

Java 9 and After:

final Scanner scanner = new Scanner(new File("testRead.txt"));
final PrintWriter writer = new PrintWriter(new File("testWrite.txt"))
try (scanner;writer) {
// ...
}

This enhancement simplifies the code by allowing resources to be declared directly within the try block, improving readability and reducing the scope of variables to where they are actually needed.

Private Interface Methods

Private interface methods are a feature introduced in Java 9 to allow interfaces to have private methods. These methods are intended to be used within the interface itself, primarily for code reuse and improved readability. They cannot be accessed or overridden by implementing classes or other classes outside the interface.

public interface MyInterface {
default void publicMethod() {
privateMethod();
}
  private void privateMethod() {
System.out.println("Private method in interface.");
}
}

Java 10

Local Variable Type Inference

Local Variable Type Inference, often referred to as “var” type, is a feature introduced in Java 10 that allows you to declare local variables without explicitly specifying their types. The compiler infers the type of the variable based on the assigned value. This feature aims to improve code readability while maintaining strong typing.

public static void main(String[] args) {
var name = "John Doe"; // Infers String type
var age = 30; // Infers int type
var salary = 50000.0; // Infers double type

System.out.println("Name: " + name);
System.out.println("Age: " + age);
System.out.println("Salary: " + salary);

// Compile-time error: var must be initialized
// var uninitializedVar;
}

The var type is used only for local variables with an initializer. It cannot be used for method parameters, return types, class fields, or uninitialized variables. While var reduces boilerplate code, it’s important to use meaningful variable names for clarity.

Overusing var can lead to reduced code readability if variable names are not descriptive.

Java 11

Local Variable Type in Lambda Expressions

Local variable type inference (the “var” type) can also be used with lambda expressions in Java. This feature was introduced in Java 11, allowing you to declare the type of variables within lambda expressions using var. This improves code readability while still maintaining the benefits of static typing.

Here’s an example demonstrating the use of local variable type inference in lambda expressions

List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");
names.forEach((var name) -> {
System.out.println("Hello, " + name);
});

Java 12

String Indent and Transform

Indent method is used to add an indentation to each line of a string. It returns a new String instance with the specified number of spaces added to the beginning of each line.

String original = "Hello\nWorld";
String indented = original.indent(4);
System.out.println(indented);
// Output:
// Hello
// World

Transform method applies a transformation function to the string and returns the result. It’s a way to perform operations on strings in a more functional programming style.

String original = "Hello";
String transformed = original.transform(s -> s.toUpperCase());
System.out.println(transformed); // Output: HELLO

Files Mismatch

Files.mismatch method is part of the java.nio.file package in Java, and it is used to compare the content of two files for differences. It’s specifically designed to efficiently determine whether two files have differing content without needing to read the entire contents of the files.

Path filePath1 = Files.createTempFile("file1", ".txt");
Path filePath2 = Files.createTempFile("file2", ".txt");
Files.writeString(filePath1, "I love Java");
Files.writeString(filePath2, "I love Technology");
long mismatch = Files.mismatch(filePath1, filePath2); // It returns 7

In this code, the Files.mismatch method will compare the content of the files referenced by filePath1 and filePath2 and return the position of the first differing byte, which will be the space character at position 7. This is because the contents “I love Java” and “I love Technology” differ starting from the seventh position.

Java 13

TextBlocks

Text Blocks were introduced as a preview feature in Java 13 and were made a permanent part of the Java language in Java 15. It provides a more readable and natural way to define strings that span multiple lines without the need for concatenation or escape characters.

String name = """
___ __ ___ __ __ __ _______ ______
/ \\ | |/ / | | | \\ | | | ____|| _ \\
/ ^ \\ | ' / | | | \\| | | |__ | |_) |
/ /_\ \\ | < | | | . ` | | __| | //
/ _____ \\ | . \ | | | |\\ | | |____ | |\\ \\__
/__/ \__\\ |__|\\__\ |__| |__| \\__| |_______|| _| `._____|
""";

Text Blocks are particularly useful for writing SQL queries, JSON, XML, and any other kind of formatted text where readability is important.

Java 14

Yield Keyword

Java 12 introduces expressions to Switch statement and released it as a preview feature. Java 13 added a new yield construct to return a value from switch statement. With Java 14, switch expression now is a standard feature.

public static String getDayType(String day) {
var result = switch (day) {
case "Monday", "Tuesday", "Wednesday","Thursday", "Friday": yield "Weekday";
case "Saturday", "Sunday": yield "Weekend";
default: yield "Invalid day.";
};

return result;
}

Java 15

Garbage Collector Updates

Z Garbage Collector was an experimental feature till Java 15. It is low latency, highly scalable garbage collector.

ZGC was introduced in Java 11 as an experimental feature as developer community felt it to be too large to be released early. A lot of improvements are done to this garbage collection since then.

ZGC is highly performant and works efficiently even in case of massive data applications e.g. machine learning applications. It ensures that there is no long pause while processing the data due to garbage collection. It supports Linux, Windows and MacOS.

The Shenandoah low-pause-time garbage collector is now out of the experimental stage. It had been introduced into JDK 12 and from java 15 onwards, it is a part of standard JDK.

Java 16

Pattern Matching for instanceof

Java 14 introduces instanceof operator to have type test pattern as is a preview feature. Type test pattern has a predicate to specify a type with a single binding variable. It continues to be a preview feature in Java 15 as well. With Java 16, this feature is now a part of standard delivery.

// Traditional instanceof
if (animal instanceof Cat) {
Cat cat = (Cat) animal;
cat.meow();
// other cat operations
}
// Modern instanceof
if (animal instanceof Cat cat) {
cat.meow();
}

Records

Java 14 introduces a new class type record as preview feature to facilitate creation of immutable data objects. Java 15 enhances record type further. With Java 16, record is now a standard feature of JDK. Defining a record is a concise way of defining an immutable data holding object.

Without using record

public final class Book {
private final String title;
private final String author;
private final String isbn;

public Book(String title, String author, String isbn) {
this.title = title;
this.author = author;
this.isbn = isbn;
}

public String getTitle() {
return title;
}

public String getAuthor() {
return author;
}

public String getIsbn() {
return isbn;
}

@Override
public boolean equals(Object o) {
// ...
}

@Override
public int hashCode() {
return Objects.hash(title, author, isbn);
}
}

With using record

public record Book(String title, String author, String isbn) {}

Java 17

Sealed Class

Sealed class is a feature introduced in Java 15 to enhance the control over class inheritance and ensure that only specific subclasses can extend it. It provides a way to declare a limited set of classes that are allowed to inherit from the sealed class, while preventing other classes from extending it. Here’s a basic explanation of how sealed classes work

public sealed class Shape permits Circle, Rectangle, Triangle, Square {...}
public sealed class Rectangle extends Shape permits TransparentRectangle, FilledRectangle {...}

In this example, the Shape class is declared as sealed using the sealed keyword. The permits keyword is used to specify the classes that are allowed to inherit from Shape.

//Defining Subclasses:
public final class Circle extends Shape {...}
public final class TransparentRectangle extends Rectangle {...}public final class FilledRectangle extends Rectangle {...}public non-sealed class Square extends Shape {...}

In this example, Circle, Rectangle, Triangle and Square are explicitly marked as permitted subclasses of Shape. The Square class is declared as non-sealed, which means it’s not final and can be further extended by classes outside the current package.

Key features of sealed classes

Controlled Inheritance: Sealed classes allow you to control which classes 
can extend them, preventing unwanted or unexpected subclasses.
Limited Subclasses: The permits keyword specifies a list of classes that 
can be direct subclasses of the sealed class.
Open and Non-Sealed: Sealed classes can also allow classes to extend them that
are open or non-sealed, offering more flexibility in class hierarchy design.
Final by Default: Sealed classes are implicitly final, meaning they cannot
be directly instantiated or subclassed unless explicitly permitted.
Use Cases: Sealed classes are useful for modeling closed hierarchies where
you want to limit the number of subclasses, ensuring better control over
the design and behavior of your class hierarchy.

Java 21

Virtual Threads

In previous threading model, Java’s threads directly correspond to operating system (OS) threads, resulting in limitations on the number of threads that can be created due to OS constraints. In the traditional threading model, excessive thread creation can strain the OS and incur high costs, particularly for short-lived threads.

Java 21 introduces virtual threads, offering mapping between virtual threads and OS threads, allowing for theoretically unlimited virtual thread creation. This innovation addresses the limitations of traditional threading models, enabling the creation of numerous threads to meet the demands of high-throughput server applications. With virtual threads, the previous constraint on thread creation is eliminated, enabling the continuation of the thread-per-request style commonly used in server applications.

An example demonstrates the use of virtual threads compared to OS/platform threads. By utilizing ExecutorService to execute 500,000 thread per tasks, the JDK efficiently manages the execution on a limited number of carrier and OS threads, allowing developers to write concurrent code effortlessly.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 500_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits

Sequenced Collections

The absence of a universal supertype for collections with a specified encounter order has caused recurring issues and complaints within Java’s collections framework. Additionally, the lack of consistent methods for accessing the first and last elements, as well as iterating in reverse order, has been a persistent drawback.

Consider the List interface, which maintains an encounter order. While accessing the first element is straightforward with list.get(0), accessing the last element requires list.get(list.size() - 1). This inconsistency poses challenges for developers and complicates code maintenance.

Although this inconvenience mentioned as the example in Collectioninterface. It also happens in Map interfaces as well.

This new feature introduces three new interfaces for sequenced collections, sequenced sets, and sequenced maps, which are added to the existing hierarchy of collections

JEP 431: Sequenced Collections

SequencedCollection interface aids the encounter order by adding a reverse method as well as the ability to get the first and the last elements. Furthermore, there are also SequencedMap and SequencedSet interfaces.

interface SequencedCollection<E> extends Collection<E> {
// new method
SequencedCollection<E> reversed();
// methods promoted from Deque
void addFirst(E);
void addLast(E);
E getFirst();
E getLast();
E removeFirst();
E removeLast();
}

The introduction of Sequenced Collections represents a notable advancement for Java Collections. By fulfilling the longstanding requirement for a standardized approach to managing collections with a specified encounter order, Java enables developers to operate with greater efficiency and clarity. These new interfaces establish a more transparent framework and predictable behavior, leading to code that is both stronger and easier to understand.

String Templates

String templates, introduced as a preview feature in JDK 21, aim to enhance the reliability and usability of string manipulation in Java. They provide a mechanism to create template expressions that can be rendered into strings. This feature helps avoid common issues like injections, which can sometimes result in unintended outcomes. With string templates, developers can write expressions within a string template and easily generate the desired output. For example:

String codingLanguage = "Java";
String sentence = "${codingLanguage} is awesome";
System.out.println(sentence);
// Output:
// Java is awesome!

Record Patterns

In Java 14, the introduction of records provided a streamlined way to create data-centric classes, focusing solely on carrying data without the need for boilerplate code. This was a significant step forward, simplifying code and enhancing readability.

Now, with the advancements in Java 21, record patterns and type patterns can be nested, offering a more declarative and composable approach to data manipulation. This means developers can navigate and process data in a more intuitive and concise manner.

Before Java 21, accessing individual values from a record required deconstructing the entire record, which could lead to verbose code. However, with the introduction of record patterns and type patterns, this process has become much simpler and more efficient.

Let’s take a closer look at the provided example:

record Address(String city, String apartment) {}
static void printCityWithoutPatternMatching(Object obj) {
if (obj instanceof Address a) {
String city = a.city();
System.out.println(city);
}
}
static void printCityWithPatternMatching(Object obj) {
if (obj instanceof Address(String city, String apartment)) {
System.out.println(city);
}
}

In the printCityWithoutPatternMatching method, prior to Java 21, accessing the city required first checking if the object is an instance of Address, then retrieving the city using the accessor method. This approach works but can be verbose and less intuitive.

In contrast, the printCityWithPatternMatching method demonstrates the power of pattern matching introduced in Java 21. Here, the instanceof check is combined with pattern matching directly in the conditional statement. This results in cleaner and more concise code, where the city is extracted directly from the object without the need for explicit accessor methods.

Overall, the advancements in Java 21, particularly with the introduction of record patterns and type patterns, further simplify data manipulation and contribute to more elegant and readable code. This encourages developers to focus on the logic of their programs rather than getting bogged down in boilerplate code.

Conclusion

To sum it up, our exploration of Java’s changes from version 8 to 21 has shown us how far this language has come. We’ve seen many cool things like lambda expressions, new Time API, the Stream API, sealed classes, records etc. Each of these improvements has made Java better for solving modern problems in coding. Now armed with this new knowledge, you can take on coding challenges with confidence. Remember, Java is always changing and growing, so keeping up with its updates lets you do even more. Whether you’re new to coding or a pro, Java has lots to offer for creating cool stuff. Let these ideas guide you as you dive into the world of Java programming. Happy coding!

If you find this article interesting, kindly consider liking and sharing it with others, allowing more people to come across it.

If you’re curious about technology and software development, I invite you to explore my other articles. You’ll find a range of topics that delve into the world of coding, app creation, and the latest tech trends. Whether you’re a seasoned developer or just starting, there’s something here for everyone.

References

https://www.archunit.org/motivation

https://www.baeldung.com/java-21-sequenced-collections

https://www.baeldung.com/migrating-to-java-8-date-time-api#examples

--

--