> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Java InaccessibleObjectException: Causes and --add-opens Fix
- URL: https://www.ggorantala.dev/java-inaccessibleobjectexception-add-opens/
- Published: 2026-08-22T09:09:00.000Z
- Updated: 2026-08-31T09:14:25.000Z
- Description: InaccessibleObjectException: module java.base does not "opens java.util" to unnamed module. Why strong encapsulation breaks reflection and how to fix it properly.
- Author: Gopi Gorantala
- Tags: Java, core-java, inaccessibleobjectexception, jpms, java-modules, java-17, java-21, jvm, java-errors

# 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

```Log
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:

```Log
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:

```Log
... 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+:

```java
// 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);
    }
}
```

```bash
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.

```bash
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:

```Log
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:

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

On JDK 17+ that flag is dead:

```Log
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

| JDK    | setAccessible(true) on a JDK-internal member                                                                                                                                                                                                                                                                   | Message / flag behaviour                                                                                                                       |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **8**  | Succeeds. No module system.                                                                                                                                                                                                                                                                                    | Nothing printed.                                                                                                                               |
| **11** | Succeeds for packages that existed in JDK 8 (\--illegal-access=permit default, [JEP 261](https://openjdk.org/jeps/261) "relaxed strong encapsulation").                                                                                                                                                        | WARNING: An illegal reflective access operation has occurred, once per call site. \--illegal-access=deny reproduces the future failure.        |
| **16** | **Throws.** Default flipped to deny by [JEP 396](https://openjdk.org/jeps/396).                                                                                                                                                                                                                                | InaccessibleObjectException. \--illegal-access=permit still available as an escape hatch.                                                      |
| **17** | Throws. [JEP 403](https://openjdk.org/jeps/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. |
| **21** | Throws. Identical wording; the top frame differs — 11/17 throw from checkCanSetAccessible, 21 adds a throwInaccessibleObjectException frame, so do not match on line numbers.                                                                                                                                  | Same.                                                                                                                                          |
| **25** | Throws. Unchanged for reflection, but the surrounding "integrity by default" work tightened further: sun.misc.Unsafe memory-access methods warn by default ([JEP 498](https://openjdk.org/jeps/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](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/reflect/AccessibleObject.html#setAccessible%28boolean%29), 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:

```java
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:

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

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

```diff
- 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:

```xml
<!-- 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>
```

```groovy
// build.gradle
tasks.withType(Test).configureEach {
    jvmArgs '--add-opens', 'java.base/java.util=ALL-UNNAMED'
}
```

```Log
# MANIFEST.MF — honoured only for `java -jar app.jar`, ignored on -cp
Main-Class: Repro
Add-Opens: java.base/java.util
```

```bash
# 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:

```bash
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`:

```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:

```java
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.