Java Reflection and MethodHandles in 2026: How They Work and What They Cost
Reflection basics, why setAccessible is not a master key, what JDK 26 changes for final fields, how MethodHandles and VarHandles differ, and JMH numbers showing a call costs anywhere from 0.5 ns to 21 ns depending on one detail. Every figure comes from committed output.
You have probably met reflection as an error message: NoSuchMethodException, InaccessibleObjectException, InvocationTargetException. Or as the reason a library “just works” — Spring finds your beans, Jackson fills your fields, JUnit runs methods nobody called. This post explains what reflection and its faster sibling, MethodHandle, really do, what the rules are in current Java, and what one call costs — measured, not remembered.
It is written to be read top to bottom by someone meeting the topic for the first time. Deeper material sits in the collapsed boxes under each section; open them when you want the next layer. Every code block links to the file it came from, and every output block is pasted from a file produced by a real run.
Versions this was tested against. Temurin JDK 25.0.5+8 (LTS) for every demo, test and benchmark; Temurin JDK 26.0.2.1+1 only for the JEP 500 comparison in section 4; JMH 1.37; JUnit Jupiter 5.11.0; Maven 3.9.11. Hardware was a shared 2 vCPU Intel Xeon VM, so read the nanosecond figures as relative, not absolute. The companion module is reflection in java-core-examples.
Reflection means calling code by name, at run time
Normally the compiler checks a call like account.deposit(25): does deposit exist, does it take a number, are you allowed to call it. If you are wrong, you get a red underline before the program ever runs. Reflection moves that whole conversation to run time. You hand Java a class, a method name as a string and the argument types, and Java looks the method up while the program is running.
The diagram shows the trade in one picture: reflection gives you the ability to act on code you did not know about when you compiled, and takes away the compiler’s safety net in exchange. Here is the smallest program that does it, next to its faster sibling. It is QuickStart.java in the repository, and you can also download it directly and run it with java QuickStart.java:
Greeter g = new Greeter();
Method m = Greeter.class.getMethod("greet", String.class);
System.out.println("reflection : " + m.invoke(g, "Ankur"));
MethodHandle h = MethodHandles.lookup().findVirtual(Greeter.class, "greet",
MethodType.methodType(String.class, String.class));
String s = (String) h.invokeExact(g, "Ankur");
System.out.println("MethodHandle : " + s);
The MethodHandle version asks for more up front — a MethodType describing the exact signature, and a cast on the result — and section 5 shows what you get in return. The rest of this post follows one thread: reflection is flexible, the rules around it have been tightening for years, and the price of a call depends on a detail you will not guess.
Going deeper: what you can inspect, and getMethod vs getDeclaredMethod
Reflection hands you four kinds of objects: a Class for the type, and Method, Constructor and Field for its members. getMethod finds public methods, including inherited ones. getDeclaredMethod finds methods of any visibility, but only those declared in that exact class — not inherited. Mixing them up is the commonest reason a lookup that “obviously exists” throws NoSuchMethodException.
Here is what getDeclaredMethods() reports for the sample Account class, sorted by name. It is the first block of output/01-basics.txt, produced by ReflectionBasicsDemo.java:
== 1. Inspect the class ==
class: com.ankurm.reflection.Account
public long balance[]
public void deposit[long]
public String owner[]
private String secret[]
public String[] stack[]
public String toString[]
public static int twice[int]
Reflection fails in four different ways — learn to tell them apart
Most reflection bugs are not logic bugs; they are one of four exceptions, and each one points at a different mistake. A wrong name throws NoSuchMethodException. Wrong argument types make invoke itself throw IllegalArgumentException. A private member throws IllegalAccessException. And if the method you called throws, you do not get that exception back — you get it wrapped in InvocationTargetException. The relevant part of ReflectionBasicsDemo.java:
== 3. The exceptions you will meet ==
wrong name -> NoSuchMethodException: com.ankurm.reflection.Account.nope()
wrong arg type -> IllegalArgumentException: argument type mismatch
method threw -> InvocationTargetException wrapping java.lang.IllegalArgumentException: over limit: 5000
private, no opt-in -> IllegalAccessException: class com.ankurm.reflection.ReflectionBasicsDemo cannot access a member of class com.ankurm.reflection.Account with modifiers "private"
after setAccessible(true): secret-of-alice
The fingerprint of a swallowed error. Look at the second and third lines: IllegalArgumentException appears twice, once as the type of the exception reflection raised itself for a bad argument and once as the wrapped failure from your method. If a stack trace ends in InvocationTargetException with no useful message, nobody called getCause(). The real error is inside.
Reflection also leaves footprints. When a method runs through Method.invoke, two extra frames appear on its stack. Same method, called both ways (from output/01-basics.txt):
== 4. What reflection adds to the stack ==
direct call:
com.ankurm.reflection.Account.stack
com.ankurm.reflection.ReflectionBasicsDemo.main
through Method.invoke:
com.ankurm.reflection.Account.stack
jdk.internal.reflect.DirectMethodHandleAccessor.invoke
java.lang.reflect.Method.invoke
com.ankurm.reflection.ReflectionBasicsDemo.main
The name DirectMethodHandleAccessor is not an accident: since JDK 18, Method.invoke is implemented on top of method handles, which is why the two halves of this post are really one story.
Going deeper: why the wrapping exists, and the JDK 18 rewrite
The wrapping is deliberate. invoke has to separate “reflection could not make the call” from “the call was made and your method failed”, and a checked InvocationTargetException is how it does that. Rule of thumb: an IllegalArgumentException that is not wrapped came from reflection (wrong argument count or type); one that is wrapped came from your code.
JEP 416 reimplemented Method, Constructor and Field on java.lang.invoke in JDK 18, replacing the older mechanism that generated bytecode stubs after a number of calls. It states that no java.lang.reflect API changed. Its own risk section warns that method-handle invocation may use more resources than the old implementation, and the old behaviour was offered as a temporary system-property workaround.
ReflectionContractTest.java — pins the wrapping and the frame names so a future JDK that changes them fails the build.
setAccessible is not a master key: modules decide what is open
A private member is off limits to other code. setAccessible(true) is how reflection asks Java to skip that check, and it is how serialization libraries and frameworks reach private fields. For years it simply worked. It no longer always does: since the module system arrived in Java 9, and since strong encapsulation of JDK internals became the default in JDK 17 (JEP 403), the JDK refuses unless the package has been opened to you.
The thing to take from the diagram is that the deciding question is about modules, not about the word private. Your own classes sit in the unnamed module on the classpath and are open to you; java.base is not open to you unless the command line says so. The demo, ModuleRulesDemo.java, tries both:
Field value = String.class.getDeclaredField("value");
System.out.println("trySetAccessible : " + value.trySetAccessible());
try {
value.setAccessible(true);
Object bytes = value.get("abc");
System.out.println("setAccessible worked, String.value is a " + bytes.getClass().getSimpleName());
} catch (InaccessibleObjectException e) {
System.out.println("setAccessible threw : " + e.getClass().getSimpleName());
System.out.println(" " + e.getMessage());
}
$ java -cp target/classes com.ankurm.reflection.ModuleRulesDemo
== setAccessible on your own class ==
Account.owner = carol
== setAccessible on a JDK private field ==
String lives in module : java.base
we live in module : unnamed module @6b95977 (unnamed=true)
java.lang open to us : false
trySetAccessible : false
setAccessible threw : InaccessibleObjectException
Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @6b95977
$ java --add-opens java.base/java.lang=ALL-UNNAMED -cp target/classes com.ankurm.reflection.ModuleRulesDemo
== setAccessible on your own class ==
Account.owner = carol
== setAccessible on a JDK private field ==
String lives in module : java.base
we live in module : unnamed module @3764951d (unnamed=true)
java.lang open to us : true
trySetAccessible : true
setAccessible worked, String.value is a byte[]
The fingerprint of an old library on a new JDK. A stack trace containing module java.base does not "opens java.lang" to unnamed module means some dependency is reflecting into JDK internals. The quick fix is the --add-opens flag shown above. The real fix is upgrading that dependency; every such flag is a debt you carry on every launch. trySetAccessible() is the polite variant for code that wants to ask without catching an exception.
Going deeper: when your own code is in a named module
The demo runs on the classpath, so your classes are in the unnamed module and open to each other. If you package your code as a named module (it has a module-info.java), the same rules apply to you: another module can reflect over your public members only if the package is exports-ed, and over private members only if it is opens-ed. This part comes from the module-system documentation rather than from a run in this repository — the companion module deliberately stays on the classpath to keep the demos short.
Reflection can rewrite a final field today — JDK 26 starts warning, and a later release will refuse
final is supposed to mean “cannot change”. Deep reflection has been the exception since JDK 5: setAccessible(true) followed by Field.set will overwrite a final instance field. JEP 500 (“Prepare to Make Final Mean Final”), delivered in JDK 26, starts closing that gap: the mutation still works, but the runtime now prints a warning, and the JEP states that a future release will throw by default. Not everything has been mutable for long, though — static final fields and record fields were already refused. The demo, FinalFieldDemo.java, starts with the case that works:
Account acct = new Account("dave", 0);
Field owner = Account.class.getDeclaredField("owner");
owner.setAccessible(true);
owner.set(acct, "mallory");
System.out.println("owner() now: " + acct.owner());
Here it is on JDK 25, on JDK 26, and on JDK 26 with the future behaviour switched on (--illegal-final-field-mutation=deny). All three runs are in output/03-final-fields.txt:
$ java -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 25
java.version = 25.0.5
== 1. final instance field ==
owner() now: mallory
== 2. static final field ==
IllegalAccessException: Can not set static final int field com.ankurm.reflection.Account.MAX_DEPOSIT to java.lang.Integer
== 3. record component ==
IllegalAccessException: Can not set final int field com.ankurm.reflection.FinalFieldDemo$Point.x to java.lang.Integer
== 4. constant inlining: the write 'works' but the getter never notices ==
Field.get -> 99
max() -> 5
$ java -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 26
java.version = 26.0.2.1
== 1. final instance field ==
WARNING: Final field owner in class com.ankurm.reflection.Account has been mutated reflectively by class com.ankurm.reflection.FinalFieldDemo in unnamed module @18b4aac2 (file:/tmp/work/java-core-examples/reflection/target/classes/)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled
owner() now: mallory
== 2. static final field ==
IllegalAccessException: Can not set static final int field com.ankurm.reflection.Account.MAX_DEPOSIT to java.lang.Integer
== 3. record component ==
IllegalAccessException: Can not set final int field com.ankurm.reflection.FinalFieldDemo$Point.x to java.lang.Integer
== 4. constant inlining: the write 'works' but the getter never notices ==
Field.get -> 99
max() -> 5
$ java --illegal-final-field-mutation=deny -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 26
java.version = 26.0.2.1
== 1. final instance field ==
Exception in thread "main" java.lang.IllegalAccessException: class com.ankurm.reflection.FinalFieldDemo (in unnamed module @18b4aac2) cannot set final field com.ankurm.reflection.Account.owner (in unnamed module @18b4aac2), unnamed module @18b4aac2 is not allowed to mutate final fields
at java.base/java.lang.reflect.Field.preSetFinal(Field.java:1504)
at java.base/java.lang.reflect.Field.setFinal(Field.java:1447)
at java.base/java.lang.reflect.Field.set(Field.java:909)
at com.ankurm.reflection.FinalFieldDemo.main(FinalFieldDemo.java:30)
Three things to read out of that. JDK 25 says nothing. JDK 26 prints a three-line warning that names the field, the class doing the mutating, and the flag that silences it. And with deny the same line becomes an IllegalAccessException — that is what “a future release will throw by default” will look like.
The write that “works” but does nothing. Section 4 of the output is the quiet trap. Limit declares private final int max = 5; because that is a compile-time constant, javac copies the literal 5 into every place that reads it. Reflection really did change the field (Field.get prints 99), but max() still returns 5. Mutating finals is unreliable even before the JDK blocks it.
Going deeper: why the JDK is doing this, and a migration loop
The JEP’s argument is that as long as an API can change any final field, nobody — including the JIT compiler — can trust a final field, which costs both safety and optimisation. Hidden classes (JDK 15) and records (JDK 16) were already protected, and JEP 500 extends the idea to ordinary classes. Its authors expect most warnings to come from library code rather than application code.
A practical loop: run your test suite on JDK 26 and collect the warnings; each one names the class doing the mutating, so you can see which library to upgrade. Then run with --illegal-final-field-mutation=deny in CI to see what will break later. The stopgap flag --enable-final-field-mutation=ALL-UNNAMED is the one printed in the warning above. I have checked the flag spellings against the real JDK 26 output, not just the JEP text.
A MethodHandle checks access once, at lookup, and demands exact types
A MethodHandle is a typed, directly executable reference to a method. You get one from a Lookup object, which remembers what your class is allowed to see. That one design choice explains most of the differences from reflection: access is checked when you create the handle, not on every call; the handle carries a precise MethodType; and calls through invokeExact must match that type exactly. From MethodHandlesDemo.java (trimmed to the calls):
== 1. Look up once, call many times ==
twice(21) = 42
balance() = 50
handle type = (Account)long
== 2. invokeExact is exact; invoke adapts ==
invokeExact, wrong return type -> WrongMethodTypeException
handle's method type (Account)long but found (Account)Object
invoke (boxing) = 50 (Long)
The cast is part of the call. With invokeExact, the compiler reads the cast on the result and the static types of the arguments to decide the call’s signature. (long) matches the handle’s (Account)long; (Object) does not, and you find out at run time, at that line. invoke (without “Exact”) adapts the types for you by boxing, which is friendlier but, as section 7 shows, not free.
The other half of the design shows up when access fails. Looking up a private method from another class fails at lookup time; privateLookupIn gets you a lookup with more rights, as long as the module allows it (section 4 of output/04-method-handles.txt):
== 4. Access is checked when you look up, not when you call ==
findVirtual(private) -> IllegalAccessException
no such method: com.ankurm.reflection.Account.secret()String/invokeVirtual
privateLookupIn -> secret-of-erin
privateLookupIn(String) -> IllegalAccessException
module java.base does not open java.lang to unnamed module @135fbaa4
Notice the first message says “no such method” about a method that exists. The lookup treats a member it may not access as invisible, which can mislead you when debugging; the third line is the same module rule from section 3 showing up again, this time as an IllegalAccessException.
Going deeper: composing handles, and which Lookup to use
Handles can be combined without writing a wrapper class: bindTo fixes the receiver, MethodHandles.insertArguments pre-fills arguments, and filterReturnValue pipes the result through another handle. This is section 3 of the same output file, which is also the reason frameworks and language runtimes like handles — you assemble behaviour, and the JIT can see through it (output/04-method-handles.txt):
== 3. Handles compose ==
bindTo(acct) = 50
after deposit10 = Account[erin, 60]
balance as text = "60"
A Lookup created by MethodHandles.lookup() sees what the calling class sees. privateLookupIn(targetClass, lookup) upgrades it to the target’s private members, subject to module openness. The class-level Javadoc for java.lang.invoke is worth reading once for the full list of find* methods.
Field.get and Field.set read and write a field by name, with no ordering guarantees. A VarHandle does the same job through the same Lookup mechanism, but gives you typed access plus atomic operations: compare-and-set, get-and-add, and memory-ordering variants. If you have read the atomics post, this is the same tool applied to a plain field. From the demo:
VarHandle vh = priv.findVarHandle(Account.class, "balance", long.class);
System.out.println("get = " + (long) vh.get(acct));
boolean won = vh.compareAndSet(acct, 60L, 100L);
System.out.println("CAS(60 -> 100) = " + won + ", balance now " + acct.balance());
boolean lost = vh.compareAndSet(acct, 60L, 200L);
System.out.println("CAS(60 -> 200) = " + lost + ", balance now " + acct.balance());
long prev = (long) vh.getAndAdd(acct, 5L);
System.out.println("getAndAdd(5) = was " + prev + ", now " + acct.balance());
The second CAS fails because the field was already 100 when it expected 60 — the point of compare-and-set. The last two lines of the output also show a safety property reflection lacks: a VarHandle for a final field is read-only. Excerpt of section 5 in output/04-method-handles.txt:
== 5. VarHandle: typed, atomic field access ==
get = 60
CAS(60 -> 100) = true, balance now 100
CAS(60 -> 200) = false, balance now 100
getAndAdd(5) = was 100, now 105
set on final field -> UnsupportedOperationException
array element CAS-able: counts[1] = 7
Going deeper: access modes and the array handle
A VarHandle exposes families of operations — plain, opaque, acquire/release and volatile reads and writes, plus compare-and-set and the atomic update methods — so you choose the memory ordering you pay for. The last line of the output uses MethodHandles.arrayElementVarHandle(int[].class), which gives atomic access to array elements, something AtomicInteger cannot do without an AtomicIntegerArray. The atomics post goes through the ordering modes with benchmarks.
What a call costs: 0.5 ns or 21 ns, depending on one detail
Now the question in the title. The benchmark, CallBenchmark.java, reads the same Account.balance() nine ways. The detail that matters is where the Method or handle is stored. Declared static final, the JIT compiler can treat it as a constant:
static final Method S_METHOD;
static final MethodHandle S_HANDLE;
static final Field S_FIELD;
static final VarHandle S_VARHANDLE;
Stored in an ordinary instance field, it is just some object the JIT cannot prove will never change:
Account acct = new Account("bench", 42);
Method method;
MethodHandle handle;
The two invokeExact benchmarks differ only in that choice:
@Benchmark
public long methodHandle_staticFinal_invokeExact() throws Throwable {
return (long) S_HANDLE.invokeExact(acct);
}
@Benchmark
public long methodHandle_instanceField_invokeExact() throws Throwable {
return (long) handle.invokeExact(acct);
}
The same numbers as a picture, sorted fastest to slowest and coloured by what is going on:
Read the chart as three tiers. In the green tier, static final handles and fields are effectively free: methodHandle_staticFinal_invokeExact (0.476 ± 0.038) and direct (0.516 ± 0.069) overlap within their error bars, so on this machine they are indistinguishable. In the amber tier, the exact same invokeExact costs 2.60 ns when the handle sits in an instance field, about five times more, and a cached Method in an instance field costs 6.48 ns — roughly twelve times the direct call, with an error bar wide enough that you should not quote the third digit. The red bar is the classic mistake: calling getMethod on every call costs 21.5 ns, about 42 times the direct call.
The explanation in the diagram is the standard one, and the measurements here agree with it: the JIT optimises hardest on values it can prove constant. This matches the reasoning in the implementation pull request for JEP 416, which notes that loading a handle from a constant lets the JIT inline the invocation. I did not inspect JIT output in this repository; the evidence here is the timing difference, not a compiler log.
What these numbers do and do not say. They come from a 2 vCPU shared VM with 2 forks and 5 measurement iterations each, so treat the ordering as the result and the absolute values as indicative. They measure a trivial method; a method that does real work makes all of these differences vanish into the noise. A bare invoke that boxes a long costs 2.69 ns here, which is the price of the adapting convenience from section 5. And direct is also what generated code looks like — which is the next section.
Going deeper: reproducing the run, and what JDK 18 changed
To rerun: mvn package, then java -jar target/benchmarks.jar CallBenchmark; scripts/run-all.sh regenerates every output file including this one. Expect the ordering to hold and the figures to move.
Since JDK 18, Method.invoke and Field.get are built on method handles and VarHandles (JEP 416), which is why a static final Method gets within about two times of a direct call: it benefits from the same constant folding as a handle. Before that rewrite the cached-Method path was a different mechanism, so older blog benchmarks that show reflection as dramatically slower describe an older implementation. I have not run these benchmarks on JDK 17 or earlier, so I cannot give you a before-and-after from this repository.
JEP 416 — the rewrite, including its performance discussion.
Why frameworks moved from reflection to code generation
If a call costs a few nanoseconds, why do so many frameworks now generate code instead? The benchmark gives part of the answer: generated code is the direct row, the fastest and the one the JIT understands best. But speed is the smaller reason. Ahead-of-time compilation to a native executable, as in GraalVM Native Image, works by analysing your program at build time and keeping only what is reachable. Reflection is invisible to that analysis, because the names are strings.
This section is sourced from documentation, not from code I ran here — I did not build a native image for this post. The Spring Boot documentation states that GraalVM is not aware of dynamic elements such as reflection and must be told about them. Spring’s answer is an ahead-of-time engine that generates source code for bean definitions and writes hint files (reflection, resources, proxies, serialization) alongside it, so that what used to be discovered at run time by reflection is decided at build time. The same idea, on a smaller scale, is the annotation processor: read an annotation at compile time, emit ordinary Java, and let the JIT treat it like hand-written code. There is a worked example in Custom Annotations and Annotation Processors in Java, with its own annotation-processor module.
Going deeper: what native-image hints look like, and what still needs reflection
Spring still needs reflection in places, for example to invoke private methods even on GraalVM, and it handles that by registering hints at build time rather than by avoiding reflection. You can supply your own through a RuntimeHintsRegistrar or the @RegisterReflectionForBinding annotation, and the GraalVM tracing agent can record what a JVM run actually reflected on to find missing entries. Both points come from Spring’s native-image documentation; the Spring Boot reference linked below covers hint files and the tracing agent.
The practical consequence: reflection did not go away, it moved. In a framework you adopt, you will meet it as build-time metadata; in your own code, the performance guidance above still applies on a normal JVM.
Usually not directly — but know which tool you are borrowing. If you know the type at compile time, call it normally; none of this applies. If you must call something by name, cache a Method or handle in a static final and the cost is a few nanoseconds. Reach for a MethodHandle when you want the exact-type checking, composition, or the best speed from a constant. Use a VarHandle when you need atomic access to a field. If you are writing a library other people run on native images or newer JDKs, prefer generating code at build time. And if your own application mutates final fields or reflects into java.base, treat that as technical debt with a date on it.
No Comments yet!