To main content

Trickery with Maven and Multi-Release JARs

Published by Benjamin Marwell on

Recently, I came across deprecation warnings in the GMavenPlus-Maven-Plugin (gplus-maven-plugin). Here’s how I solved it by putting a Java 8-bytecode file (class file version 52.0) into META-INF/versions/java21, which is better known as a Multi-Release JAR.

JDK-APIs to be removed

One of the APIs which are deprecated for removal is the infamous SecurityManager. As you can tell from the documentation, it is not only deprecated; instead, it is deprecated for removal. This means, code relying on these classes are going to break at some point in the future. While I expect them to work for quite a bit longer (JDK 30 or so? Who knows?), many applications do either not really need then or the functionality can be replaced somehow.

The Problem: Imports

The hardest problem to solve: You cannot use imports starting with Java 21 if you want to avoid all warnings. Now, you could just work with reflection, which is cumbersome. Another idea would be to move the code into its own class. But the moment you begin building with a version of Java which has the SecurityManager removed, your build will break.

Luckily, Multi-Release JARs can help us get this problem out of the way.

Maven: The intended way

First, you decide which class is to be replaced in higher JDK versions by another version of itself. In my case (the groovy plugin), I created a file SecurityManagerSetter.java with two implementations:

  1. The Default implementation in src/main/java

  2. The logging-only implementation in src/main/java21.

Usually, you create a new source directory (e.g. src/main/java21) with the source file mentioned above and configure a new maven-compiler-plugin execution like so:

Classical Multi-Release JAR single-module compilation
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <executions>
        <execution>
          <id>compile-java21</id>
          <phase>compile</phase>
          <goals>
            <goal>compile</goal>
          </goals>
          <configuration>
            <release>21</release>
            <compileSourceRoots>
                <compileSourceRoot>${project.basedir}/src/main/java21</compileSourceRoot>
            </compileSourceRoots>
            <multiReleaseOutput>true</multiReleaseOutput>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

However, this is ugly for a very specific reason:

  • You either need a JDK which spans all wanted release targets (in this case: Java 8 and Java 21)
    Or

  • you need to use Maven toolchains to start multiple compilation runs.

In this case, both options are an overkill, because we just need to replace a single file with e Java 21 version. Currently, the plugin only needs a single JDK which can produce Java 8 bytecode, which can be anything between a JDK 8 and JDK 21 (at the time of writing this article). I would not want to change that for a single file.

Maven: The creative way

So, who said we need to have a Java 21 bytecode file (class file version 65.0) in our META-INF/versions/java21 folder? I asked Nicolai Parlog who wrote this article about Multi-Release JARs, and he confirmed my guess: The class file in the Java 21 folder can be anything up to (including) Java 21 class files.

While the Maven documentation currently says, outputDirectory is not writable, Maven 3 does not actually enforce this restriction. For the maven-compiler-plugin v3.12.0 I created a Pull Request to remove that restriction.

With that out of the way, we can start compiling files with Java 8 into the Java 21 directory like so:

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
        <executions>
          <execution>
            <id>compile-java21</id>
            <phase>compile</phase>
            <goals>
              <goal>compile</goal>
            </goals>
            <configuration>
              <compileSourceRoots>
                <compileSourceroot>${project.basedir}/src/main/java21</compileSourceroot>
              </compileSourceRoots>
              <!-- cannot use multireleaseOutput, because we are creating a Java 8 class file -->
              <outputDirectory>${project.build.outputDirectory}/META-INF/versions/21</outputDirectory>
            </configuration>
          </execution>
        </executions>
    </plugin>
  </plugins>
</build>

This is a simple trick: We did not specify multiReleaseOutput, which implicitly requires the release parameter to be set and therefore enforces the target bytecode to be the version of the release parameter. However, as Nicolai hinted on 𝕏 (formerly Twitter), there is no such restriction enforced by the JDK:

Hello, Nicolai. πŸ‘‹πŸ» This is the summary! 😊

Back to the XML snippet — so, what do we actually do here?

We only compile files from src/main/java21 and put them into target/classes/META-INF/versions/21. This can be done with any JDK, so the build will still run on Java 8, 11, 17, 21… you name it.

Maven trickery: Compiling for JDK 30

Remember I mentioned JDK 30 a few paragraphs into this article? This is because at the time of writing, JDK 30 was not available for a loooong time: JDK 21 just came out last month, not even by all vendors due to Oracleβ€˜s TCK situation.

That said, with this trick where we uncouple the META-INF/versions/javaNN directory from both the release parameter of the maven-compiler-plugin and the JDK which invoked maven (or the toolchain which invoked javac, for that matter), we can name our output directory anything you want!

How is this helpful?

In case you got an information that Java 30 will actually drop the SecurityManager (again, this is just a wild assumption and most probably wrong), you can now put your SecurityManager-cleansed Java 8 class file into META-INF/versions/java30, and it will start working the moment the first Java 30 early access build arrives! Isn’t this neat? 😊

This also enables using a single JDK version when, by definition, multiple JDKs would have been required by the bytecode versions: JDK 30 would probably not have support for release=8 anymore.

Conclusion

In this blog post we discovered how we can avoid toolchain invocations or avoid restricting the build to higher JDK versions for simple use cases.

Did you find this helpful or do you even have similar use cases? Let Nicolai and me know on Twitter (now 𝕏)!