DEV Community

Anna Voronina
Anna Voronina

Posted on

The Hitchhiker's Guide to LTS: Key changes when upgrading from Java 8 to Java 11

This is the first article in a series on what developers can expect when upgrading between LTS versions of Java. In this part, we'll look at the key changes that programmers will encounter when switching from Java 8 to Java 11.

1337_hitch_guide_to_the_lts_8_11/image1.png

Introduction

When Java 25 was released, we published an article about it. Its author highlighted the main changes and discussed how convenient and exciting they're for developers. After the publication, one of our readers reached out and said they'd like us to cover the challenges developers face when moving from one LTS version of Java to the next, starting with Java 8.

After thinking about it, we decided, "Why not?" After all, when reading various blogs, one often comes across comments from developers who say that they're still using Java 8. For them, an article like that can spark serious consideration about making the jump. For everyone else, it's simply a pleasant retrospective.

We're kicking off this series by comparing Java 8 with the next LTS release: Java 11.

LTS who?
LTS (Long Term Supported) is a software release model where certain stable versions receive extended support, including security updates, bug fixes, and technical support, for a longer period than standard releases.

First, let's take a look at some Java 8 features. It introduced some pretty advanced changes, and since some people are still using it 11 years later, let's review the most important ones.

First things that come to mind are Stream API, lambdas, and references to methods and constructors that transform constructs like these:

List<User> activeUsers = new ArrayList<>();
for (User user : users) {
  if (user.isActive()) {
    activeUsers.add(user);
  }
}

activeUsers.sort(new Comparator<User>() {
  @Override
  public int compare(User u1, User u2) {
    return u1.getCreatedAt().compareTo(u2.getCreatedAt());
  }
});

List<UserDto> result = new ArrayList<>();
for (User user : activeUsers) {
  result.add(UserMapper.toDto(user));
}
Enter fullscreen mode Exit fullscreen mode

Into more concise ones:

List<UserDto> result = users.stream()
        .filter(User::isActive)
        .sorted(Comparator.comparing(User::getCreatedAt))
        .map(UserMapper::toDto)
        .collect(Collectors.toList());
Enter fullscreen mode Exit fullscreen mode

Let's not forget the many functional interfaces that made the above-mentioned constructs possible. Here are some of them:

Implementations for default methods in interfaces appeared in Java 8.

Java's first steps toward functional-style constructs caused a real stir in 2014. However, time doesn't stand still, and four years later the world saw the next LTS release—Java 11.

So, did anything significant happen to the language between versions 8 and 11? Let's take a look.

Changes developers can expect in Java 11

I'd like to point out that this article is just a summary of the things Java programmers should focus on first, not a complete list of changes. Some of them may be a sticking point, because in certain cases, they may prevent your Java 8 project from running in the Java 11 environment. Others help streamline code thanks to new language features and constructs. Well, let's get it started.

Enough said. var, JEP 286

I'd like to remind that Java is a strongly typed language, meaning that all types must be known at compile time. Before Java 10, programmers had to specify a variable's full type when initializing it.

However, since the compiler can determine the type from the initialization expression, there's no reason to write it out, especially if it's long and awkward. Why not hide that redundant representation behind a special keyword?

JEP 286 made this possible. Instead of something like an abstract StateDatabaseHelperContainerMapMessage, we can now use the concise var:

var stateDbHelper = new StateDatabaseHelperContainerMapMessage();
Enter fullscreen mode Exit fullscreen mode

P.S. I'd like to thank him for the enterprise name.

This is an excellent approach that is definitely worth adopting. Still, keep in mind that the initialization expression should make it clear what kind of object we're dealing with. The examples below illustrate how not to use var:

var x = foo();
var data = get();
Enter fullscreen mode Exit fullscreen mode

If the type isn't clear from the variable name and initializing expression, it's better to use explicit typing!

The module system in Java. JEP 261

