Better Programming

Advice for programmers.

Follow publication

The Evolution of Java

The most important language enhancements that have been added to Java in the last 20 years

David Übelacker
Better Programming
Published in
12 min readApr 8, 2023

--

Photo by Jessica Lewis on Unsplash

On January 23, 1996, Java was first released, and over the years, it has undergone significant changes. I started working with Java in the early 2000s, using J2SE 1.3, which lacked features that are now commonplace. Java 5 introduced generics, “for each” loops, annotations, autoboxing, and unboxing, resulting in more modern Java code.

Back then Java code looked like this:

List users = new ArrayList();
users.add(new User("John"));
users.add(new User("Mary"));

for (Iterator iterator = users.iterator(); iterator.hasNext(); ) {
User user = (User) iterator.next();
System.out.println(user.getName());
}

Java 5 finally brought us generics, for each, annotations, autoboxing and unboxing, so todays java code looks normally more like this:

List<User> users = new ArrayList<>();
users.add(new User("John"));
users.add(new User("Mary"));

for (User user : users) {
System.out.println(user.getName());
}

Though I haven’t used Java as my primary language in the past six years, I recently returned to it and have familiarized myself with the changes and new APIs that have been integrated mostly since Java 8. In this post, I will highlight some of the most significant language enhancements that have been added to Java in the last 20 years.

Optionals

Optionals were introduced in Java 8 as a way to handle null values in a more robust and safer way. Prior to Java 8, handling null values in Java code could be error-prone and could lead to NullPointerExceptions.

An Optional in Java is a container object that may or may not contain a value. It is used to represent the case where a value might not be present, instead of using a null reference. The Optional class provides a set of methods for working with the value it contains or for handling the case where the value is not present.

By using Optional, Java developers can write cleaner, more expressive code that is easier to read and maintain. Optionals can also help to reduce the risk of NullPointerExceptions, which can be a common source of bugs in Java code.

A simple example:

Optional<String> optionalString = Optional.ofNullable(getString());
if (optionalString.isPresent()) {
String string = optionalString.get();
System.out.println(string);
} else {
System.out.println("The string is not present");
}

Especially when you need to access a value in a deep object structure, optionals make life difficult. Instead of testing null on every level like without optionals:

String street = null;

if (employee != null && employee.getAddress() != null) {
street = employee.getAddress().getStreet();
} else {
street = "No street";
}

you can do something like:

String street = Optional.ofNullable(employee)
.map(Employee::getAddress)
.map(Address::getStreet)
.orElse("No street");

Streams

Streams are a new addition to the Java Collections API and provide a powerful way to process and manipulate collections of data. With the introduction of streams, Java programmers can easily write code that is more concise, readable, and expressive when working with collections. The Stream API provides a fluent and functional programming style that allows developers to perform complex operations on collections of data with ease.

Streams should be state of the art for you, they were already introduced in Java 8 (2014). Nevertheless, I noticed that there are still Java programmers who don’t like streams because they never really got into them.

forEach and filter

Let’s assume we have a list of users and want to call the setLocked(true) method on all users that have not yet been locked. The old way we would do it like this:

for (User user : users) {
if (!user.isLocked()) {
user.setLocked(true);
}
}

With streams we write the same with the help of the functions filter and forEach as follows:

users.stream()
.filter(u -> !u.isLocked())
.forEach(u -> u.setLocked(true));

It’s much nicer, isn’t it? I know for old hands it’s harder to read at first, but with time you will learn to love this streams 😇!

map and reduce

Another thing to do regularly is to calculate a single value from a collection. For example, calculate the sum of all salaries of a list of employees.

The old way:

Double totalSalary = 0.0;

for (Employee employee : employees) {
totalSalary += employee.getSalary();
}

and with streams:

Double totalSalary = employees.stream()
.map(Employee::getSalary)
.reduce(0.0, (a, b) -> a + b);

Nice, right 😁 ? And you can even improve it a little bit:

Double totalSalary = employees.stream()
.map(Employee::getSalary)
.reduce(0.0, Double::sum);

Tip: If you’re not already using streams, give them a try. However, keep in mind your colleagues and future maintainers of your code and avoid overusing them. It’s often better to break down complex transformations into several steps rather than trying to write everything in one long-expression.

Lambda Expressions

Lambda expressions provide a way to pass behavior as a parameter to a method. They also have been introduced with Java 8. I’m sure you are using them already all the time. But do you also implement your own methods of accepting lambdas?

Suppose you need a method that returns two values. There are other languages like Ruby that can return multiple values. Java does not allow this.

One option is to put the values in an array and return the array, although this may not be the most elegant solution. Another option is to define an extra class to hold the values, but this can be costly in terms of time and resources.

