You move a service from Java 11 to Java 21 and it fails at startup with InaccessibleObjectException. What changed, and what do you do about it?
Strong encapsulation of JDK internals became the default in JDK 16 and JDK 17 removed the option to relax it wholesale, so deep reflection into a package nobody opened now throws instead of warning. The fix is a targeted --add-opens for one module and package, or a library version that no longer needs one.
What the interviewer is scoring
- Does the candidate distinguish opens from exports instead of treating the two flags as interchangeable
- Whether the module and package are read out of the exception rather than a flag being copied from a search result
- That classpath code is placed in the unnamed module, so the failure is attributed to JDK internals and not to their own packages
- Can they say why JDK 17 no longer offers a single switch that turns the enforcement off
- Whether upgrading the offending library is treated as the fix and the flag as a stopgap
Answer
What changed between the two releases
The module system arrived in Java 9, but for compatibility it was not enforced against code on the classpath. Reflective access into JDK internals still worked and produced a warning, and --illegal-access let you choose how loudly. JDK 16 flipped the default to deny, and JDK 17 removed the --illegal-access option entirely, so there is no longer any way to relax encapsulation wholesale. What used to be a line in your logs that everyone learned to ignore is now an InaccessibleObjectException at the moment the reflective call happens, which for a framework doing its scanning at startup means the context never comes up.
Note what has not changed. Your own code, and your dependencies, are still on the classpath and still in the unnamed module, where everything is open to everything else. The failure is always about reaching into a module the JDK ships. If a stack trace says a library cannot reflect over one of your entity classes, encapsulation is not your problem and you should look at your own module declaration or at the library's configuration instead.
Exports and opens are not the same permission
The two flags answer two different questions, and conflating them wastes an afternoon.
--add-exports grants normal compile-and-run access to the public types of a package that the module does not export. You need it when code refers to an internal API by name — an old library calling into a sun.* or com.sun.* class, for instance.
--add-opens grants deep reflective access: reading and writing non-public fields, calling non-public methods, and crucially calling setAccessible(true) on any member. You need it whenever a framework introspects a type rather than merely referring to it, which covers serialisation, dependency injection, mocking, proxy generation and most object-copying utilities.
Both take the same shape, naming the source module, the package and the module being granted access, with ALL-UNNAMED standing for everything on the classpath:
--add-opens java.base/java.lang=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
The exception text tells you precisely which of these to write. It names the member, the module that owns it, the package, and the fact that the package is not open to your module. Translate those three names into the flag; do not guess, and in particular do not paste the blanket list of six --add-opens flags that circulates for build tools, because each one you add is a permission you now have to justify to whoever reviews your runtime configuration.
Getting the flag to the place that needs it
A flag that works on your laptop and not in CI is the usual second act of this migration, because there are three or four independent launchers involved. Your build's test plugin has its own JVM argument list. Your IDE has another. The container image has an entry point, and an application server or launcher script may have a third. The java launcher also reads options from the JDK_JAVA_OPTIONS environment variable, which has been honoured since Java 9 and is a reasonable way to inject the same flags into every launch of a container without editing each command line.
For an executable jar there is a tidier option: the Add-Opens and Add-Exports manifest attributes let the artefact declare what it needs, so the permission travels with the code rather than with the invocation. That is worth preferring wherever it applies, because the failure mode of flags-on-the-command-line is that somebody starts the process a new way and the flags are silently absent.
Why the flag is a debt and not a fix
Every --add-opens you add is a statement that some component depends on an implementation detail of the JDK, and that dependency will break again. The releases keep moving: the security manager was deprecated for removal by JEP 411 in JDK 17, and the memory-access methods of sun.misc.Unsafe have been deprecated for removal since JDK 23. A dependency that reaches into internals to do reflection today is a dependency that will need attention at the next boundary too.
So treat the flag as a note in your migration ticket rather than the resolution of it. In practice, almost every library that needed opening on Java 11 has a current version that does not, because the maintainers had the same deadline you did and moved to public API — method handles, the varhandle API, or a supported extension point. Upgrading dependencies first and then adding flags only for what remains usually leaves you with a much shorter list than starting from the flags. Where a library is unmaintained and reaches into java.base, you have found a genuine architectural decision rather than a configuration one, and it should be visible as such.
What breaks in the same migration without throwing anything
The quiet changes deserve a mention, because they cause the incidents that follow a week later. JEP 400 made UTF-8 the default charset for the standard APIs from JDK 18 onwards, so any code that called a FileReader or String.getBytes without specifying a charset changes behaviour when the host locale was not UTF-8 to begin with. Files written by the old version and read by the new one, or vice versa, are the failure you find in a data pipeline rather than in a log. The fix is the same one that was correct all along, which is to name the charset at every boundary, and the migration is a good excuse to do it.
Alongside that, check anything that parses the Java version string, anything that depends on the default garbage collector, and any agent or bytecode-manipulation library, since those track internal structure by nature and are the components most likely to need a version bump rather than a flag.
Read the module and package out of the exception and grant exactly that with
--add-opens, then treat the flag as a temporary record of a dependency on JDK internals. The real fix is almost always a library version that stopped needing it.
Likely follow-ups
- Which of --add-opens and --add-exports would a serialisation library need to reflect over a JDK type, and why?
- How do you make these flags apply to your test runner and your production launcher without maintaining the list twice?
- What replaced sun.misc.Unsafe for the operations libraries were using it for?
- What else about default platform behaviour changed between JDK 11 and JDK 21 that could alter output with no error at all?
Related questions
- Where does a class come from at runtime, and what is the difference between ClassNotFoundException and NoClassDefFoundError?hardAlso on jpms5 min
- How does Node load your modules, and how would you make a Node service use more than one core?mediumAlso on modules6 min
- How would you test a model for bias?hardSame kind of round: concept6 min
- Leadership wants DORA metrics on a dashboard for every team. How do you use them well, and how would each one be gamed?hardSame kind of round: concept4 min
- Which delivery metrics would you actually track for a team, and why does velocity stop working the moment you set a target for it?mediumSame kind of round: concept6 min
- Every architecture document you have inherited is out of date. How do you write documentation that survives contact with a changing system?mediumSame kind of round: concept4 min
- What is the difference between SOC 2 and ISO 27001, and what does an auditor accept as evidence?mediumSame kind of round: concept6 min
- Where do you draw the line between a unit test and an integration test, and how do you keep a few thousand of them under ten minutes?hardSame kind of round: concept5 min