Java Language Evolution: A Guide to Features from Java 8 to 21

Subsequent language enhancements beyond Java 8

Madhan Kumar
14 min readFeb 15, 2024
JAVA

Introduction:

Embark on a concise journey through Java’s transformation from version 8 to 21. Explore the language’s evolution, uncovering key features that have reshaped coding practices and elevated the developer experience. Whether you’re familiar with Java 8 or intrigued by the latest innovations, this brief exploration offers insights into the impactful enhancements introduced across these significant versions. Dive into the evolution of Java, where each release brings fresh capabilities and refines the programming landscape.

JAVA 9 ENHANCEMENTS :

Java 9 brings with it a host of notable changes and improvements :

Interface Private Methods :

Introduction of private methods within interfaces.

Ever since the release of Java 8, the addition of default methods to interfaces has become a possibility. Now, with the arrival of Java 9, these default methods have the added capability of calling private methods, making it convenient for developers to reuse code without having to expose it to the public. While it may not be a monumental update, this logical addition effectively improves organization within default methods.

Java 8 example :

public interface Shape {

double getjaArea(); // Abstract method

double getPerimeter(); // Abstract method

default void printDetails() {
System.out.println("Area: " + getArea());
System.out.println("Perimeter: " + getPerimeter());
// Calculate and print additional details directly here (less reusable)
}
}

Java 9 example :

public interface Shape {

double getArea(); // Abstract method

double getPerimeter(); // Abstract method

default void printDetails() {
System.out.println("Area: " + getArea());
System.out.println("Perimeter: " + getPerimeter());
printSpecificDetails(); // Calls private method for reusable logic
}

private void printSpecificDetails() {
// Calculate and print additional details in a reusable way
}
}

Here, the printDetails only handles common actions. The specific calculations are encapsulated in a private method printSpecificDetails. This promotes better organization, reusability, and easier maintenance of the interface and its implementations.

While this doesn’t directly compare Java 8 and Java 9, it hopefully clarifies how private methods enhance code structure and reusability when available.

Diamond Operator for Anonymous Inner Classes :

The ability to use the diamond operator with anonymous inner classes for more concise code.

Java 7:

import java.util.*;

public class Example {
public static void main(String[] args) {
// Java 7: No Diamond Operator with anonymous inner class
List<String> languagesJava7 = new ArrayList<String>() {
{
add("Java");
add("C++");
add("Python");
}
};
// Print the list
System.out.println("Languages in Java 7: " + languagesJava7);
}
}

Java 8 and java 9:

import java.util.*;

public class Example {
public static void main(String[] args) {
// Java 9: Full Diamond Operator support with anonymous inner class
List<String> languagesJava9 = new ArrayList<>() {
{
add("Java");
add("C++");
add("Python");
}
};
// Print the list
System.out.println("Languages in Java 9: " + languagesJava9);
}
}

In this example, languagesJava7 is initialized without using the Diamond Operator, as this was the typical syntax prior to Java 8 and 9. languagesJava9, on the other hand, takes advantage of the Diamond Operator, demonstrating the improved syntax introduced in Java 8.

Try-With-Resources Enhancement :

Permitting effectively-final variables to be utilized as resources in try-with-resources statements.

Another improvement brought about by Java 7 is the try-with-resources, relieving the developer from the need to be concerned about resource release.

To illustrate its power, first consider the effort made to properly close a resource.

Java 7 Example :

BufferedReader br = new BufferedReader(...);
try {
return br.readLine();
} finally {
if (br != null) {
br.close();
}
}

Introduced the flexibility of using effectively-final variables as resources, even if they don’t implement AutoCloseable. This provides additional convenience and reduces boilerplate code.

Java 8 & 9 Example :

try (final InputStream in = new FileInputStream("file.txt")) {
// Use in
} catch (IOException e) {
// Handle exception
}

Despite its power, try-with-resources had a few shortcomings that Java 9 addressed.

