Your team is upgrading from JDK 8 to JDK 21 and worried about strongly-encapsulated internal APIs. How do you use the JPMS tooling to assess and de-risk this, and what are the tooling's limits?
answer
- jdeps --jdk-internals over ALL first- + third-party JARs = inventory
- JEP 396/403: strong encapsulation default -> internals fail at runtime
- Triage: fix our code / upgrade dep / temporary --add-opens bridge
- Blind spot: reflection, ServiceLoader, agents, generated bytecode
- Runtime-test on JDK 21 early + jdeps in CI to prevent regressions
basics
~20 sRun jdeps --jdk-internals across all your code and dependency JARs to find usage of internal JDK APIs and their suggested replacements, then fix or replace those before upgrading. Because jdeps is static, also runtime-test, since reflective access to internals won't show up.
solid answer
~40 sI'd treat it as an inventory-then-remediate program. First, run `jdeps --jdk-internals` (with --multi-release where relevant) over every first-party module and every third-party JAR to build a complete inventory of internal-API usage (sun.misc.Unsafe, jdk.internal.*, etc.), since modern JDKs strongly encapsulate these (JEP 396/403) and access fails at runtime. jdeps prints supported replacements where they exist. I'd prioritize by whether it's our code (fix directly) or a dependency (upgrade the library, find an alternative, or, as a temporary bridge, use --add-opens/--add-exports at launch). Crucially I'd pair this with runtime testing: jdeps is static and cannot see reflective access, ServiceLoader, agents, or generated bytecode, so a clean report is necessary but not sufficient. I'd run the full test suite and key paths on JDK 21 early, watching for InaccessibleObjectException, and bake jdeps into CI to prevent regressions.
code
java · 29 lines// 1) Inventory internal-API usage across an app and its deps:
// jdeps --jdk-internals --multi-release 21 -R -cp 'libs/*' app.jar
//
// Sample finding:
// app.jar -> JDK removed internal API
// com.acme.FastBuf -> sun.misc.Unsafe (suggested: java.lang.invoke.VarHandle)
//
// 2) Temporary bridge at launch when a dep can't be fixed yet (TRACK AS DEBT):
// java --add-opens java.base/sun.misc=ALL-UNNAMED -jar app.jar
//
// 3) Proper fix in our own code: replace Unsafe-style access with VarHandle
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class Counter {
private volatile int value;
private static final VarHandle VALUE;
static {
try {
VALUE = MethodHandles.lookup()
.findVarHandle(Counter.class, "value", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
int incrementAndGet() {
return (int) VALUE.getAndAdd(this, 1) + 1; // supported, no sun.misc.Unsafe
}
}go deeper
Understands that some old APIs are 'internal' and that a tool (jdeps) can flag them before upgrading.
Runs jdeps --jdk-internals on the app and dependencies, reads the suggested replacements, and knows strong encapsulation can break things at runtime.
Builds the full inventory across first- and third-party JARs, triages fixes vs upgrades vs --add-opens bridges, and pairs static analysis with runtime testing for reflective gaps.
Runs the whole migration as a risk program: complete touchpoint register with ownership and remediation, CI enforcement, an audited list of escape-hatch flags, and an explicit account of where static tooling under-reports versus runtime evidence.
## The risk being assessed Before Java 9, lots of code reached into **internal JDK APIs** — classes in `sun.*`, `com.sun.*`, `jdk.internal.*` — that were never part of the supported, public API. The classic example is `sun.misc.Unsafe`. The module system (Java 9) began **encapsulating** these, and **JEP 396 (Java 16)** then **JEP 403 (Java 17)** made strong encapsulation the default: reflective or direct access to internals now **fails at runtime** unless explicitly opened. So upgrading 8 → 21 can turn silent dependencies on internals into hard failures (`InaccessibleObjectException`, `IllegalAccessError`, or missing classes). Terms: - **Strong encapsulation**: the module system blocks access to a module's non-exported/non-opened packages, even via reflection. - **`--add-opens` / `--add-exports`**: launch-time JVM flags that punch a hole to re-allow access to a specific package — an escape hatch, not a fix. - **Multi-release JAR**: a JAR carrying version-specific class variants; you must analyze the right release. ## Step 1 — Inventory with jdeps `jdeps --jdk-internals` is the headline tool. Run it across: - every **first-party** artifact, and - every **third-party** JAR on the classpath (use `-R`/`--recursive` and feed the full dependency set). For multi-release JARs add `--multi-release 21` (or the relevant version) so you analyze the classes that will actually run. The output names each offending class and, where the JDK provides one, a **suggested supported replacement** (e.g. "use java.lang.invoke.VarHandle instead of Unsafe"). This yields a complete, prioritizable inventory. ## Step 2 — Triage and remediate - **Our code using internals** → rewrite against the suggested public API (e.g. `VarHandle`/`MethodHandles` for `Unsafe`-style access, `java.net.http` for old internal HTTP). - **A dependency using internals** → upgrade to a JDK-21-compatible version; if none, find an alternative library or fork. - **No immediate fix** → as a *temporary bridge only*, launch with `--add-opens`/`--add-exports` to re-open the needed package, and track it as debt. This unblocks the upgrade without rewriting, but it's brittle and may break again. ## Step 3 — Cover jdeps's blind spot with runtime testing This is the part principals must not skip. **jdeps is static**: it reads bytecode references. It **cannot** see: - **Reflective** access (`Class.forName`, `setAccessible(true)`), - **ServiceLoader** / dynamic providers, - **Java agents** and **runtime-generated bytecode** (Spring CGLIB proxies, mocking libs, ORMs), - **string-driven** access where the class name is computed. So a clean `jdeps --jdk-internals` does **not** prove the app runs. Mitigation: run the **full test suite and critical production paths on JDK 21 early**, watch logs for `InaccessibleObjectException` and `IllegalAccessException`, and consider enabling the JVM to surface illegal access where available. Combine static inventory with empirical runtime evidence. ## Step 4 — Institutionalize Bake `jdeps --jdk-internals` into **CI** so a newly-added dependency that touches internals fails the build, preventing re-accumulation of this debt. Keep an explicit, reviewed list of any `--add-opens`/`--add-exports` flags as known risk. ## The principal-level judgment The deliverable isn't "ran a tool" — it's a **risk register**: every internal-API touchpoint, owned (us vs vendor), with a remediation (fix / upgrade / temporary flag), plus a runtime-validation gate acknowledging that static analysis under-reports. You're managing the gap between *what tooling can prove* and *what only running the system reveals*.
- A vendor JAR uses sun.misc.Unsafe and has no compatible release. What's your pragmatic path?As a tracked, temporary bridge, launch with --add-opens for the specific package so encapsulation allows the access, log it as risk, and in parallel pursue an upgrade/replacement/fork. The flag unblocks the migration without endorsing it as permanent.
- Why is jdeps insufficient on its own for upgrade assurance?It's static and only sees compile-baked references; reflective access, ServiceLoader, Java agents, and runtime-generated bytecode are invisible. A clean report is necessary but not sufficient — runtime testing on the target JDK is required.
- How do you prevent this debt from re-accumulating after the upgrade?Add jdeps --jdk-internals to CI so any new dependency touching internal APIs fails the build, and keep a reviewed register of any --add-opens/--add-exports flags still in use.
saying these in an interview costs you the question
- Treating a clean jdeps report as proof the upgrade is safe (ignores reflection/agents)
- Making --add-opens/--add-exports the permanent solution instead of a tracked bridge
- Only scanning first-party code and skipping third-party JARs
- Forgetting --multi-release when analyzing multi-release JARs
- Confusing public deprecated APIs with strongly-encapsulated internal APIs