The State of the JPMS in 2022

In my last post, I discussed the Java Platform Module System and some of the basics. This post will cover the state of the tooling and overall ecosystem for using the JPMS in production in 2022, 5 years after its initial release.

To guide us along, I am using a simple utility application from my work at Camgian as the migration target. This application supports both a CLI and a Swing GUI, and is written using Java 17. Note that the module system was introduced with Java 9, so you must be using a Java version 9 or later 9 (sorry Android users).

Starting the Migration

To get started with using the JPMS, all we need to do is add a module-info.java file under the src/main/java directory. With Gradle 7.0+, you don’t even have to change your build script. Gradle will automatically recognize Java modules when a module-info.java file is present. Pretty neat!

Here’s what a starter module-info.java file would look like for an application:

module my.application {

}

Remember that modules live in a global namespace; therefore, module names must be unique. This is less important for applications than it is for libraries, so don’t sweat it too much at this point. You will have to tell Gradle the name of your main module so it knows where to start the application:

application {
    mainClass.set('com.example.MainClass')
    mainModule.set('my.application')
}

IntelliJ IDEA has supported the Java Platform Module System since 2016.3, so we’re covered there. As soon as you add this file, the IDE will recognize that we’re using the module system.

The first thing that will happen is that a bunch of errors will pop up that say something like "Package ‘javax.swing’ is declared in module ‘java.desktop’, but module ‘my.application’ does not read it". With an empty module-info file, the only module our application reads by default is java.base, which contains all the java.lang and java.util classes. Anything else in the JDK was broken into separate modules as a part of the creation of the JPMS.

The nice thing here is that your classpath is cleaned up tremendously. You’ll notice far fewer options show up in the IDE auto-complete window as you type. To resolve the errors, the IDE makes it as easy as possible. Navigate to the error, open the context menu (alt + enter), and the first option will be a prompt to add a requires java.desktop directive to our module-info.java file. You don’t have to know the exact name of the modules you need, or that the Swing classes are in the java.desktop module. Let the IDE take over here, and you should quickly see the number of errors drop.

For the app I was migrating, the only JDK module I needed to add ended up being java.desktop. On to the next step!

External Dependencies

To resolve the errors concerning dependencies outside the JDK, a little more attention is required. We can break these dependencies down into three cases:

  1. Dependencies defined as explicit modules
  2. Dependencies defined as automatic modules
  3. Dependencies defined as neither an explicit nor automatic module

Explicit Modules

If your application is using any external dependencies that are also defined as modules, then the process is the same and the IDE will handle things for you.

In my case, I found that none of the dependencies I was using were defined as explicit modules. Some are still on Java 8, others haven’t been updated in a while, but overall, it’s a sign that the Java ecosystem has a ways to go to get up to speed with modules.

Automatic Modules

Wait, what’s an automatic module? This is something I skipped over in my introduction post because it’s much more relevant here. If you have a library that isn’t defined as an explicit module for whatever reason (maybe it’s stuck on Java 8 for Android support), you can still make it immediately usable for other modules by defining it as an automatic module.

An automatic module is created by moving an existing JAR from the classpath to the module path, without changing its contents. A module descriptor is generated on the fly by the module system. Automatic modules have the following characteristics:

  • Does not contain module-info.class
  • Has a module name specified in META-INF/MANIFEST.MF or derived from its filename
  • requires transitive (analogous to a Gradle api dependency) all other resolved modules
  • Exports all its packages
  • Reads the unnamed module

As a library maintainer, all you have to do to define your library as an automatic module is to add an Automatic-Module-Name entry to your JAR’s manifest. This can be done easily with Gradle:

tasks.jar {
    manifest {
        attributes["Automatic-Module-Name"] = "my.library"
    }
}

This approach offers a way to choose a module name even before fully migrating the library to a module. Obviously the goal is to eventually move to an explicit module, but, as you’ll see, this is WAY better than doing nothing.

I was pleasantly surprised to find that most of the external dependencies used by my application (e.g., Guava) did have an Automatic-Module-Name defined. I had to go in and add names for a few internal dependencies (see the book for more advice on choosing library module names), but once I got those redeployed the IDE could recognize them and easily add them to my application’s module descriptor.

Neither Explicit Nor Automatic Modules

This final case for external dependencies is where things get tricky, the case where the dependency you are using is not defined as an explicit module and also does not have an Automatic-Module-Name. Usually you come across this with older libraries – in my case it was with a command line argument parsing library called JCommander.

