Search code examples
javajunit5java-platform-module-system

Access jdk.unsupported from JUnit doesn't need module require


I'm trying to figure out why JUnit behaves differently from a 'regular' Java program with respect to the jdk.unsupported module. And yes, I know I shouldn't be using this. I'm just trying (and failing) to understand how things work.

I have this program:

public class UnsafeAttempt {
    public static void main(String[] args) {
        try {
            Class.forName("sun.reflect.ReflectionFactory");
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }
}

When run on the modulepath, this throws a java.lang.ClassNotFoundException, which I expect. When I add requires jdk.unsupported; to module-info.java, it runs without problems, which I also expect.

But if I put it in a unit test:

import org.junit.jupiter.api.Test;

public class UnsafeTest {
    @Test
    public void sunreflect() throws Exception {
        Class.forName("sun.reflect.ReflectionFactory");
    }
}

With this module-info.java file in src/test/java:

open module my.module {
    exports my.module;

    requires org.junit.jupiter.api;
}

This works without adding requires jdk.unsupported;, and I don't understand why.

I see this behaviour in both IntelliJ and Maven. I haven't configured the Maven Surefire plugin in any way.

What does JUnit or Surefure (or something else) do to make this work?

EDIT: I've created a GitHub repo with a reproducer, to make it easier to try this: https://github.com/jqno/modules-are-confusing


Solution

  • TL;DR: Even though your test code is being loaded into a named module as expected, the "application" that's running the tests (Surefire) is a non-modular application. This means a default set of root modules are used, which in this case includes jdk.unsupported. Since that module is resolved its possible to find its classes via Class::forName(...).


    Modular vs Non-modular Applications

    Ever since the Java Platform Module System (JPMS) was added in Java 9 there are basically two "types" of Java applications: modular applications and non-modular applications. The former is the "new" type.

    Modular Application

    Whenever you launch an application via --module that application is modular. For such applications, which modules are resolved at run-time is entirely dependent on the root modules. The root modules include the one passed to --module and any additional ones passed via --add-modules. If the jdk.unsupported module is not a root module and not required, either directly or transitively, by a root module, then it will not be resolved. In that case, any attempt to reflectively load a class from jdk.unsupported will fail.

    Non-modular Application

    But what about non-modular applications? These are launched via -jar or by specifying the main class by name. In other words, the "entry point" is not a module, and so the run-time cannot determine which modules need to be resolved. So, Java uses a default set of root modules:

    In the JDK the default set of root modules contains every module that is observable on the upgrade module path or among the system modules, and that exports at least one package without qualification.

    The jdk.unsupported module is among the system modules and exports at least one package, meaning it will be resolved. And that means Class::forName(String) will be able to find classes in that module. Note that none of the forName methods care if the caller's module reads the module of the requested class, or if the requested class's package is exported or opened to at least the caller's module. If the class can be found by the class loader then it will be. However, the ability to reflectively get/set fields and invoke methods from the class depends on if that class's package is exported or opened to the caller.


    Your Repository's Situation

    When I clone your repository and execute mvn test -X I see the following (file paths sanitized/replaced by placeholders):

    [INFO] -------------------------------------------------------
    [INFO]  T E S T S
    [INFO] -------------------------------------------------------
    [DEBUG] Determined Maven Process ID 1344
    [DEBUG] Fork Channel [1] connection string 'pipe://1' for the implementation class org.apache.maven.plugin.surefire.extensions.LegacyForkChannel
    [DEBUG] boot classpath:  <surefire-jars>
    [DEBUG] boot(compact) classpath:  surefire-booter-3.2.5.jar  surefire-api-3.2.5.jar  surefire-logger-api-3.2.5.jar  surefire-shared-utils-3.2.5.jar  surefire-extensions-spi-3.2.5.jar  surefire-junit-platform-3.2.5.jar  common-java5-3.2.5.jar
    [DEBUG] Path to args file: <argsfile>
    [DEBUG] args file content:
    --module-path
    "<test-classes-dir>;<classes-dir>;<project-dependency-jars>"
    --class-path
    "<surefire-jars>"
    --add-modules
    ALL-MODULE-PATH
    --add-opens
    org.junit.platform.commons/org.junit.platform.commons.util=ALL-UNNAMED
    --add-opens
    org.junit.platform.commons/org.junit.platform.commons.logging=ALL-UNNAMED
    org.apache.maven.surefire.booter.ForkedBooter
    [DEBUG] Forking command line: cmd.exe /X /C ""java" @<argsfile> <surefire-temp-files>"
    [DEBUG] Fork Channel [1] connected to the client.
    [INFO] Running nl.jqno.module.UnsafeTest
    [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.061 s -- in nl.jqno.module.UnsafeTest
    [DEBUG] Closing the fork 1 after saying GoodBye.
    [INFO]
    [INFO] Results:
    [INFO]
    [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
    

    Our focus is the "args file content". You can see it puts the main code, the test code, and the project dependencies on the --module-path and makes sure they're resolved. But it continues to put Surefire code on the --class-path and launches the applications by specifying the main class by name: org.apache.maven.surefire.booter.ForkedBooter. That means the "application" that runs the tests (Surefire) is a non-modular application. Thus, the jdk.unsupported module is resolved, Class::forName can find its classes, and your test passes.


    Follow-up Questions

    From a comment:

    I wasn't aware that the modulepath and classpath could exist simultaneously. Does this mean that jars loaded on the modulepath get all the protections of the JPMS, while jars loaded on the classpath (including those "special" default modules) do not? So, nobody can access the classes that my module didn't export, while my module can access everything that's loaded on the classpath?

    Any code loaded from the module-path will be put into named modules. That means all the strong encapsulation rules of JPMS will apply. Other code can only access (directly or reflectively) public members that are in packages exported to at least the caller, and can only reflectively access private members that are in packages opened to at least the caller.

    The "special default modules" for non-modular applications come from the "system modules" and "upgrade module-path". Thus, these modules will also be named modules and all the rules of JPMS will apply. As far as I know, it's impossible for system modules to not be non-automatic named modules. So, in a way, all non-modular applications mix the class-path and module-path, as the system modules are always implicitly on the module-path.

    The system modules are those in the modular run-time image. By default, that includes all the java.* modules and, at least for those JDKs based on OpenJDK, all the jdk.* modules. But you can create custom run-time images via the jlink tool.

    Any code loaded form the class-path is put into the unnamed module. The unnamed module behaves, for the most part, like it would pre Java 9. All its members are completely open to the public. Though when it interacts with named modules, the same JPMS rules apply for those modules; the unnamed module can only access members that the named modules export/open.

    Named modules can only directly use code from the exported packages of other modules that it reads. Since it's impossible to requires the unnamed module (it has no identity), named modules can never directly use code from the unnamed module. However, the opposite is not true. The unnamed module can directly use code from the exported packages of named modules.

    Note your my.module test module is using reflection to get the class from the jdk.unsupported module. That's an indirect way of using code. And that's why your module does not need to read the jdk.unsupported module; getting the Class of a type in a named module will work as long as said module has been resolved. But while you can get a Class, Field, Method and so on, that does not mean you'll be able to access them. Access (e.g., Field::get, Method::invoke, etc.) is still subject to the rules of JPMS. Public members must be from packages that are exported to at least the caller, private members must be from packages that are opened to at least the caller. Though again, the caller does not need to read a module to reflectively use its code.