Using try-with-resources makes it possible for resources to be effortlessly released, removing the need for lengthy formal procedures.

Identifier Naming Update :

The underscore character (_) is no longer considered a valid identifier name.

In Java 8, the compiler emits a warning when ‘_’ is used as an identifier.

// It returns warning and runs successfully
int _ = 10;

System.out.println("Value "+_);

Java 9 took this a step further by making the sole underscore character illegal as an identifier, reserving this name to have special semantics in the future.

// Compile error in Java 9+
// unless you run Java 21 with preview features enabled
int _ = 10;

System.out.println("Value "+_);

JAVA 11 ENHANCEMENTS :

One of the most noteworthy advancements in the language, surpassing Java 8, is the introduction of the ‘var’ type name. Initially unveiled in Java 10, it underwent further refinements in Java 11.

This functionality enables a streamlined approach to declaring local variables by eliminating the need for explicit type specifications, thereby reducing verbosity in the code.

Simple Variable Initialization :

// Before: String message = "Hello!";
var greetingMessage = "Hello!";
System.out.println("Message : " + greetingMessage);

Output:

Message : Hello!

Iterating over a List :

// Before: List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
var names = Arrays.asList("Alice", "Bob", "Charlie");
System.out.println("list :" + names);

Enhanced for Loop :

// Before: for (Map.Entry<String, Integer> entry : map.entrySet())
for (var entry : map.entrySet()) {
// Process each entry
}

Using try-with-resources :

// Before: try (BufferedReader reader = new BufferedReader(new FileReader("example.txt")))
try (var reader = new BufferedReader(new FileReader("example.txt"))) {
// Read from the file
} catch (IOException e) {
// Handle exception
}

Lambda Expressions :

// Before: Function<String, Integer> strLength = (String s) -> s.length();
var strLength = (String s) -> s.length();

Method Reference :

// Before: Comparator<String> comparator = String::compareTo;
var comparator = String::compareTo;

Instance Creation with Anonymous Class :

// Before: Runnable runnable = new Runnable() { public void run() { System.out.println("Running"); } };
var runnable = new Runnable() { public void run() { System.out.println("Running"); } };

Using var with Generics :

// Before: Map<String, List<Integer>> data = new HashMap<>();
var data = new HashMap<String, List<Integer>>();

JAVA 14 ENHANCEMENTS :

Switch Expressions :

The traditional switch statement received an update in Java 14. While Java continues to support the conventional switch statement, it introduces the new switch expression to the language.

The switch expression is a feature introduced in Java 12 as a preview feature and enhanced in Java 13 before becoming standard in Java 14. The main difference lies in the introduction of the enhanced switch statement, which is now more flexible and can be used as an expression, allowing it to produce a value.

Example 1 :

public class Main {
public static void main(String[] args) {
String day = "MONDAY";
int numLetters = switch (day) {
case "MONDAY", "FRIDAY", "SUNDAY" -> 6;
case "TUESDAY" -> 7;
default -> {
String s = day.toString();
int result = s.length();
yield result;
}
};
System.out.println("NUM: " + numLetters);
}
}

In Java 14, the switch statement can be used as an expression, allowing you to assign its result directly to a variable (numLetters in this case).

Multiple Values in a Case :

case "MONDAY", "FRIDAY", "SUNDAY" -> 6;

This demonstrates the ability to have multiple values for a single case, reducing redundancy in code.

Single-Value Case :

case "TUESDAY" -> 7;

In this case, a single value is returned directly without the need for additional braces.

Default Case with Block :

default -> {
// Block of code for the default case
}

The default case includes a block of code. In this example, it converts the day variable to a string, calculates its length, and then uses the yield keyword to produce the result.

Yield Keyword:

yield result;

The yield keyword is used to produce a value from the switch expression. In this case, it returns the result calculated in the default case.

This enhancement in Java 14 provides a more concise and expressive way to handle multiple conditions with the switch statement. The switch expression makes the code cleaner and more readable by reducing the need for boilerplate code often associated with switch statements.

