Java Reflection and Method Handles
Purpose
Keep dynamic access deliberate, narrow and described by metadata/contracts. Compilers and basic refactoring tools cannot prove a string-computed edge; specialized analyzers may approximate it, but correctness still depends on runtime inputs, loader/module identity and configuration. Two failure modes: application code using reflection where an interface would do, so a rename compiles and fails at runtime; and reflection over a name that came from outside the process, which becomes an execution primitive once initialization, construction or invocation is reachable.
Workflow
- Inspect the target and ownership contract. Read compiler release/toolchains, actual JDK/vendor, module descriptors, launch flags, loader boundaries and native-image/tool versions. The Java snippets fit Java 17 unless stated: method handles need Java 7+, VarHandles/JPMS/privateLookupIn Java 9+, sealed types Java 17+ and non-preview pattern switches Java 21+. Preserve the project target; do not enable preview, upgrade or open modules implicitly. Missing launch/loader evidence makes an access diagnosis conditional.
- Ask what varies. If the set of implementations is known at build time, an interface, a
sealed hierarchy or map of suppliers often suffices.
ServiceLoaderkeeps invocation typed but provider discovery/instantiation can still fail at runtime. Dynamic access can be appropriate for plugins, frameworks and tooling; inspect the actual variability. - If it must be dynamic, decide where the openness stops. One factory, one registry, one
adapter — never scattered
getDeclaredMethodcalls through business code. - Validate tokens before resolution. Map external tokens to code-owned types/operations.
asSubclassnarrows a genuinely configurable class to an expected supertype, but does not authorize its constructor, static initializer, loader, code source or later methods. - Choose the mechanism by frequency. A one-off at startup:
Class/Methodreflection is fine—but resolve/validate once and cache with a lifecycle-safe key. Repeated on a measured hot path: compare a stable typedMethodHandle, a bound functional adapter and generated code. - Register what the runtime cannot see — module
opens, native-image reflection configuration, AOT metadata — and test on the target runtime, because a JVM run proves nothing about a native image.
Rules
- Prefer an interface to reflection. The common shape — "instantiate the class named in configuration, then call it through an interface" — needs reflection only for the construction; every call afterwards goes through the interface, checked by the compiler.
- Prefer
ServiceLoaderto hand-rolled classpath scanning for plugin discovery: it is declarative (META-INF/servicesorprovides … within a module), the JDK's own mechanism, and visible to the module system. It does not provide ordering, dependency injection, failure isolation or unload lifecycle, and native-image support must still be verified for the toolchain. - Prefer build-time to run-time. An annotation processor or code generator produces code you can read, debug, and that the compiler checks; runtime reflection produces behaviour nobody can grep for. This is why modern frameworks moved mapping, validation and injection metadata towards build time — see java-annotations.
- Reflection loses more than performance: no ordinary compile-time type checking, incomplete rename/find-usage/dead-code results unless specialized tooling understands the metadata, and less direct stack traces. Those costs apply even when invocation happens once at startup.
setAccessible(true)on another module's private member fails under strong encapsulation unless the package is opened (opens,--add-opens). Requiring--add-opensin production is a design decision, not a workaround — record it and revisit it, because the JDK's direction is towards restricting it further. Reflecting into JDK internals is not a supported contract.- Never let a payload, header or message field directly select a class/member.
Class.forNamewith initialization can execute static initialization; construction/invocation and polymorphic deserialization can reach powerful gadget behavior. Map known tokens to known operations and validate code source/loader where plugins are allowed; a deny-list is not a security boundary— java-serialization-hardening covers the deserialisation side. - Wrap reflective failures at the boundary.
NoSuchMethodException,IllegalAccessExceptionandInvocationTargetExceptionare implementation detail; propagate a domain or configuration error, and always unwrapInvocationTargetException.getCause()— losing the cause hides the real exception under a generic wrapper (java-exception-design). - For repeated dynamic invocation, resolve a typed
MethodHandle(orVarHandle) once. A stable handle visible as a compiler constant often enables adapter/target inlining, but this is a JIT decision, not astatic finalguarantee.invokeWithArgumentsintentionally performs generic array/spreader adaptation;Method.invokehas varargs/boxing/wrapping/access costs. Measure the actual target and storage shape. Core reflection is MethodHandle/VarHandle-based since JDK 18; the old implementation was removed in JDK 22, making old inflation/direct-handle switches no-ops. - Use
VarHandlerather thansun.misc.Unsafeor reflection for low-level field access with explicit memory-ordering semantics. QueryisAccessModeSupported; final fields support reads, not arbitrary writes.varhandles-and-memory-orderingcovers the access modes. - Do not use reflection to bypass a design you control. Reaching into a private field to test a class, to mutate an immutable object, or to "just get this working" makes the private surface a de facto API that the next refactor breaks. In tests, prefer constructing the object through its real API — java-test-design.
- Native interop is a different boundary: foreign code can crash or corrupt the process, although Java-side checks may throw. FFM became final in Java 22; API support and native-access configuration depend on the target release. It is not a replacement for ordinary reflection; hand native interop decisions to jni-and-ffm and off-heap-memory.
- Closed-world native-image analysis may infer constant reflective edges and framework metadata,
but runtime-computed access needs owned reachability metadata. Missing edges may fail at build
time or only on an untrained runtime path. Test the native artifact itself; this constraint can
justify build-time alternatives—see
graalvm-native-image. - Treat
Lookup,MethodHandleandVarHandleas capabilities. Access checks happen when a handle is created; code receiving the handle can invoke it without re-proving the creator's private access. Never expose a full-power lookup or non-public handle across an untrusted plugin boundary; expose a narrow parent-owned interface instead. - Cache without pinning reloadable code. A
Class, reflected member, method handle, lambda/proxy class or cache value can retain its defining loader. Parent-loaded framework caches should use lifecycle eviction,ClassValuewhere appropriate, or rigorously tested weak-key designs.
Acceptance gate
- Resolve mandatory members/providers during their owner's startup and optional plugins during their own lifecycle; report missing/ambiguous signatures with loader/module/code source.
- Test the deployment modes actually supported: named/classpath modules, duplicate loaders, reload/unload and native artifacts where applicable. Do not build unused modes merely to satisfy this list.
- Exercise primitive/reference/null/varargs signatures and verify
WrongMethodTypeException, target exceptions and access failures are translated without losing causes. - Benchmark only after proving the reflective path is material; include direct/interface,
Method.invoke, stable/unstable handles and generated alternatives with allocation profiling.
Deliver the dynamic boundary, allowed operations, exact signature/access requirements, lifecycle and failure mapping, plus checks actually run. Separate structural/access correctness from unmeasured speed and untested native-image/reload claims.
References
- When reflection is justified, and what to use instead
— read when deciding whether a requirement genuinely needs reflection, when replacing
reflective code with an interface,
ServiceLoaderor generated code, or when reviewing reflection found in application code. - Method handles, VarHandles and module encapsulation
— read when dynamic access is unavoidable: choosing between
Method,MethodHandleand generated accessors, resolving handles correctly, and dealing withopens,--add-opensand native-image configuration. - What reflection and handles cost on the current runtime
— JEP 416's method-handle implementation of core reflection, the per-operation cost table
from
Class.forNametoinvokeWithArguments, and how to verify inlining and boxing rather than assume them; read when a hot path invokes reflectively, when a runbook still tunes thesun.reflect.*inflation flags, or when choosing betweenMethod.invoke,invoke,invokeExactandinvokeWithArguments.