JDK 17 and ‘illegal reflective access’
Introduction
Java 9 introduced the Java Platform Module System (JPMS) – herein shorthanded as ‘modules’. They are a way to group classes and interfaces in related collection of packages. There are plenty of online resources that go in depth on the architecture, use cases and best practices of modules. However briefly, some of the benefits of modules are:
- Abstraction – modules provide a way to hide the implementation details and limit accessibility of internal and undocumented classes and interfaces
- Explicit Dependencies – modules declare any other modules they depend on, simplifying dependency tracking and resolving
- Enhanced Security – Because of ‘Strong Encapsulation’ (the ability of modules to enforce the above abstraction), classes outside of the module cannot open, even via reflection, classes inside the module, unless the module itself opens the class up to that outside module. This helps guard against possibility of rogue dependencies attempting to access implementation classes and gaining unauthorized access.
Again there are many points of discussion for JPMS, but these are central to our discussion.
From JDK 9 until prior to JDK 16, the Strong Encapsulation feature was enforced by default but set at ‘permit’. This meant that when classes, such as the ones inside the JAX Webservice Reference Implementation Runtime (RT), attempted to reconfigure the visibility and accessibility of classes at runtime via reflection, a warning was shown in the logs for the first time only.
The configuration name for this feature was --illegal-access
. And the possible values are as such:
- permit (default for JDK 9 to 11) – access allowed but warning issued the first
- warn – warning issued every time illegal access is detected
- debug – used to trace where illegal access was being issued from
- deny – (default for JDK 16) throw an exception when illegal access detected
The problem
Starting with JDK 16 this default became deny. Moreover the configuration switch itself is, as of JDK 17, deprecated and marked for removal in future JDK releases. Meaning the application cannot be started with the it back down to ‘permit’ level.
Back to the issue itself. While this warning is issued anytime reflective access is made to an unopened module (such as to package java.lang in the java.base module), I will use an example of a library that many enterprise applications use (if you want a small test code that invokes this warning try this fantastic article). In an app that uses JAX WS, the access warning manifests as such:
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by com.sun.xml.ws.model.Injector (jar:file:/home/user/app.jar!/BOOT-INF/lib/rt-2.3.1.jar!/) to method java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int)
WARNING: Please consider reporting this to the maintainers of com.sun.xml.ws.model.Injector
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
By turning debugging on (in JDK versions < 17 ), we get some more insight from the JVM:
Caused by: java.lang.NoSuchMethodException: sun.misc.Unsafe.defineClass(java.lang.String,[B,int,int,java.lang.ClassLoader,java.security.ProtectionDomain)
at java.base/java.lang.Class.getMethod(Class.java:2227) ~[na:na]
at com.sun.xml.ws.model.Injector$3.run(Injector.java:109) ~[rt-2.3.1.jar:2.3.1]
at com.sun.xml.ws.model.Injector$3.run(Injector.java:105) ~[rt-2.3.1.jar:2.3.1]
We see that the Injector class has implemented an anonymous class body which is attempting to establish a reflection reference to the class and method sun.misc.Unsafe.defineClass.
Further in the stack-trace, the following remark is seen: Suppressed: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @5b8dfcc1
So what the ‘Injector’ class seems to be doing is making the method ‘defineClass’ (class: java.lang.ClassLoader) accessible so it can call it via reflections. This seems very dubious but AOP libraries and other systems that deal in dynamic code generation on the fly used to harness these internal mechanism, non-public Java APIs to make things possible.
Solution
One would think that if the default is ‘deny‘ for the argument –illegal-access, then it’s just a matter of setting the JVM to start with the value ‘permit‘ instead. Unfortunately as mentioned above, the argument switch itself is no longer considered by the JVM as of JDK 17. So assuming you’re bound for an ‘LTS’ version (17 or 21), then the switch is no longer a viable option.
The best practice would be to rid the code base of any instance of illegal access. Whether a code is written inhouse or in an external library; it should be made compliant by adopting best practices and/or update the non-compliant 3rd party library.
However that approach might not always be possible. So we look to configuration offered by the JPMS itself, namely the ‘opens‘ and ‘exports‘ arguments.
If the error is being experienced by a module that you have rebuild control over, then you can simply change the modules-info.java file. In this article I will assume that you’re working with a non-module/unnamed module code. In such a circumstance, the best way forward is to advise the JVM at runtime. This can be done two ways, JVM switches or JAR manifests headers.
JAR Manifests
The JEPS 261 notes make reference to two new headers/attributes available in Java 9. These correspond to the command line switches of the same name and are defined as:
- Add-Exports
- Add-Opens
The value of each attribute is a space-separated list of slash-separated module-name/package-name pairs. A
OpenJDK JPES 261<module>/<package>
pair in the value of anAdd-Exports
attribute has the same meaning as the command-line option--add-exports <module>/<package>=ALL-UNNAMED
. A<module>/<package>
pair in the value of anAdd-Opens
attribute has the same meaning as the command-line option--add-opens <module>/<package>=ALL-UNNAMED
.
Each of these headers can occur at most once, so all modules that need to be exported or opened need to be done so in the same respective attribute. For demonstration purposes, I’ll use Maven:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<manifestEntries>
<Add-Exports>java.base/sun.nio.ch</Add-Exports>
<Add-Opens>java.base/java.lang java.base/java.lang.reflect java.base/java.io</Add-Opens>
</manifestEntries>
</archive>
</configuration>
</plugin>
In the above code, we add a single export of sun.nio.ch from the java.base module to any unnamed modules. Along with several reflective access allowance to java.base module. On repackage, these headers will be added to JAR’s MANIFEST.MF file. When directly launching using java -jar command, the JVM will respect these attributes as if there were provided as command line arguments. Naturally in your own maven setup, you may choose to target specific execution/goals. Also the exact packages you open or export depends very much on the error you’re receiving.
JVM Switches
JAR manifest route might not always be viable, especially if the JAR is dynamically loaded or you don’t have the means to rebuild a project.
Command Line Arguments
The command line approach is very straight forward. If we are to replicate the same configuration from above then it would look something like this:
java --add-exports=java.base/sun.nio.ch=ALL-UNNAMED \
--add-opens=java.base/java.lang=ALL-UNNAMED \
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED \
--add-opens=java.base/java.io=ALL-UNNAMED \
-jar app.jar
Spring Boot Service/Executable JAR
Spring Boot offers a means to create executable jars. These are usually for running Spring Boot apps as system services. If setting the arguments in the service configuration itself is not feasible, then Spring Boot scans for a file in the same directory as the executable JAR with the filename same as the jar file, with a ‘.conf’ file extension.
This file provides support for specifying the ‘JAVA_OPTS’ config, in which the above arguments can be specified. An example of such a file would look like this:
RUN_ARGS="--spring.profiles.active=qa"
JAVA_OPTS="--add-exports=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED"
Discussion
As Java moves towards adopting security best practices – whether that’s JPMS or sealed classes – it’s evident that the best recourse is to pay down the tech debt and update the code to adopt these policies into your own code. Or if not your own code (in the case of external dependencies), then use updated libraries or different libraries that follow Java 9+ security best practices.
In the enterprise world that’s not always possible, so we discussed here resolution to one particular problem which occurs when migrating to JDK 16+ from JDK 11. These temporary fixes can buy you some time while allowing you to upgrade to a more modern versions of Java. Note that I am saying temporary fixes. Proper fixes should be made lest future deprecations come down the pipe.
Further Reading
- JDK 11 Migration Guide (Section: ‘Understanding Runtime Access Warnings’)
https://docs.oracle.com/en/java/javase/11/migrate/index.html - Internal JDK Elements Strongly Encapsulated in JDK 17
https://www.infoq.com/news/2021/06/internals-encapsulated-jdk17/ - A peek into Java 17: Encapsulating the Java runtime internals
https://blogs.oracle.com/javamagazine/post/a-peek-into-java-17-continuing-the-drive-to-encapsulate-the-java-runtime-internals