However, there is a more elegant solution: with a lambda expression 😇:

User user = new User("John Doe", false);

extractFirstAndLastName(user, (firstName, lastName) -> {
System.out.println("First name: " + firstName);
System.out.println("Last name: " + lastName);
});

You just write a method, that accepts a lambda as callback, that gets called with the result values.

To create such a method, you need to define an interface with the @FunctionalInterface describing the expected callback lambda:

@FunctionalInterface
public interface ExtractNames {
void extract(String firstName, String lastName);
}

And then you can create the method with a parameter of that interface type:

public void extractFirstAndLastName(User user, ExtractNames callback) {
String firstName = user.getName().split(" ")[0];
String lastName = user.getName().split(" ")[1];

callback.extract(firstName, lastName);
}

Instead of implementing the callback, you can now just pass an lambda expecting firstName and lastName as parameters.

LocalDate, LocalTime and LocalDateTime

There are two things you will always have problems with as a developer. Encoding and time. I have wanted to start support groups for both of these several times 😂.

Java 8 has given us LocalDate, LocalTime, LocalDateTime, ZonedDateTime, Duration and Period. A completely new API for date and time, making life much easier.

Remember how often you did the following, to get a date “without” time?:

Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date());
calendar.set(Calendar.MILLISECOND, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.HOUR, 0);

Date date = calendar.getTime();

Now you can just do:

LocalDate date = LocalDate.now();

Don’t want to get into detail about the new API, but here is another example of how to use the new API, compared to the old one.

Old API:

SimpleDateFormat format = new SimpleDateFormat("yyyy-MMM-dd HH:mm:ss z");
format.setTimeZone(TimeZone.getTimeZone("UTC"));
System.out.println(format.format(new Date()));

New API:

ZonedDateTime dateTime = ZonedDateTime.now(ZoneId.of("UTC"));
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm:ss z");
System.out.println(formatter.format(dateTime));

Overall, the new Date-Time API in Java 8 provides a more modern and comprehensive set of classes for working with dates and times, with better support for time zones, daylight saving time, and more.

If you are still messing around with the old dates, you should finally start using the new API.

var

Java 10 introduced the “var” keyword to simplify the syntax of declaring local variables, and to reduce boilerplate code. Prior to Java 10, when declaring a variable, developers had to explicitly specify its type, even when the type could be easily inferred from the expression that initializes the variable.

The “var” keyword allows developers to declare local variables without specifying their types explicitly, and instead, the compiler infers the type of the variable from the context in which it is used. This can make code more concise and easier to read, and it can also reduce the risk of errors caused by mistyping the variable type.

Overall, the introduction of the “var” keyword in Java 10 is a small but significant improvement to the language syntax, which can make code more concise and easier to read, while maintaining the type safety that is a key feature of the Java language.

String name = "John Doe";
Integer age = 30;
Double salary = 1000.0;

becomes:

var name = "John Doe";
var age = 30;
var salary = 1000.0;

With Java 11 the var feature has been extended to lambda parameters. Instead of writing something like:

List<String> strings = Arrays.asList("apple", "banana", "cherry");
strings.sort((String s1, String s2) -> s1.length() - s2.length());

You can write:

List<String> strings = Arrays.asList("apple", "banana", "cherry");
strings.sort((var s1, var s2) -> s1.length() - s2.length());

HTTP client

Before Java 11 you had to use third-party libraries to make HTTP requests. Java had its own API represented byHttpURLConnection and HttpClient, but this was very complex and had several limitations.

Java 11 includes a new built-in HTTP which is a significant improvement over the old APIs, providing a modern and easy-to-use API for sending HTTP requests and receiving responses in Java applications. It also provides several advanced features, such as support for HTTP/2, authentication, and retry policies, which can help developers build high-performance and reliable applications that interact with web services.

A simple example:

package http;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.concurrent.CompletableFuture;

public class HttpClientExample {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/v1/data"))
.header("Authorization", "Bearer your-bearer-token")
.GET()
.build();

// send the HTTP request asynchronously and handle the response
CompletableFuture<HttpResponse<String>> response
= client.sendAsync(request, BodyHandlers.ofString());

// wait for the response to complete and handle any errors
HttpResponse<String> httpResponse = response.join();
if (httpResponse.statusCode() == 200) {
// print the response body
System.out.println(httpResponse.body());
} else {
// handle the error response
System.err.println("HTTP error " +
httpResponse.statusCode() +
": " + httpResponse.body());
}
}
}

Text Blocks

Text Blocks is a feature introduced in Java 13 that allows for the creation of multi-line string literals with a more readable syntax.

Prior to Java 13, creating multi-line strings required the use of escape characters or concatenating multiple strings, which could result in code that was difficult to read and maintain. With Text Blocks, the need for escape characters and string concatenation is eliminated, making the code more concise and easier to read.

