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
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.
Understanding modules, how they're resolved, and how reflection works with them is important to understanding why your test passes.
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.
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:
At compile time only, the compilation module path. This path contains module definitions in source form.
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.
The system modules, which are the compiled definitions built in to the environment.
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
.
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).
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.
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:
A
requires B
, then A
reads B
.A
requires B
and B
transitively requires C
, then both A
and B
reads C
.X
is automatic, then it reads all enumerated modules.A
requires X
and X
is automatic, then A
reads X
and all other enumerated automatic 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.
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.
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.
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.
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.
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 classD
if any of the following hold:
C
andD
are in the same module.- The member is
public
andD
ispublic
in a package that the module containingD
exports to at least the module containingC
.- The member is
protected static
,D
ispublic
in a package that the module containingD
exports to at least the module containingC
, andC
is a subclass ofD
.D
is in a package that the module containingD
opens to at least the module containingC
. All packages in unnamed and open modules are open to all modules and so this method always succeeds whenD
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 Field
s, Constructor
s, and Method
s. 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.
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:
ALL-MODULE-PATH
.org.apache.maven.surefire.booter.ForkedBooter
, by name.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;