A Journey From Java 8 to 21

Enes Harman
5 min readOct 19, 2024

--

Over the past few months, my company completed a migration from Java 8 to Java 21. Throughout this process, I had the opportunity to explore the key differences between the two versions. In this post, I’ll highlight the most significant features and improvements in Java 21 that developers familiar with Java 8 will encounter.

Private Interface Methods

Private interface methods allow interfaces to define helper methods that are not exposed to the implementing classes or the outside world. These methods can only be called by other methods within the same interface, enabling code reuse and maintaining encapsulation within the interface itself.

interface DatabaseConnector {
void connect();

default void logConnection(String dataBase, int port) {
writeFile(dataBase, port);
}

private void writeFile(String dataBase, int port) { //This method can only be called in this interface.
//file operation
}
}

class MySqlConnector implements DatabaseConnector{
@Override
public void connect() {
//Connect to MySql
logConnection("MySql", 3306);
}
}

In this example, we define a logger method that logs connection information to a log file. This method utilizes a private helper function declared within the same interface. Keep in mind, the private function is only accessible within the interface where it’s defined.

Sealed Classes

Sealed classes, introduced in Java 17, restrict which classes can extend or implement them. They are declared with the sealed keyword and use the permits clause to specify allowed subclasses. This ensures controlled inheritance, improving code safety and readability. Permitted subclasses must be either final, sealed, or non-sealed, defining whether they can be further extended or not.

sealed class MsgAdapter permits FirebaseAdapter, ApnsAdapter { //Permitted adapters
// Common adapter logic
}

final class FirebaseAdapter extends MsgAdapter {
// Android push logics
}

non-sealed class ApnsAdapter extends MsgAdapter {
// IOS push logics
}

This feature also can be used with interfaces:

sealed interface Logger permits FileLogger, DatabaseLogger {
void log();
}

non-sealed class FileLogger implements Logger {
@Override
public void log() {
//Log to File
}
}

final class DatabaseLogger implements Logger {
@Override
public void log() {
//Log to Database
}
}

Records

Records, introduced in Java 14 as a preview feature and finalized in Java 16, provide a compact way to create immutable, which means their fields can not be changed after the object is created, data classes. They automatically generate essential methods like equals(), hashCode(), and toString(), reducing boilerplate code.

record Message(String message, Long ts) {
public void printMessage() {
System.out.println(message);
}
}

Records are also able to hold metods. They are usualy used to create simple data transfer objects.

Pattern Matching for instanceof

Pattern Matching for instanceof simplifies type checks and casting in a more concise way. It allows you to test whether an object is an instance of a specific type and, if it is, automatically cast it to that type within a single operation. This feature avoids additional casting lines.

if(connector instanceof SqlConnector conn) {
// mysql connection
} else if(connector instanceof OracleConnector) {
OracleConnector conn = (OracleConnector) connector;
// oracle connection
}

In the example above, we have a connector object that can be an instance of two different classes. In the first condition, we utilize pattern matching, which enhances the code’s readability. In the second condition, we follow the traditional approach.

Switch Expressions

Switch expressions, introduced in Java 12 as a preview feature and finalized in Java 14, enhance the traditional switch statement by allowing it to return a value and supporting a more concise syntax. They improve readability and reduce boilerplate code.

String adapter = switch (platform) {
case IOS -> "ApnsAdapter";
case ANDROID -> {
// Some logic
yield "FcmAdapter"; // Yielding the value to the switch expression
}
case HUAWEI -> "HuaweiAdapter";
default -> throw new IllegalArgumentException("Invalid platform: " + platform.name());
};

Yield is a new statement that is used to return value. It works like a return but it is not a keyword. That means you can use ‘yield’ in your code as a variable name.

Keep in mind, default case is necessary for Switch Expression. Without default, the code will not be compiled.

Local-Variable Type Inference

Java often has lengthy class names, which can make the code less readable when these classes are used as variable types. The Local-Variable Type Inference feature helps alleviate this issue by allowing developers to declare variables without specifying the explicit type, reducing boilerplate code and enhancing readability.

Local-variable type inference, introduced in Java 10, allows developers to use the var keyword to declare local variables without explicitly specifying their types. The compiler infers the type based on the assigned value, making the code more concise and improving readability.

var names = List.of("Enes", "Ahmet", "Mustafa");
var age = 25;
var exception = new ArrayIndexOutOfBoundsException();

This feature has some limitations. Firstly, the variable must be assigned a value at the time of declaration. Secondly, these variables can only be declared within local scope, meaning they cannot be used as instance variables or class-level variables.

Vector API

The Vector API is a feature introduced as an incubating feature in Java 16 that provides a mechanism for expressing vectorized computations. It allows developers to write code that takes advantage of SIMD (Single Instruction, Multiple Data) instructions available on modern hardware, enabling more efficient processing of data in parallel.

int[] a = {1, 2, 3, 4};
int[] b = {5, 6, 7, 8};
int[] result = new int[a.length];

var vectorA = IntVector.fromArray(IntVector.SPECIES_256, a, 0);
var vectorB = IntVector.fromArray(IntVector.SPECIES_256, b, 0);

var vectorResult = vectorA.add(vectorB);

vectorResult.intoArray(result, 0);

System.out.println(java.util.Arrays.toString(result)); // Outputs: [6, 8, 10, 12]

In the example above, we define arrays as the inputs and output for our operation. We then create our vectors based on the a and b arrays. The operation vectorA.add(vectorB) is performed on the vector units of the processor, making it significantly faster than a scalar operation

If you want to learn more about vector operations and SIMD processors, feel free to check my previous article.

Conclusion

These are just a few of the features that have been introduced in Java with each version since Java 8. While learning and adapting to these changes may take some time, I believe this post serves as a solid introduction for developers looking to explore the new enhancements and capabilities that Java has to offer.

--

--

Enes Harman

I’m a computer engineer passionate about deeply understanding and clearly sharing fundamental concepts.