Before Java 9, a Java project—whether an application or a library—was simply a set of classes loaded via the classpath. It was just a list of necessary classes, JAR files, and the directories that contained them. This architectural approach presented developers with the following issues:

  • Lack of a higher encapsulation level. The entire application had access to any public class on the classpath, regardless of whether it was intended for external use or presented as an internal implementation.
  • Dependency issues. If a dependency exists at compile time but is missing from the classpath, the application crashes during execution rather than when it starts.

The Java Platform Module System (JPMS), introduced in JEP 261, enables representing Java applications or libraries as a set of modules rather than a set of classes. These modules:

  • declare their dependencies on other modules;
  • hide the internal implementation packages from external use.

Here's a brief example of how you can leverage this. Let's say we have the following library structure:

src/
 └─ com.example.lib/
    ├─ com/example/lib/api/LibPublicApi.java
    └─ com/example/lib/internal/InternalClass.java
Enter fullscreen mode Exit fullscreen mode

We want library users to interact with it only via the classes from the api package, while keeping the classes in the internal package inaccessible from the outside—even though they are declared as public. We can achieve this by defining the library as a separate Java module.

To do this, let's create the module-info.java file in the package root and configure it as follows:

module com.example.lib {
 exports com.example.lib.api;
}
Enter fullscreen mode Exit fullscreen mode

The module-info.java file and the module com.example.lib { .... } construct indicate that this package and its subpackages are a Java module. The exports construct opens the com.example.lib.api package to anyone who uses this module. That's all regarding encapsulation. Not explicitly exported packages will be unavailable outside this module.

If one module requires another, we explicitly state this in the module configuration by adding the following line:

requires com.example.lib

So, if we run the application and the JVM can't find the required module, the application/library will crash when it starts rather than during execution.

By the way, the module system proved to be especially useful for the standard Java library. By splitting the JDK into modules, the platform developers could clearly define which parts belong to the public APIs and which are internal implementation. This allowed to gradually restrict access to internal APIs and provide official replacements. This eliminated the risk of changes to the internal implementation breaking user code. A partial timeline of these changes is shown below:

  • JEP 260: Encapsulate Most Internal APIs;
  • JEP 396: Strongly Encapsulate JDK Internals by Default;
  • JEP 403: Strongly Encapsulate JDK Internals.

Another significant outcome of implementing the module system was the introduction of the jlink tool, which is designed to create customizable Java runtime images. Since the standard Java library has been divided into modules, developers can create a minimal environment that includes only parts of the platform that a specific application actually needs.

The jlink tool analyzes an application's module dependencies and creates a self-contained runtime that includes only the necessary standard library modules. This approach can significantly reduce the distribution size and increase the speed of starting the application.

Let's say we have a modular application, com.example.app. We can build a custom runtime for it with a single command:

jlink \
  --module-path $JAVA_HOME/jmods:mods \
  --add-modules com.example.app \
  --output app-runtime
Enter fullscreen mode Exit fullscreen mode

As a result, the process creates an app-runtime directory that contains a minimal Java runtime image with only the modules required by com.example.app. The application runs using the JVM from this directory, without using Java installed on the system. This allows a Java application to ship with its own runtime.

You can find a more detailed overview of the Java module system here.

G1 is now the default GC. JEP 248

In Java 8, the default garbage collector, Parallel GC, prioritized achieving the highest possible throughput, which resulted in rare but prolonged stop-the-world pauses.

As the Java ecosystem evolved and the platform transitioned to Java 11, the requirements for applications significantly changed. JVM heap sizes have increased, and microservice architecture and containerization have become common. In this environment, high GC throughput was no longer the only priority. Even brief pauses lasting a few seconds were deemed unacceptable for online services, despite the fact that the total time spent in GC remained relatively low.

For this reason, G1 became the default garbage collector starting with Java 9. Unlike Parallel GC, G1 deliberately trades some throughput for shorter, more predictable pauses. It runs garbage collection more frequently and aims to keep stop-the-world pauses within defined limits. As a result, although total GC time may increase, the impact on application latency is far more stable and controllable.

