Strong Encapsulation of JDK Internals in Java 16 and 17

Introduction For years, many libraries and applications reached into the JDK's internal classes - sun.* and com.sun.* packages, and private fields of classes like String - using reflection and setAccessible(true). The module systemClick to read: module system of Java 9 made these internals non-exported, but kept a loophole open so that existing code would not break: reflective access was allowed with a warning. Java 16Click to read tutorial: Overview of New Features in Java 16 (JEP 396) closed the loophole by default, and Java 17Click to read tutorial: Overview of New Features in Java 17 (JEP 403) removed the option to reopen it globally. This tutorial explains the strong encapsulation of JDK internals: what changed in each release, the errors you get, which internals are still accessible, and how to fix or work around the errors. Why encapsulate the internals?
  • Security: internals such as the private fields of String or the JDK's class-loading machinery could be changed by any code, undermining guarantees the platform relies on.
  • Maintainability: code depending on internals breaks whenever the JDK changes them - which held back JDK development and made upgrades painful. Many problems when moving from Java 8 to 11 came from libraries using internals.
  • Integrity: with the internals encapsulated, the JDK can optimize and change its implementation freely, as long as the public APIs stay the same.
What changed, release by release
  • Java 9 to 15: code on the class path could still use deep reflection on JDK internals which existed in Java 8, with a single warning: "WARNING: An illegal reflective access operation has occurred". The option --illegal-access controlled this, with permit as the default.
  • Java 16: the default became --illegal-access=deny: such access now fails with an exception. --illegal-access=permit could still restore the old behaviour.
  • Java 17: the --illegal-access option was removed - the JVM ignores it with a warning. The only way to open an internal package is now explicitly, one package at a time, with --add-opens.
The error in action The program below tries to read the private value field inside a String, a classic example of deep reflection into the JDK -
Reflective access to a JDK internal field
//DeepReflection.java
import java.lang.reflect.Field;
import java.lang.reflect.InaccessibleObjectException;

public class DeepReflection {
  public static void main(String[] args) throws Exception {
    Field value = String.class.getDeclaredField("value");
    try {
      value.setAccessible(true);
      byte[] bytes = (byte[]) value.get("Java");
      System.out.println("Read String's internal bytes: " + bytes.length);
    } catch (InaccessibleObjectException e) {
      System.out.println("InaccessibleObjectException:\n  " + e.getMessage().replaceAll("@[0-9a-f]+", "@..."));
    }
  }
}
 OUTPUT of the above code
InaccessibleObjectException:
  Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @...
Explanation of the code
  • getDeclaredField() still works - you can see that the field exists - but setAccessible(true) throws an InaccessibleObjectException (a RuntimeException added in Java 9).
  • The message explains why: the java.base module does not open the package java.lang to our code, which lives in the unnamed module because it is on the class path. (The module's identity hash is replaced by "..." in the output.)
  • Ordinary use of public APIs is unaffected - only deep reflection into non-public members of JDK internals, and direct use of non-exported packages, is blocked.
Opening a package with --add-opens When a library genuinely needs access, and cannot yet be upgraded, the package can be opened explicitly on the command line. The form is --add-opens module/package=target-module, where ALL-UNNAMED means all code on the class path -
java --add-opens java.base/java.lang=ALL-UNNAMED DeepReflection
 OUTPUT of the above command
Read String's internal bytes: 4
The old global switch no longer exists from Java 17 -
java --illegal-access=permit DeepReflection
 OUTPUT of the above command
Java HotSpot(TM) 64-Bit Server VM warning: Ignoring option --illegal-access=permit; support was removed in 17.0
InaccessibleObjectException:
  Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @...
The JVM prints a warning that the option is ignored, and the access still fails. --add-opens (and --add-exports, for compile-time and run-time use of non-exported packages) can also be put in the Add-Opens attribute of an executable JAR's manifest, or in the JDK_JAVA_OPTIONS environment variable.
What is still accessible: sun.misc.Unsafe A few critical internal APIs, for which there were no public replacements, were deliberately left accessible in the jdk.unsupported module - most importantly sun.misc.Unsafe, used by many high-performance libraries, and sun.reflect.ReflectionFactory, used by serialization libraries -
sun.misc.Unsafe is still accessible
//UnsafeStillThere.java
import java.lang.reflect.Field;

public class UnsafeStillThere {
  public static void main(String[] args) throws Exception {
    Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
    f.setAccessible(true);               // allowed: jdk.unsupported opens sun.misc
    Object unsafe = f.get(null);
    System.out.println("Got " + unsafe.getClass().getName() + " from module " + unsafe.getClass().getModule().getName());
  }
}
 OUTPUT of the above code
Got sun.misc.Unsafe from module jdk.unsupported
These too are being replaced by supported APIs - VarHandle (Java 9) and the Foreign Function & Memory APIClick to read: Foreign Function & Memory API (Java 22) cover most uses of Unsafe, and its memory-access methods were deprecated for removal in Java 23, with warnings on use from Java 24. How to migrate
  1. Find the problems: run jdeps --jdk-internals on your application and its libraries, which lists uses of JDK internals and suggests replacements, and run your tests on the new Java version.
  2. Upgrade libraries: most errors come from old versions of libraries (bytecode generators, serializers, mocking frameworks, dependency injection). Their current versions use supported APIs.
  3. Replace internal APIs in your own code with public ones - for example java.util.Base64 instead of sun.misc.BASE64Encoder, or MethodHandles and VarHandle instead of reflection hacks.
  4. Use --add-opens only as a temporary measure, for the specific packages that still need it, and track each one so it can be removed.
Summary In this tutorial we saw how Java 16 and 17 strongly encapsulated the JDK's internals: Java 16 denied deep reflection into them by default, and Java 17 removed the --illegal-access option. We saw the InaccessibleObjectException this causes, opened a single package with --add-opens, and saw that sun.misc.Unsafe remains accessible through the jdk.unsupported module. Lastly, we went through the steps to migrate code which uses JDK internals. Next, read the overview of new features in Java 17Click to read tutorial: Overview of New Features in Java 17.