Skip to content

Java InaccessibleObjectException: Causes and --add-opens Fix

InaccessibleObjectException: module java.base does not "opens java.util" to unnamed module. Why strong encapsulation breaks reflection and how to fix it properly.

Java core-java inaccessibleobjectexception jpms java-modules java-17 java-21 jvm java-errors
Gopi Gorantala
Reading Progress

On This Page

Java InaccessibleObjectException: Causes and --add-opens Fix

Your Java 8 application worked for a decade. You bumped the base image to 17 or 21 and the first request blew up in a library you have never opened in an IDE. This is the single most common runtime failure of an 8 → 11/17/21 migration, and the fix people reach for first is usually the wrong one.

1. The Error

Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field transient java.lang.Object[] java.util.ArrayList.elementData accessible: module java.base does not "opens java.util" to unnamed module @482cd91f
	at java.base/java.lang.reflect.AccessibleObject.throwInaccessibleObjectException(AccessibleObject.java:391)
	at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:367)
	at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:315)
	at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:183)
	at java.base/java.lang.reflect.Field.setAccessible(Field.java:177)
	at Repro.main(Repro.java:7)

The message has three variable parts, and each one tells you something:

The verb — "opens" vs "exports". If the package is exported (public API you can compile against) but not opened, you get does not "opens java.util". If the package is not exported at all — a genuine JDK internal — you get:

java.lang.reflect.InaccessibleObjectException: Unable to make public static boolean jdk.internal.misc.VM.isBooted() accessible: module java.base does not "exports jdk.internal.misc" to unnamed module @453da22c

The target — unnamed module @<hash> vs a module name. Classpath code lives in the unnamed module. If your caller is on the module path you get the module name instead, with no hash:

... module java.base does not "opens java.util" to module com.example.app

The member. The full Field/Method/Constructor signature, including transient, final, throws clauses — printed straight from Member.toString().

Two exceptions are frequently confused with this one:

  • java.lang.IllegalAccessException: class R5 cannot access a member of class java.util.ArrayList (in module java.base) with modifiers "private" — you never called setAccessible(true). Plain Java access control, not modules.
  • java.lang.IllegalAccessError: class Test (in unnamed module @0x5e481248) cannot access class sun.security.util.SecurityConstants (in module java.base) because module java.base does not export sun.security.util to unnamed module @0x5e481248 — static, non-reflective access to a non-exported class. That one needs --add-exports, not --add-opens.

InaccessibleObjectException first exists in JDK 9. It is unchecked, extends RuntimeException, and only ever comes out of AccessibleObject.setAccessible(true) (or Module.addOpens misuse).

2. How to Reproduce It

Single file, runs with the source launcher on Java 11+:

// Repro.java
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

public class Repro {
    public static void main(String[] args) throws Exception {
        Field f = ArrayList.class.getDeclaredField("elementData");
        f.setAccessible(true);                       // throws on JDK 16+
        Object[] backing = (Object[]) f.get(new ArrayList<>(List.of("a")));
        System.out.println(backing.length);
    }
}
java Repro.java                       # JDK 16+ : InaccessibleObjectException
java --add-opens java.base/java.util=ALL-UNNAMED Repro.java   # prints 1

Java 8 equivalent — same source, compiled the old way; it works and prints 1 with no warning.

javac Repro.java && java Repro        # JDK 8: no warning, no exception

On JDK 9–15 the same code runs but prints the deprecation-era warning — once, for the first offending call site only:

WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by Repro (file:/tmp/repro/j11/r.jar) to field java.util.ArrayList.elementData
WARNING: Please consider reporting this to the maintainers of Repro
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

You can force the future on JDK 11 to find every offender before you migrate:

java --illegal-access=deny -jar app.jar    # JDK 9-16 only

On JDK 17+ that flag is dead:

OpenJDK 64-Bit Server VM warning: Ignoring option --illegal-access=permit; support was removed in 17.0

Environment-specific triggers. In real systems this rarely fires from your code. It fires from Jackson deserializing into java.util/java.time types, Objenesis/Mockito instantiating without a constructor, Spring's ReflectionUtils on JDK collections, Hibernate proxying, Groovy/Kryo/Gson field walkers, and anything that unmaps a DirectByteBuffer via sun.nio.ch. It commonly appears only under Surefire (which forks its own JVM without your app's flags), or only in the container image where JAVA_TOOL_OPTIONS differs from your laptop.

3. Version Behaviour Matrix

