appendix E
Targeting multiple Java versions with multi-release JARs

It’s never easy to decide which Java version to require for your project. On the one hand, you want to give users the freedom of choice, so it would be nice to support several major versions, not just the newest one. On the other hand, you’re dying to use the newest language features and APIs. From Java 9 on, there’s a new JVM feature, multi-release JARs (MR-JARs), that helps you reconcile these opposing forces—at least, under some circumstances.

MR-JARs allow you to ship bytecode for different Java versions in the same artifact. You can then rely on the JVM to load the classes that you compiled for the most recent version it supports. Starting with a project that runs successfully on your minimally required version, you can selectively improve it on newer JVMs by using more resilient and performant APIs—without being forced to raise your project’s baseline.

With all of that out of the way, let’s explore this handy new feature. We’ll start with creating a simple MR-JAR before looking at how it’s structured internally. We’ll end with some recommendations for when and how to use MR-JARs.

Creating a multi-release JAR

To prepare for an MR-JAR, you need to split source files by the Java version they target, compile each set of sources for the corresponding version, and place the resulting .class files into separate folders. When packaging them with jar, you add the baseline class files as usual (directly or with -C; check section 4.5.1) and use the new option --release ${release} for each other bytecode set.

Let’s look at an example. Say you need to detect the currently running JVM’s major version. Java 9 offers a nice API for that, so you no longer have to parse a system property. (Section 6.5.1 gives a glimpse of it, but the details aren’t important here.) By deploying an MR-JAR, you can use that API if you’re running on Java 9 or later.

The hypothetical app has two classes, Main and DetectVersion; and the goal is to have two variants of DetectVersion, one for Java 8 and earlier and another for Java 9 and later. These two variants need to have the exact same fully qualified name (which can make it challenging to work with them in your IDE)—assume you place them into two parallel source folders, src/main/java and src/main/java-9.

Figure E.1 shows how to organize the sources, and listing E.1 shows how to compile and package them into an MR-JAR. Note the two compilation steps and the separate output folders. The end result is shown in figure E.2.

Listing E.1 Compiling and packaging sources for different Java versions into a JAR

javac --release 8   
    -d classes
    src/main/java/org/codefx/detect/*.java
javac --release 9   
    -d classes-9
    src/main/java-9/module-info.java
    src/main/java-9/org/codefx/detect/DetectVersion.java
jar --create                         
    --file target/detect.jar         
    -C classes .                     
    --release 9                  
    -C classes-9 .               
AppE-01.png

Figure E.1 One possible way to lay out the source code for a MR-JAR. The most important detail is that the version-dependent code, here DetectVersion, has the same fully qualified name in all variants.

This simple example creates two variants of DetectVersion, one for the minimally required Java 8 and another for Java 9. Formalizing that to the general case of creating a feature with several classes for several versions is surprisingly complex and tedious, so I’ll spare you the formal version. Instead, section E.3 leaves you with a rule of thumb.

AppE-02.png

Figure E.2 The JAR resulting from listing E.1

Internal workings of MR-JARs

How does an MR-JAR work? It’s pretty straightforward: it stores version-unspecific class files in its root (as usual) and version-specific files in META-INF/versions/${version}.

In addition to the folders in META-INF/versions, an MR-JAR can also be recognized by looking at the plaintext file META-INF/MANIFEST.MF: in MR-JARs, the manifest has an entry Multi-Release: true.

Usage recommendations

Now that you know how to create MR-JARs and how they work, I want to give you some recommendations for how to make the most out of them. More precisely, I’ll give you tips on these topics:

  • How to organize source code
  • How to organize bytecode
  • When to use MR-JARs

Organizing the source code

  • The code for the oldest supported Java version goes in the project’s default root directory: for example, src/main/java, not src/main/java-X.
  • The code in that source folder is complete, meaning it can be compiled, tested, and deployed as is without additional files from version-specific source trees like src/main/java-X. (Note that if you’re offering a feature that only works on a newer Java version, having a class that only throws errors stating “Operation not supported before Java X” counts as complete. My recommendation is to not leave it out, leading to an uninformative NoClassDefFoundError.)

These aren’t technical requirements; nothing stops you from targeting Java 11 and putting half of the code in src/main/java and the other half, or even all of it, in src/main/java-11. But that will only cause confusion.

By sticking to the guidelines, you keep the source tree’s layout as simple as possible. Any human or tool looking into it sees a fully functioning project that targets the required JVM version. Version-dependent source trees then selectively enhance that code for newer versions.

How do you verify whether you got it right? As I said earlier, a formal description is complex, so here’s that rule of thumb I promised. To determine whether your particular layout works, mentally (or actually) undertake the following steps:

  1. Compile and test the version-independent source tree on the oldest supported Java version.
  2. For each additional source tree:
  1. Move the version-dependent code into the version-independent tree, replacing files where they have the same fully qualified name.
  2. Compile and test the tree on the newer version.

If that works, you got it right.

Of course, your tools also have to work with the source layout you choose. Unfortunately, at the time of writing, IDEs and most build tools don’t have good support for this layout, and you might be forced to compromise. As an alternative solution, consider creating separate projects for each Java version.

Organizing the bytecode

  • The bytecode for the oldest supported Java version goes into the JAR’s root, meaning it’s not added after --release.
  • The bytecode in the JAR’s root is complete, meaning it can be executed as is without additional files from META-INF/versions.

Once again, these aren’t technical requirements, but they guarantee that everybody looking into the JAR’s root sees a fully functioning project compiled for the required JVM version with selective enhancements for newer JVMs in META-INF/versions.

When to use MR-JARs

How do MR-JARs help you solve the dilemma of picking the required Java version? First, and to state the obvious, preparing a MR-JAR adds quite a bit of complexity:

  • Your IDE and build tool must be configured appropriately to allow easy work on source files with the same fully qualified name that are compiled against different Java versions.
  • You need to keep multiple variants of the same source file in sync, so that they keep the same public API.
  • Unit testing gets more complicated because you might end up writing tests that only run or pass on specific JVM versions.
  • Integration testing gets more cumbersome because you need to consider testing the resulting artifact on each Java version for which the MR-JAR contains bytecode.

Also, MR-JARs aren’t a good fit for using convenient new language features. As you’ve seen, you need two variants of the involved source files, and there’s no good argument on the basis of convenience if you have to keep a source file with the inconvenient variant. Language features will also quickly pervade a code base, leading to a lot of duplicate classes. This isn’t a good idea.

APIs, on the other hand, are the sweet spot for MR-JARs. Java 9 introduced a number of new APIs that solve existing use cases with more resilience and/or better performance:

  • Detecting the JVM version with Runtime.Version instead of parsing system properties (see section 6.5.1)
  • Analyzing the call stack with the stack-walking API instead of creating a Throwable (this book doesn’t cover that API, but developers of your logging framework are already using it)
  • Replacing reflection with variable handles (see section 12.3.2)

If you want to use a newer API on a newer Java release, all you need to do is encapsulate your direct calls to it in a dedicated wrapper class and then implement two variants of it: one using the old API, another using the new one. If you’ve accepted the complexities outlined before, then this is straightforward.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.93.141