The syntax for Text Blocks uses three double quotes to mark the beginning and end of the block, with the content of the string appearing between the opening and closing markers. Text Blocks also allow for the inclusion of leading and trailing white space, which is useful for formatting purposes.

String html = """
<html>
<head>
<title>Example</title>
</head>
<body>
<h1>Hello, world!</h1>
</body>
</html>
""";

Switch Statements

In the first Java version, you could only use the types short, char, int and byte for switch statements. Java 5, added support for switch statements with enums, Java 7 added support for using strings in switch statements and with Java 12 switch expressions have been introduced.

A classic switch statement looks like this:

int day = 1;

switch (day) {
case 1:
System.out.println("Monday");
break;
case 2:
System.out.println("Tuesday");
break;
default:
throw new IllegalStateException("Unexpected value: " + day);
}

With expressions you can do things like:

switch (day) {
case 1, 2, 3, 4, 5 -> System.out.println("Weekday");
case 6, 7 -> System.out.println("Weekend");
default -> throw new IllegalStateException("Unexpected value: " + day);
}

and also directly return the result of a switch:

public static String test(Day day) {
return switch (day) {
case MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY -> "Weekday";
case SATURDAY, SUNDAY -> "Weekend";
};
}

In Java 17 pattern matching in switches has been introduced as a preview (still in preview in current Java version 19):

Object obj = "Hello";
int length = switch (obj) {
case String s && s.length() > 0 -> s.length();
case Integer i -> i;
default -> 0;
};
System.out.println("Length is " + length);

Records

A Java record is a new feature introduced in Java 16 (JEP 395) that provides a concise way to declare a simple class that is used primarily to store data. It is similar to a class, but its primary purpose is to represent a data record or a data transfer object (DTO) rather than a complex behavior or functionality.

A record is defined using the record keyword, followed by the class name and a list of component fields, which define the data that the record represents.

Instead of writing something like:

public class User {
private String firstName;
private String lastName;
private int age;

public UserOld(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}

public String firstName() {
return firstName;
}

public String lastName() {
return lastName;
}

public int age() {
return age;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserOld userOld = (UserOld) o;
return age == userOld.age && Objects.equals(firstName, userOld.firstName) && Objects.equals(lastName, userOld.lastName);
}

@Override
public int hashCode() {
return Objects.hash(firstName, lastName, age);
}

@Override
public String toString() {
return "UserOld{" +
"firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
", age=" + age +
'}';
}
}

You can just write:

public record User( String firstName, String lastName, int age ) { }

As you see records automatically give you accessor methods and the implementation of equals, hashCode and toString.

Sealed Classes

Sealed classes are a new feature introduced in Java 15 (JEP 360) that allows developers to restrict the subclasses of a class or interface to a predefined set of classes. Sealed classes provide more control over class hierarchies and help to make code more maintainable and secure.

To declare a sealed class or interface, you use the sealed keyword followed by the class or interface name, and a list of permitted subclasses, which can be either other classes or interfaces. For example:

public sealed class Shape permits Circle, Rectangle, Square {
...
}

public final class Square extends Shape {
...
}

public final class Rectangle extends Shape {
...
}

public final class Square extends Shape {
...
}

No other class beside Circle, Rectangle, and Square can extend in this example the class Shape.

Pattern Matching for instanceof

Pattern Matching for instanceof is a feature introduced in Java 16 that simplifies the common use case of checking the type of an object before performing an operation on it.

Prior to Java 16, if you wanted to perform an operation on an object based on its type, you would typically use a combination of instanceof and a cast, like this:

if (obj instanceof String) {
String s = (String) obj;
// do something with s
} else if (obj instanceof Integer) {
Integer i = (Integer) obj;
// do something with i
} else {
// do something else
}

With Pattern Matching for instanceof, you can simplify this code by combining the type check and the cast into a single expression, like this:

if (obj instanceof String s) {
// do something with s
} else if (obj instanceof Integer i) {
// do something with i
} else {
// do something else
}

Outlook

Looking ahead, there are already some exciting enhancements in the works for future versions of Java. For example, in Java 21 the next LTS release, which is expected to be released in September 2023, there are plans to introduce new features such as:

  • Virtual threads
  • Sequenced collections
  • String templates

It’s clear that Java will continue to evolve and improve in the years to come, building on its strong foundation to provide developers with the tools they need to create high-quality, efficient, and reliable software. Whether you’re a seasoned Java developer or just starting out, it’s an exciting time to be a part of the Java community!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

David Übelacker
David Übelacker

Written by David Übelacker

Fullstack Developer & Software Architect | In love with Web & Mobile Applications

Responses (5)

Write a response