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: Despite the fact your test code is being loaded into an named module, the "application" responsible for executing the tests (Surefire) is placing the main class (for the forked JVM) on the class-path. This means a default set of root modules is being used for module resolution. That set includes jdk.unsupported, which is why that module is being resolved. Additionally, obtaining a Class reflectively does not require that class's package to be accessible to the caller. Nor does the caller's module need to reads (i.e., requires) the target class's module. Taking that all into account, that is why your test passes.


    Overview of Modules

    Understanding modules, how they're resolved, and how reflection works with them is important to understanding why your test passes.

    Module Resolution

    Note some of the quoted documentation below mentions compile-time behavior, but we only care about the run-time behavior. I also ignore service binding, as it is not related to why the jdk.unsupported module is resolved.

    Observable modules

    These are the modules that are capable of being found during resolution. From documentation:

    The set of observable modules at both compile-time and run-time is determined by searching several different paths, and also by searching the compiled modules built in to the environment. The search order is as follows:

    1. At compile time only, the compilation module path. This path contains module definitions in source form.

    2. The upgrade module path. This path contains compiled definitions of modules that will be observed in preference to the compiled definitions of any upgradeable modules that are present in (3) and (4). See the Java SE Platform for the designation of which standard modules are upgradeable.

    3. The system modules, which are the compiled definitions built in to the environment.

    4. The application module path. This path contains compiled definitions of library and application modules.

    The upgradle module path is set by --upgrade-module-path and the application module path is set by --module-path.

    The jdk.unsupported module is a system module in the JDK and is thus observable. Note the system modules are those in the runtime image, which includes modules such as java.base.

    Root modules

    These are the modules that the resolution algorithm begins the search with. From documentation:

    The set of root modules at compile-time is usually the set of modules being compiled. At run-time, the set of root modules is usually the application module specified to the 'java' launcher. When compiling code in the unnamed module, or at run-time when the main application class is loaded from the class path, then the default set of root modules is implementation specific. 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 [emphasis added].

    When launching an application, these are specified by the --add-modules and --module options. Or, if --module isn't used, then as described a default set of root modules is used, which can still be augmented by --add-modules.

    As will be shown later in this answer, the "application" responsible for executing your tests has its main class loaded from the class-path. Thus, the default set of root modules is being used. Since the jdk.unsupported module is a system module, and it unconditionaly exports at least one package, it will be among the root modules (and therefore resolved).

    Enumeration

    Starting with the root modules, the resolution algorithm recursively enumerates the required modules until all are found. Any automatic module that's enumerated forces all other observable automatic modules to be enumerated.

    Readability graph

    The next step of module resolution is to take the enumerated modules and compute which modules any given module reads. This is known as the readability graph. The logic is relatively simple:

    Module Types

    Named modules

    Ignoring dynamic modules, named modules are those loaded into a ModuleLayer and have a name. When launching an application, this means all system modules and those modules loaded from the module-path.

    A named module may or may not be "automatic". A non-automatic named module explicitly defines which modules it requires and which packages it exports and/or opens via its module-info descriptor.

    Automatic modules

    An automatic module is a named module that does not have a module-info descriptor. They receive special treatment (emphasis mine):

    Automatic modules receive special treatment during resolution so that they read all other modules in the configuration. When an automatic module is instantiated in the Java virtual machine then it reads every unnamed module and is treated as if all packages are exported and open.

    I mention this because you brought up that your real code uses objenesis, which appears to be non-modular as of posting this answer. Meaning if you put it on the module-path then it would become an automatic module, which further means it would reads the jdk.unsupported module if resolved.

    Unnamed module

    The unnamed module contains all code that is not associated with a named module. When launching an application, this means all code loaded from the class-path. The unnamed module reads all modules and is treated as if all its packages are both exported and opened. This gives behavior similar to how it was before Java 9 (the version modules were introduced).

    As noted earlier, if the main class is loaded from the class-path (i.e., into the unnamed module), a default set of root modules is used for resolution.

    Named & Unnamed Module Interaction

    Note it is entirely possible to define both the module-path and the class-path for a single application. Anything on the class-path will be loaded into the unnamed module. And any module among the system modules, on the upgrade module-path, or on the application module-path that are resolved will be loaded into a named module. Which modules are resolved and how they all interact depends on everything discussed in the previous sections.

    Any system module that is resolved will always be a non-automatic named module.

    If you do mix the module-path and class-path, I recommend you do not put the same code on both paths. That can lead to weird access errors when code fails to be resolved as a named module when it should be, yet can still be found on the class-path. Better to just have the module or class simply not be found; the error is more straightforward.

    Modules & Reflection

    Readability

    Note that reflection does not require module A to reads module B in order for a class in the former to reflectively access code in the latter.

    Accessibility

    The rules regarding if reflection can be used to access members is documented by AccessibleObject#setAccessible(boolean):

    This method may be used by a caller in class C to enable access to a member of declaring class D if any of the following hold:

    • C and D are in the same module.
    • The member is public and D is public in a package that the module containing D exports to at least the module containing C.
    • The member is protected static, D is public in a package that the module containing D exports to at least the module containing C, and C is a subclass of D.
    • D is in a package that the module containing D opens to at least the module containing C. All packages in unnamed and open modules are open to all modules and so this method always succeeds when D is in an unnamed or open module.

    The jdk.unsupported module both exports and opens its sun.reflect package unconditionally.

    But this only applies to accessing members. It does not prevent you from reflectively obtaining the Class, and from there its Fields, Constructors, and Methods. If you look at the documentation of Class#forName(String), it says:

    Returns the Class object associated with the class or interface with the given string name. Invoking this method is equivalent to:

    Class.forName(className, true, currentLoader)
    

    And Class#forName(String,boolean,ClassLoader) has the following API note:

    API Note:

    This method throws errors related to loading, linking or initializing as specified in Sections 12.2, 12.3, and 12.4 of The Java Language Specification. In addition, this method does not check whether the requested class is accessible to its caller [emphasis added].

    For completeness, Class#forName(Module,String) says the same:

    This method does not check whether the requested class is accessible to its caller.


    Your Reproducible Example

    When I clone your repository and execute mvn test -X, I see the following logs (file paths sanitized/omitted):

    [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
    

    The important part in this case is the "args file content". From those arguments, we can see:

    The first and second points ensure your test module is loaded from the module-path into a named module.

    The last point is ultimately why the jdk.unsupported module is resolved. As explained in the previous section, this casuses a default set of root modules to be used for module resolution. The jdk.unsupported module will be part of this set and will therefore be resolved.

    And your test module does not need to reads (i.e., requires) the jdk.unsupported module because you're using reflection to obtain the Class of sun.refect.ReflectionFactory.

    Finally, the jdk.unsupported module both exports and opens its sun.reflect package unconditionally. This means that:

    You mention your real code uses objenesis. These two points means that library can directly and/or reflectively use the code of sun.reflect, whether it's loaded into an automatic module or the unnamed module. And if and when that library is modularized, it will have to add the following directive to its module-info descriptor:

    requires [static] [transitive] jdk.unsupported;