Skip to content

Week 7 - Method Handles, VarHandles, and Reflection

Conceptual Core

Three generations of "do something dynamically" in the JVM, each with a different cost model:

  • java.lang.reflect (1.1+) - original. Method.invoke is a native dispatcher, opaque to the JIT, ~10-100× slower than a direct call.
  • MethodHandle (7+) - typed handles, JIT-inlinable. Substrate for lambdas, Spring 5+, Hibernate 6+, Jackson 2+.
  • VarHandle (9+) - typed handles to variables with explicit memory-ordering modes. Replaces almost every legitimate use of sun.misc.Unsafe.

The mental model: reflection for tooling, MethodHandle for hot-path dynamic dispatch, VarHandle for concurrent code. Pick wrong, hot path is 50× slower than necessary.

The trap

Caching a Method in a static field thinking it's "fast now." It isn't - every .invoke() still pays the dispatcher cost. The 7+ idiom: lookup.unreflect(method) to convert to a MethodHandle, cache that, call .invokeExact(args...).

Mechanical Detail

  • MethodHandles.Lookup is capability-based: it encodes the access rights of the class that obtained it. lookup.findVirtual(...), lookup.findStatic(...). To reach private members in another class, use MethodHandles.privateLookupIn(target, lookup) - modern replacement for setAccessible(true).
  • invokeExact vs invoke: invokeExact requires the call-site signature to exactly match the handle's MethodType (including return type). invoke allows asType conversions but loses inlining. Always invokeExact in hot paths.
  • VarHandle memory modes: Plain, Opaque, Acquire/Release, Volatile - map 1:1 to C++20 relaxed/opaque/acquire+release/seq_cst. Use the weakest that proves correctness.
  • Foreign Function & Memory API (java.lang.foreign, stable in 22+) - MemorySegment, MemoryLayout, Linker.nativeLinker() returns a MethodHandle to a C function. Replaces JNI for new code. MethodHandle is the substrate.
  • The strong-encapsulation saga: Java 9 introduced modules; 17 made --illegal-access=deny permanent. Old libraries reaching into java.base need --add-opens java.base/java.lang=ALL-UNNAMED. Fix path: prefer Panama/VarHandle/privateLookupIn over --add-opens.

Lab

Build a tiny DI container (~150 lines): scan a package for @Inject constructors, topologically sort by dependencies, instantiate with MethodHandle (lookup.unreflectConstructor(ctor).invokeExact(deps)). JMH vs Constructor.newInstance(deps). Expect MethodHandle ~5-10× faster after warmup. Stretch: use LambdaMetafactory to get within 1.5× of a direct call.

Idiomatic Drill

Find every setAccessible(true) in a codebase you maintain. Classify: legitimately needed (→ MethodHandles.privateLookupIn), replaceable with a public API, or a leak waiting to happen.

Production Hardening Slice

--add-opens java.base/java.lang=ALL-UNNAMED   # only for known legacy
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintModuleResolution
Capture module-resolution output for one revision; commit it. The next time a library bump silently adds an --add-opens requirement, the diff tells you which package needs review.

Comments