So, if you used to explicitly enable G1 using the -XX:+UseG1GC JVM flag when starting your application, you no longer need to do so after moving away from Java 8.

An API for Immutable collections. JEP 269

Creating immutable collections with constant values is a fairly common task. Prior to Java 9, the API for this task was not user-friendly. Developers had to create something like this:

Set<String> set = new HashSet<>();
set.add("a");
set.add("b");
set.add("c");
set = Collections.unmodifiableSet(set);
Enter fullscreen mode Exit fullscreen mode

Or this:

Set<String> set = 
    Collections.unmodifiableSet(Stream.of("a", "b", "c").collect(toSet()));
Enter fullscreen mode Exit fullscreen mode

Since Java 9, it has been possible to define immutable collections as follows:

Set<String> set = Set.of("a", "b", "c");
Enter fullscreen mode Exit fullscreen mode

Now that's an improvement!

Remember that these methods don't accept null values or duplicates as arguments. Otherwise, you'll run into a NullPointerException or an IllegalArgumentException, respectively.

Such methods exist for all collections and associative arrays (that is, for Map). This is crucial to keep in mind when migrating from Java 8!

Compact strings. JEP 254

Before Java 9, the internal representation of a string used a char array because Java strings rely on UTF-16 encoding, which allocates two bytes per character. However, JEP 254 points out that:

  • strings often consume a significant portion of the heap;
  • most strings contain only Latin characters.

Each Latin character fits into a single byte. To save memory, Java changed the internal string representation from char[] to byte[] and added a flag that indicates which encoding the string uses:

  • ISO-8859-1 / Latin-1 (one byte per character) when all characters in the string fit into it;
  • UTF-16 otherwise (two bytes per character).

Since we're talking about strings, I can't skip over the new methods that were added to the String class:

  • repeat creates a new string by repeating the original one a given number of times;
  • strip removes the leading and trailing whitespace;
  • stripLeading removes whitespace only from the beginning of the string;
  • stripTrailing removes whitespace only from the end of the string;
  • isBlank checks whether the string contains anything other than whitespace;
  • lines splits one string into multiple lines using line terminators.

Keep this in mind when upgrading to Java 11. This means you won't have to build all these methods yourself or drag in any third-party dependencies for them in your own project.

Removed from the JDK

Starting with JDK 11, some large modules have been removed from the standard Java distribution. Notably, JavaFX was removed from the JDK and moved to a separate project, OpenJFX, which is now distributed and developed independently.

As part of JEP 320, the Java EE and CORBA modules were removed from the JDK due to their outdated status and lack of active development. This change streamlined the Java platform, shifting the focus of its development to core capabilities and moving enterprise and UI solutions to external ecosystems.

If your project had one of the above modules, they need to be added separately.

Additional fields in @deprecated. JEP 277

When developing an API, it's important to notify users when the lifecycle of its components is coming to an end. If certain methods become outdated, developers shouldn't rely on them anymore and should start using more suitable alternatives instead. Java provides the Deprecated annotation for this exact purpose.

Before Java 9, the annotation didn't carry any additional information, creating ambiguity about its meaning in a given context. To provide developers with more clarity regarding the outdated API, the Deprecated category now includes two parameters:

  • since is a string parameter indicating the version in which the marked API was officially recognized as deprecated;
  • forRemoval is a boolean parameter that signals to developers whether the API will be removed in future versions.

These clarifying parameters streamline communicating the API status to its users.

HttpClient instead of HttpUrlConnection. JEP 321 and JEP 110

Before Java 11, HTTP requests were handled via HttpUrlConnection. The GET request and response output looked as follows:

URL url = new URL("https://api.example.com/data");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();

connection.setRequestMethod("GET");
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
connection.setRequestProperty("Accept", "application/json");

int status = connection.getResponseCode();

InputStream inputStream;
if (status >= 200 && status < 300) {
    inputStream = connection.getInputStream();
} else {
    inputStream = connection.getErrorStream();
}

BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder responseBody = new StringBuilder();

String line;
while ((line = reader.readLine()) != null) {
    responseBody.append(line);
}

reader.close();
connection.disconnect();

System.out.println(responseBody);
Enter fullscreen mode Exit fullscreen mode

By the time Java 11 was released, JEP 321 introduced HttpClient, which offered several advantages over HttpUrlConnection:

  • it's non-blocking, allowing asynchronous requests;
  • it provides a more convenient API;
  • it's higher-level;
  • it supports HTTP/2 and WebSocket.

This is a simple example of a request via HttpClient:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/data"))
        .header("Accept", "application/json")
        .GET()
        .build();

HttpResponse<String> response =
        client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println(response.body());
Enter fullscreen mode Exit fullscreen mode

Here's also sendAsync, which enables creating a chain of asynchronous actions:

client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
      .thenApply(HttpResponse::body)
      .thenAccept(System.out::println)
      .exceptionally(ex -> {
          ex.printStackTrace();
          return null;
      });
Enter fullscreen mode Exit fullscreen mode

This is a very important change to keep in mind in order to implement new features without relying on the outdated HttpUrlConnection.

Files.readString and Files.writeString

These are some minor changes related to reading and writing files. In Java 11, to read the file's contents or write something to it, just use the Files.readString and Files.writeString methods, respectively:

var fileContent = Files.readString(Path.of("file.txt"));
var content = "Hello, File!";
Files.writeString(Path.of("file.txt"), content);
Enter fullscreen mode Exit fullscreen mode

In the past, the simplest way to do this was:

String text = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
Enter fullscreen mode Exit fullscreen mode

By the way, using Path.of to define a path is now considered the preferred option. As of Java 11, the previously used Paths.get() is now considered obsolete.

A data filter for serialization. JEP 290

This change benefits developers who use Java mechanisms to deserialize external data in their applications.

Before Java 9, developers had to write custom classes to control which classes were deserialized. For example, these:

public class ObjectInputStreamWithClassCheck extends ObjectInputStream {
  private final static List<String> ALLOWED_CLASSES = Arrays.asList(
        User.class.getName()
  );

  public ObjectInputStreamWithClassCheck(InputStream in) throws .... {
    super(in);
  }

  @Override
  protected Class<?> resolveClass(ObjectStreamClass desc) throws .... {
    if (!ALLOWED_CLASSES.contains(desc.getName())) {
      throw new NotSerializableException(
          "Class is not available for deserialization"
      );
    }

    return super.resolveClass(desc);
  }
}
Enter fullscreen mode Exit fullscreen mode

They also had to use them for deserialization:

var ois = new ObjectInputStreamWithClassCheck(externalData);
Object obj = ois.readObject();
Enter fullscreen mode Exit fullscreen mode

With JEP 290, this capability is available in the standard library. To specify which objects are available for deserialization, just use the ObjectInputFilter filter:

ObjectInputFilter myFilter = 
                  ObjectInputFilter.Config.createFilter("java.util.Date;!*");
ObjectInputStream ois = new ObjectInputStream(externalData);
ois.setObjectInputFilter(myFilter);
Object obj = ois.readObject();
Enter fullscreen mode Exit fullscreen mode

You can configure filters using special string expressions. In the example above, we enabled deserialization only for objects of the java.util.Date class. If anything else is deserialized, we'll encounter the InvalidClassException.

By the way, we have an article discussing the consequences of unsafe deserialization. I recommend reading it.

Conclusion

That concludes the article. We understand that it's impossible to cover everything but did our best to spotlight the issues that developers are most likely to come across. I'd like to briefly mention JShell's arrival and the option to use the java command to immediately compile and run a single file. Note that version 8 was the last with the 1.* prefix. Starting with version 9, Java versions are designated by whole numbers.

It's time to say goodbye! We'll continue this series of articles by discussing the transition to the next LTS version, Java 17. So, if you found this content interesting, consider subscribing to our blog! See ya soon!

Top comments (0)