Example 2 :

No Fall-Through :

Unlike traditional switch statements, switch expressions in Java don’t have fall-through behavior. This eliminates subtle bugs caused by missing break statements.

To handle multiple constants for a single case, you can specify them in a comma-separated list. This avoids the need for fall-through, making the code more straightforward and less error-prone.

String result = switch (k) {
case 1 -> {
String description = "one";
yield description;
}
case 2 -> {
String description = "two";
yield description;
}
default -> "many";
};

The switch expression evaluates the value of k and performs the corresponding case.

Each case block is enclosed in curly braces, providing a distinct scope for local variables like description.

The yield keyword is used to return a value from each case.

The final result is assigned to the result variable.

These enhancements in switch expressions contribute to more robust and readable code by addressing common pitfalls associated with fall-through and scoping in traditional switch statements.

Example 3 :

Enums in Java necessitate the inclusion of either a default case or the explicit coverage of all possible cases. Opting for the latter is a prudent approach, ensuring meticulous consideration of all enum values. Introducing an additional value to the enum will trigger a compilation error for all switch expressions utilizing it, fostering code integrity and preventing oversights.

enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

Day day = Day.TUESDAY;
String expressionResult = switch (day) {
case MONDAY -> ":(";
case TUESDAY, WEDNESDAY, THURSDAY -> ":|";
case FRIDAY -> ":)";
case SATURDAY, SUNDAY -> ":D";
};

System.out.println("Day " + expressionResult);

Enum Definition :

The Day enum represents the days of the week.

Switch Expression :

The switch expression evaluates the day variable, which is set to Day.TUESDAY.

Each case represents a day with associated expressions (emoticons in this case).

Handling All Enum Values :

For enums, it’s crucial to either have a default case or explicitly cover all possible enum values.In this example, all days are explicitly covered, ensuring that any addition to the Day enum triggers a compile error for all switch expressions.

Example 4 :

A switch expression is not limited to the lambda-like arrow-form cases. The conventional switch statement, with its colon-form cases, can also be employed as an expression by utilizing the ‘yield’ keyword:

String result ="foo";

int result = switch (s) {
case "foo":
yield 2;
case "bar":
yield 2;
default:
yield 3;
};

System.out.println("Result "+result);

This variation can also serve as an expression, resembling the traditional switch statement more closely due to the Cases allow fall-through and Cases share the same scope.

JAVA 15 ENHANCEMENTS :

Text Blocks :

Since Java 14, unlike other contemporary programming languages, expressing multiline text in Java was notoriously difficult.

Before java 15 :

html = ""
html += "<html>\n"
html += " <body>\n"
html += " <p>Hello, world</p>\n"
html += " </body>\n"
html += "</html>\n"

System.out.println(html);

Output :

<html>
<body>
<p>Hello, world</p>
</body>
</html>

To enhance the developer experience, Java 15 introduced Text Blocks, a feature that facilitates the creation of multi-line string literals in a more programmer-friendly manner.

After Java 15 :

html = """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
"""

System.out.println(html);

Output :

<html>
<body>
<p>Hello, world</p>
</body>
</html>

Resembling the traditional String literals, Text Blocks in Java offer the advantage of accommodating new lines and quotes without the necessity for escape characters.

Text Blocks are initiated with “”” followed by a new line, and conclude with “””. The closing token can be positioned at the end of the final line or on a separate line, as illustrated in the example above.

For each line-break in the source code, there will be a \n character in the result.

Providing a practical solution for cases where lengthy lines need to be split is achievable by concluding the line with the \ character.

Example :

String singleLine = """
Hello \
World
""";

System.out.println(html);

Output :

Hello World

JAVA 16 ENHANCEMENTS :

Record Classes :

Record classes bring a novel type declaration to the language, offering a streamlined way to define immutable data classes. Rather than the customary formalities involving private fields, getters, and constructors, they provide a more concise syntax that enables us to.