When I described automatic modules above, I mentioned that the module name can also be derived from the JAR’s filename. For these libraries without explicit Automatic-Module-Names, the IDE attempts to do just that. If you’re going through and just cleaning up errors, you might not even notice that this is happening under the hood. IntelliJ will let you add these types of dependencies to your module descriptor, and once you’ve done so all the IDE warnings disappear.

Just so you’re keeping track, this is what my module descriptor looked like at this point:

module my.application {
    requires java.desktop;      // explicit module from the JDK
    requires com.google.common; // automatic module with name specified in META-INF/MANIFEST.MF
    requires jcommander;        // automatic module with name derived from filename
}

With all the IDE warnings gone, I thought I was in the clear! However, when I went to compile my application, I got this error:

error: module not found: jcommander
    requires jcommander;
             ^

So what’s up with this? As it turns out, Gradle does not treat dependencies without module descriptors or Automatic-Module-Names as automatic modules. To get this JAR on the module path, we have to do some additional work. As I looked into this, I was happy to find out that someone had gone ahead and written a Gradle plugin to make resolving the issue easier.

This plugin essentially takes your incoming JARs and inserts an Automatic-Module-Name that you specify into the manifest. With the plugin applied, I added the info to my build script:

extraJavaModuleInfo {
    automaticModule("jcommander-1.82.jar", "jcommander")
}

If it feels like a hack, it definitely is. The better solution is to at least get the library itself updated with an Automatic-Module-Name, if not a true module descriptor. If you come across this case, be sure to post an issue on GitHub or if it’s your library, go in and add the Automatic-Module-Name yourself. (Note: JCommander already had an issue posted.)

Ok, should be good to go now, right? I went to compile again and to my surprise got another error:

Not a module and no mapping defined: jsr305-3.0.2.jar

Oh no! Where did this come from?

Transitive Dependencies

After some investigation, I realized that it’s not good enough to set up all your explicit dependencies to work with the JPMS. You’ve also got to ensure that ALL transitive dependencies are also properly modularized. Thankfully I was dealing with a short dependency list with this application, but I can imagine this getting out of hand very quickly.

Using the Gradle dependencies task, I could see that com.google.code.findbugs:jsr305:3.0.2 was a dependency of com.google.guava:guava:31.1-jre. This ancient JAR apparently didn’t have an Automatic-Module-Name, so I had to set it up the same way as I did JCommander.

After iterating several times through the loop of error -> add artifact transform info to build script -> error, I eventually got my program to run! Now that we’ve got our app using the JPMS, we can try to take advantage of some its additional benefits.

Custom Runtime Images

When I was first learning about the JPMS reading Java 9 Modularity, the ability to create custom runtime images was probably the most intriguing and exciting feature for me. A custom runtime image is fully self-contained – it bundles the application’s modules with the JVM and everything else it needs to execute your application. No other Java installation (JDK/JRE) is needed! Custom runtime images are often many times smaller than a full JDK, and you avoid the potential for mismatch between the installed Java versions on the target platform and the version your application needs.

Imagine using one on a resource constrained device like an embedded system. Or using it as a basis for a container image for running an application in the cloud (being more resource-efficient is good in the cloud too where everything is metered). Custom runtime images potentially run faster by virtue of link-time optimizations that are otherwise too costly or impossible to do with the full JDK. They are also more secure, as the attack surface decreases without the unnecessary parts of the JDK.

Once I had a fully-functional, modularized application, creating a custom runtime image turned out to be pretty simple. There’s a well-documented jlink Gradle plugin that, once applied, makes it as easy as running the jlink task to create your image.

I was pretty astounded by the results. Before migration, an equivalent runtime image (one that doesn’t depend on Java being installed on the target device) created with launch4j weighed in at a hefty 175.1 MB. My JPMS + jlink image was only 30.3 MB, a 5.8x reduction (83%)!

Closing Thoughts

Overall, I have mixed thoughts about the JPMS after this journey. The benefits are obviously there – security, optimization, strong encapsulation, etc. For the most part, the tooling is also in good shape between IntelliJ and Gradle.

However, too much of the library ecosystem is behind, even 5 years later. The JPMS is a system in which the benefits shine when everyone plays along. When you have legacy libraries, things get difficult, exemplified by the effort and hacks it took to get a very simple utility application up and running. For a larger, more complex application, I would imagine the tradeoff would not be worth it.

Going forward, I hope more of the Java world will adopt and migrate to the JPMS. I have made sure, at the very least, that all the libraries I maintain have Automatic-Module-Names defined. If Android ever lets me move them past Java 8, I also hope to one day turn them into explicit modules. But that’s a post for another day.

1 Trackback or Pingback

Leave a Reply