Intro to the Java Platform Module System

Introduced in Java 9, the Java Platform Module System (JPMS) was a major change to the Java language. I think a lot of developers (myself included) never got a chance to learn and explore the new features, as before we knew it the new release cadence brought us Java 10, 11, and so on every 6 months.

Last year, I dove into the book Java 9 Modularity by Sander Mak and Paul Bakker. I’d highly recommend picking up a copy, but if you’re just getting into the JPMS this post will cover some of the basics.

Background and Motivation

Before jumping in, it’s important to understand the concept of modularity itself. Modularization is the act of decomposing a system into self-contained but interconnected modules. Modules are identifiable artifacts containing code, with metadata describing the module and its relation to other modules. An application consists of multiple modules working together. The goal with all of this is managing and reducing complexity.

Three Core Tenets of Modules

The book lays out three core tenets of every module. They are:

  1. Strong encapsulation

    Modules must be able to conceal part of their code from other modules, drawing the line between code that is publicly usable and code that is deemed an internal implementation detail. This prevents accidental or unwanted coupling between modules.

  2. Well-defined interfaces

    As the saying goes, public APIs, like diamonds, are forever. Modules should expose well-defined and stable interfaces to other modules.

  3. Explicit dependencies

    Dependencies should be part of the module definition. This gives rise to a module graph, which is important for understanding and running applications.

Modularity Before Java 9

These concepts are likely familiar to any Java developer, even if you’re still stuck using Java 8. Before Java 9 was released we could see strong encapsulation in the form of packages and access modifiers. The only issue arose when you wanted to access a class from another package in your component, but still wanted to prevent others from using it. Your options were less than ideal: make it public (which means public to every other type in the system, thus no encapsulation), or put it in an .impl or .internal package as a hint (which people ignore). Simply put, there wasn’t a way to hide an implementation package before Java 9.

With well-defined interfaces, Java has always supported interface types. It is quite common to see implementation classes hidden behind factories or dependency injection in Java code. On this tenet, Java had it right from the start.

When it comes to explicit dependencies, things start to crumble a bit more. Import statements are strictly a compile-time construct – once you package your code in a JAR, there’s no telling which other JARs contain the types your JAR needs to run. In fact, many external tools like Maven and Gradle have evolved alongside Java to try to remedy this particular problem. Even with these tools, there is no guarantee that your application has all its required dependencies available on the classpath when you go to run it.

What if a class cannot be found on the classpath? Then you get a runtime exception. Because classes are loaded lazily, this could potentially be triggered when some unlucky user clicks a button in your application for the first time. The JVM cannot efficiently verify the completeness of the classpath upon starting. There is no way to tell in advance whether the classpath is complete, or if you should add another JAR. Obviously, that’s not good.

There’s also the possibility of having duplicate classes on the classpath – in which case only one wins. It could be an older version, which would also lead to runtime exceptions. If only more information were available about the relations between JARs at runtime …

JPMS Basics

JPMS to the rescue! From the onset, there were two main goals of the module system:

  1. Modularize the JDK itself
  2. Offer a module system for applications to use

The JPMS introduces a native concept of modules into the Java language and runtime. Modules can either export or strongly encapsulate packages. Modules also express dependencies on other modules explicitly.

With the JPMS, all three tenets of modularity are addressed. The cool thing is, the module system is completely opt-in for application code. This is why most people don’t even notice it, even when using Java 9+!

Benefits

Using the JPMS has a number of benefits:

  • Reliable configuration

    The module system checks whether a given combination of modules satisfies all dependencies before compiling or running code. This leads to fewer runtime errors.

  • Strong encapsulation

    Modules explicitly choose what to expose to other modules. Accidental dependencies upon internal implementation details are prevented.

  • Scalable development

    Explicit boundaries enable teams to work in parallel while still creating maintainable codebases. Only explicitly exported public types are shared, creating boundaries that are automatically enforced by the module system.

  • Security

    Strong encapsulation is enforced at the deepest layers inside the JVM. This limits the attack surface of the Java runtime. Gaining reflective access to sensitive internal classes is not possible anymore.

  • Optimization

    Because the module system knows which modules belong together, including platform modules, no other code needs to be considered during JVM startup. It also opens up the possibility to create a minimal configuration of modules for distribution. Furthermore, whole-program optimizations can be applied to such a set of modules. Before modules, this was much harder, because explicit dependency information was not available and a class could reference any other class from the classpath.

Examples

Sounds great, but what does all this look like in practice? Let’s take a look at what it takes to define a module, starting with a module descriptor.

module java.prefs {
    requires java.xml;

    exports java.util.prefs;
}

This is an example module-info.java file from the JDK. The first line defines the name of the module, in this case java.prefs. Note that modules live in a global namespace; therefore, module names must be unique.

The second line includes a requires statement. The requires keyword indicates a dependency, in this case on java.xml.

The next line, containing the exports keyword, means that a single package from the java.prefs modules is exported to other modules.

That’s it! It really is that easy to get started. The other things to note are:

  • Every module has an implicit dependency on java.base, which contains the java.lang and java.util classes
  • Strong encapsulation is the default – only when a package is exported can it be accessed from other modules (enforced at compile time and runtime)
  • Normal Java accessibility rules are still in play after readability has been established (but public classes in non-exported packages are only public to other packages within the module)

Readability

The concept of readability should ring a bell for Gradle users who are used to carefully choosing dependency configurations. With the JPMS, readability is not transitive by default. Using the requires keyword is thus analogous to an implementation dependency with Gradle. I’ve broken out the other cases below:

JPMS Gradle
requires implementation
requires transitive api
requires static compileOnly

When you need another module for internal uses, a normal requires suffices. If, on the other hand, types from another module are used in the exported API, requires transitive is in order.

The Unnamed Module

With Java 9+, the entire JDK has also been broken apart into modules. You’re probably wondering, "how does that work if my code is not using the JPMS?" The answer is – any code not defined as explicit modules ends up in the unnamed module.

The unnamed module is special – it reads all other modules. You are still responsible for constructing a correct classpath, and almost all guarantees and benefits of the module system are voided. The other side effect of this is that applications can no longer compile against encapsulated types in platform modules. This is the way it should have been from the beginning, so moving up to Java 9+ forces applications to remove such references if present.

Versioning

Another question I had when learning about the JPMS was – what about versioning? Does this completely replace Gradle?

With versioning, modules in the Java module system cannot declare a version in module-info.java. Modules are resolved purely by name, which means that selection and retrieval of the right versions of dependencies are delegated to existing build tools like Gradle and Maven.

Conclusion

Reading about the JPMS was fascinating and eye-opening to me as a Java developer. I immediately wanted to jump in, so in my next post I’ll share my experience using the JPMS in practice. I’ll touch on the current state of the tooling and ecosystem, automatic modules, and how I shrunk an application bundle size 83% with a custom runtime image derived from the JPMS and jlink. Stay tuned!

And lastly, this post barely touches on the full contents of the book Java 9 Modularity. Check it out for further material on services, modularity patterns, migration, multi-release JARs, and testing modules.

1 Trackback or Pingback

Leave a Reply