public record Person(int x, int y, int z) { }

The Record Class above is much like a regular class that defines the following:

two private final fields, int x and int y

a constructor that takes x and y as a parameter

x() and y() methods that act as getters for the fields

hashCode, equals and toString, each taking x and y into account

Example :

In Point Class :

package com.esg;

public record Point(int x, int y, int z) {
// No need to explicitly declare fields or methods; they are implicitly present
}

In RecordsExample Class :

public class RecordsExample {

public static void main(String[] args) {
// Using the Person record
Point point = new point(10,20,30);

// Accessing fields of the record
int x = point.x();
int y = point.y();
int z = point.z();

// Displaying information
System.out.println("X: " + x);
System.out.println("Y: " + y);
System.out.println("Z: " + z);
}
}

Output :

X: 10
Y: 20
Z: 30

Record Classes are designed as transparent carriers of shallowly immutable data, adhering to a set of constraints to support this purpose.

In a Record Class, fields are not only inherently final but are entirely restricted from having any non-final fields.

The header of the definition must comprehensively specify all possible states, disallowing the addition of extra fields in the body of the Record Class. While additional constructors can be defined for default values, the canonical constructor that takes all record fields as arguments cannot be hidden.

Record Classes are limited in their capabilities; they cannot extend other classes, declare native methods, are implicitly final, and cannot be abstract.

Record classes in Java can implement interfaces. You can declare any number of interfaces in the record’s signature:

record Point(int x, int y) implements Serializable {
// ... implementation
}

Data can only be supplied to a record through its constructor, which, by default, is an implicit canonical constructor. If data validation or normalization is necessary, the canonical constructor can also be explicitly defined.

JAVA 17 ENHANCEMENTS :

Sealed Classes :

Sealed classes and interfaces offer a means to limit the classes or interfaces that can extend or implement them. They serve as a valuable tool for enhancing the design of public APIs and present an alternative to Enums for representing a fixed number of alternatives.

In previous Java versions, certain mechanisms were available to achieve similar objectives. Classes labeled with the final keyword were not open for extension, and access modifiers could ensure that classes were only extended within the same package.

Sealed classes, building upon these existing features, introduce a more nuanced approach by enabling authors to explicitly enumerate the allowed subclasses.

public sealed class Shape permits Circle, Square, Triangle {
// Common methods or fields for all subclasses can be defined here

// The subclasses will be explicitly listed after 'permits'
}

final class Circle extends Shape {
private final double radius;

public Circle(double radius) {
this.radius = radius;
}

// Additional methods or fields specific to Circle
}

final class Square extends Shape {
private final double side;

public Square(double side) {
this.side = side;
}

// Additional methods or fields specific to Square
}

final class Triangle extends Shape {
private final double side1;
private final double side2;
private final double side3;

public Triangle(double side1, double side2, double side3) {
this.side1 = side1;
this.side2 = side2;
this.side3 = side3;
}

// Additional methods or fields specific to Triangle
}

In this example, the Shape class is declared as sealed using the sealed keyword, and it permits the subclasses Circle, Square, and Triangle. Each of these subclasses is marked as final, and they provide their specific implementations. Sealed classes, in this context, offer a fine-grained approach by explicitly listing the allowed subclasses (permits). This ensures that only the specified subclasses can extend the sealed class.

Authors are forced to always explicitly define the boundaries of a sealed type hierarchy by using exactly one of the following modifiers on the permitted subclasses:

final: the subclass can not be extended at all

sealed: the subclass can only be extended by some permitted classes

non-sealed: the subclass can be freely extended

JAVA 21 ENHANCEMENTS :

Pattern Matching for Switch :

In the past, the switch statement had limitations; it could only check for exact equality and was restricted to a few types such as numbers, Enum types, and Strings.

This enhancement expands the switch statement’s capabilities, enabling it to operate on any type and match more intricate patterns.

