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: 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(...)
.
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.
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.
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.
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.
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.