JDKsetAccessible(true) on a JDK-internal memberMessage / flag behaviour
8Succeeds. No module system.Nothing printed.
11Succeeds for packages that existed in JDK 8 (--illegal-access=permit default, JEP 261 "relaxed strong encapsulation").WARNING: An illegal reflective access operation has occurred, once per call site. --illegal-access=deny reproduces the future failure.
16Throws. Default flipped to deny by JEP 396.InaccessibleObjectException. --illegal-access=permit still available as an escape hatch.
17Throws. JEP 403 removed the escape hatch.OpenJDK 64-Bit Server VM warning: Ignoring option --illegal-access=permit; support was removed in 17.0. Only --add-opens/--add-exports work.
21Throws. Identical wording; the top frame differs — 11/17 throw from checkCanSetAccessible, 21 adds a throwInaccessibleObjectException frame, so do not match on line numbers.Same.
25Throws. Unchanged for reflection, but the surrounding "integrity by default" work tightened further: sun.misc.Unsafe memory-access methods warn by default (JEP 498, default warn since JDK 24, moving to deny in 26+), and native access is gated by --enable-native-access.Same.

sun.misc and sun.reflect remain exported and open from the jdk.unsupported module on every release — that is why sun.misc.Unsafe kept working while everything else broke.

4. Why It Happens — Surface Level

Since Java 9 the JDK is a set of modules. A module declares three separate things: what it requires (readability), what it exports (compile-time and runtime access to public members), and what it opens (deep reflection into any member, including private ones). java.base exports java.util but does not open it to anybody.

setAccessible(true) is a request to suppress access checks. From JDK 16 onward the JVM refuses that request unless the declaring package is open to your module. Your code on the classpath is in the unnamed module, and java.base opens nothing to it.

5. Why It Happens — Under the Hood

AccessibleObject.setAccessible(true) calls checkCanSetAccessible(Reflection.getCallerClass(), declaringClass). The check, per the setAccessible Javadoc, succeeds only if caller and target are in the same module, or the member is public in a public class in an exported package, or the target package is open to the caller's module. Unnamed and open modules are open to everything, which is why reflecting into your own classpath classes never fails.

The runtime state lives in java.lang.Module: each module carries maps of exported and opened packages keyed by target module, plus "to everyone" and "to all unnamed" flags, mirrored in the VM's ModuleEntry export tables. Module.isOpen(String pn, Module other) is the actual predicate — you can call it yourself:

ArrayList.class.getModule().isOpen("java.util", Repro.class.getModule());  // false

--add-opens does not go through some side channel. The launcher turns each occurrence into a jdk.module.addopens.N system property; during startup jdk.internal.module.ModuleBootstrap.boot() reads them and calls Modules.addOpens(...), which mutates those same maps before your main class is loaded. That is why the flag must appear before -jar/the main class, and why calling Module.addOpens from your own code at runtime cannot help you: a module can only open its own packages, and java.base will not do it on request.

Two consequences engineers get wrong:

  • MethodHandles.privateLookupIn is not a bypass. It performs the same check and throws IllegalAccessException: module java.base does not open java.util to unnamed module @4c6e276e.
  • Opening a package does not make final fields writable. f.setInt(null, 2) on a static final still fails with IllegalAccessException: Can not set static final int field ... to (int)2. Strong encapsulation and final-field immutability are separate guarantees.

The distinction between the "opens" and "exports" wording comes from the same code path: throwInaccessibleObjectException picks the verb by asking whether the package is exported to the caller at all. Seeing "exports" means you are touching a genuine internal (jdk.internal.*, sun.nio.ch, sun.security.*) and --add-opens alone will not be enough — you also need --add-exports to compile.

On JDK 9–15, ModuleBootstrap implemented JEP 261's relaxed encapsulation by opening every java.* package that had existed in JDK 8 to the unnamed module at startup, and installing an IllegalAccessLogger that printed one warning per unique call site. Packages introduced in 9+ were never covered — which is why some code failed on 9 while most didn't.

6. The Fix

Fix 1 — upgrade the library (do this first). Nine times out of ten the reflection is not yours. Modern Jackson, Mockito/Objenesis/Byte Buddy, Spring 6, and Hibernate 6 no longer poke at java.base internals. Find the owner before you widen the JVM's attack surface:

mvn dependency:tree | grep -i objenesis
jdeps --jdk-internals --multi-release 21 target/app.jar

Fix 2 — open exactly what is needed, at runtime.

- java -jar app.jar
+ java --add-opens java.base/java.util=ALL-UNNAMED \
+      --add-opens java.base/java.lang=ALL-UNNAMED \
+      -jar app.jar

Syntax is --add-opens <module>/<package>=<target-module>; ALL-UNNAMED means classpath code. One flag per package — there are no wildcards. If your caller is a named module, name it: --add-opens java.base/java.util=com.example.app.

Where to put it depends on who starts the JVM:

<!-- pom.xml — Surefire/Failsafe fork a JVM that does NOT inherit your flags -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <argLine>--add-opens java.base/java.util=ALL-UNNAMED</argLine>
  </configuration>