Importantly, these improvements maintain backward compatibility, meaning that the switch statement continues to function seamlessly with traditional constants. For instance, it behaves as expected with Enum values, just as it did before these enhancements were introduced.

   Object input = 10; // Replace with any value of different types
String result = switch (input) {
case String s when s.length() > 5 -> "It's a long String with length greater than 5";
case String s when s.length() <= 5 -> "It's a short String with length 5 or less";
case Integer i when i % 2 == 0 -> "It's an even Integer";
case Integer i when i % 2 != 0 -> "It's an odd Integer";
case Double d when d > 0 -> "It's a positive Double";
case Double d when d < 0 -> "It's a negative Double";
default -> "It's something else";
};

System.out.println("Result: " + result);

Output :

It's an even Integer

String Templates : (Preview Feature)

The use of string templates in Java 21 is currently classified as a preview feature. It should be noted that these are not considered a permanent addition and may undergo modifications or even be removed in subsequent versions of the language.

String Templates represent an expansion of the capabilities found in single-line String literals and Text Blocks, offering enhanced features such as String interpolation.

In earlier Java versions, achieving String interpolation involved considerable manual effort. Common approaches included using the + operator or StringBuilder. While String::format was another option, it introduced the risk of inadvertently providing an incorrect number of arguments.

public class RecordPatternExample {
public static void main(String[] args) {
// Create an instance of Point record
var name = "Duke";
var info = STR."My name is \{name}";

// Pass the Point record to the processObject method
System.out.println(info);
}
}

Instance Main Methods : (Preview Feature)

Instance main methods are considered an experimental feature in Java 21. As such, they have not been fully incorporated into the official version of the language and may potentially undergo changes or even be removed in future updates.

In previous versions of Java, creating even the most basic applications required a substantial amount of boilerplate code.

Instance main methods introduce greater flexibility into the Java launch protocol, rendering certain aspects of the main method optional.

Henceforth, visibility becomes inconsequential, given that the main method need only be non-private. The String[] parameter can be omitted, and the main method can take the form of an instance method.

With the ability to define main methods in multiple ways, the updated launch protocol establishes priorities to determine the preferred choice:

Static main with arguments :

public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}

This is a static way of main class.

Static main without arguments :

public class HelloWorld {
public static void main() {
System.out.println("Hello, World!");
}
}

In this example, the main method is defined without the traditional String[] args parameter. Note that this deviates from the standard convention and may not be the recommended practice in most scenarios. The main method without arguments won't be recognized as the standard entry point by the Java Virtual Machine (JVM), and you would need to manually call it from within another method or class.

In a real-world scenario adhering to Java conventions, it’s recommended to use the standard public static void main(String[] args) signature to maintain compatibility with the Java launch protocol.

Instance main with arguments :

public class MainExample {
public void main(String[] args) {
System.out.println("Executing instance main with arguments");
for (String arg : args) {
System.out.println("Argument: " + arg);
}
}

public static void main(String[] args) {
MainExample instance = new MainExample();
instance.main(args);
}
}

In this example, there is an instance method named main that takes a String[] args parameter. The standard static main method creates an instance of the class and calls the instance method, passing the command-line arguments.

Instance main without arguments :

public class MainExample {
public void main() {
System.out.println("Executing instance main without arguments");
}

public static void main(String[] args) {
MainExample instance = new MainExample();
instance.main();
}
}

In this example, there is an instance method named main without any parameters. The standard static main method creates an instance of the class and calls the instance method.

This is not a standard or recommended practice in Java. The standard entry point is a static main method, and deviating from this convention may lead to confusion and decreased maintainability of the code.

Conclusion :

This article has delved into the advancements introduced in the Java language post-Java 8. Staying vigilant about the Java platform is crucial, given the accelerated release cadence where a fresh Java version emerges every six months, ushering in modifications to both the platform and the language.

--

--

Madhan Kumar
Madhan Kumar

Written by Madhan Kumar

Software Engineer By Profession (Java) | Blogger | Full Stack Developer | Python | AI Explorer