</plugin>
// build.gradle
tasks.withType(Test).configureEach {
    jvmArgs '--add-opens', 'java.base/java.util=ALL-UNNAMED'
}
# MANIFEST.MF — honoured only for `java -jar app.jar`, ignored on -cp
Main-Class: Repro
Add-Opens: java.base/java.util
# When you cannot change the command line (containers, wrappers, IDE runners)
export JDK_JAVA_OPTIONS="--add-opens java.base/java.util=ALL-UNNAMED"   # Java 9+, `java` launcher only

Note Add-Opens in the manifest is space-separated module/package pairs with no =target — it always targets the JAR's own module.

Fix 3 — for compile-time access to a non-exported package, you need --add-exports on both javac and java:

javac --add-exports java.base/sun.security.util=ALL-UNNAMED R6.java
java  --add-exports java.base/sun.security.util=ALL-UNNAMED R6

Without it javac says: error: package sun.security.util is not visible ... (package sun.security.util is declared in module java.base, which does not export it to the unnamed module).

Fix 4 — stop reflecting. The ArrayList.elementData example has a public answer (toArray()), and most real cases do too.

7. Best Practices & The Better Design

Treat every --add-opens as a documented, owned exception with a removal ticket — not a permanent line in your Dockerfile. The better design is to make reflection unnecessary or explicit:

  • Fail soft where reflection is optional. trySetAccessible() (Java 9+) returns false instead of throwing, so a diagnostic feature degrades rather than taking the process down.
  • Open your own packages declaratively, not with flags. If Jackson needs your DTOs, say so in module-info.java:
module com.example.app {
    requires com.fasterxml.jackson.databind;
    opens com.example.dto to com.fasterxml.jackson.databind;   // deep reflection, scoped
    exports com.example.api;                                   // public API only
}
  • Prefer records and constructor-based binding over field injection: a record's canonical constructor is public API, so no framework needs to open anything.

Rewritten, this compiles and runs on any JDK from 9 to 25 with zero flags:

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

public class Fixed {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>(List.of("a", "b"));

        // Public API instead of ArrayList.elementData
        Object[] snapshot = list.toArray();
        System.out.println("elements = " + snapshot.length);

        // Optional introspection that degrades instead of exploding
        try {
            Field f = ArrayList.class.getDeclaredField("elementData");
            if (f.trySetAccessible()) {
                System.out.println("capacity = " + ((Object[]) f.get(list)).length);
            } else {
                System.out.println("capacity = unavailable (java.util not open)");
            }
        } catch (ReflectiveOperationException e) {
            System.out.println("capacity = unavailable (" + e.getClass().getSimpleName() + ")");
        }
    }
}

8. How to Prevent It Long-Term

  • Scan before you migrate. jdeps --jdk-internals --multi-release 21 app.jar lists every internal API your JAR touches statically; jdeprscan --release 21 app.jar reports deprecated and removed APIs. Neither sees reflection by name, so also run with --illegal-access=deny on a JDK 11 job before jumping to 17.
  • Run the test suite on the next LTS in a parallel CI job. A Java-21 matrix entry that is allowed to fail turns a production surprise into a pull-request comment.
  • Ban sun.* and jdk.internal.* in CI with de.thetaphi:forbiddenapis or an ArchUnit rule, so no new offender lands.
  • Keep the flag list in one place — a single JAVA_OPTS/jvmArgs definition shared by the app, Surefire, and the container entrypoint. Divergence between them is the reason "it works locally, fails in tests".
  • Verify what the JVM actually got: jcmd <pid> VM.command_line, or jcmd <pid> VM.system_properties | grep jdk.module. java --show-module-resolution and java --list-modules explain the resolved graph when a module is unexpectedly absent.
  • Log the exception properly. InaccessibleObjectException inside a framework is usually wrapped; make sure your handlers print the full cause chain, or you will see only "could not deserialize".

Related failures worth linking: module encapsulation also explains ClassNotFoundException: javax.xml.bind.DatatypeConverter (Java EE modules removed in JDK 11) and the sun.misc.Unsafe warnings of JEP 498; the same 8 → 17 upgrade usually surfaces UnsupportedClassVersionError from a stale runtime, and NoSuchMethodError from libraries shaded against an older java.base.

9. Key Takeaways

  • InaccessibleObjectException is thrown only by setAccessible(true); if you never called it, you are looking at IllegalAccessException or IllegalAccessError and the fix is different.
  • Read the verb: "opens"--add-opens; "exports" → the package is a real internal and you need --add-exports (plus, usually, a different design).
  • JDK 16 (JEP 396) flipped the default to deny; JDK 17 (JEP 403) removed --illegal-access entirely — there is no global escape hatch after 16.
  • --add-opens must be on the launcher command line (or JDK_JAVA_OPTIONS, or the Add-Opens manifest attribute for java -jar); Surefire and Gradle test JVMs need it configured separately.
  • MethodHandles.privateLookupIn performs the same check, and opening a package still will not let you write final fields.
Javacore-javainaccessibleobjectexceptionjpmsjava-modulesjava-17java-21jvmjava-errors

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments