On This Page
The trace, and the line that triggers it
Exception in thread "main" java.lang.reflect.InvocationTargetException
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:118)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at Repro.main(Repro.java:76)
Caused by: java.lang.IllegalArgumentException: id must not be empty
at Repro$Svc.load(Repro.java:10)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
... 2 moreThat's JDK 21.0.12.1. On 8, 11 and 17 the same failure prints NativeMethodAccessorImpl.invoke0 and DelegatingMethodAccessorImpl.invoke where the DirectMethodHandleAccessor lines are, and the "Caused by" is identical. The cause is the part you want. Everything above it is the JDK telling you it carried your exception across a reflective call and nothing else.
The line that usually puts this on a pager is this one:
log.error("Handler failed: " + e.getMessage());It prints Handler failed: null. Every time. Because InvocationTargetException never has a message.
I spent more than a day on this one, and most of it wasn't the wrapping, which is boring. It was the stuff around the wrapping: why the top frame points into JDK internals, why a grep for a reflection frame stops matching after the sixteenth call, and which exceptions come out of reflection unwrapped. This is the writeup so you can skip that day, on whichever JDK you run.
My first instinct, for the record, was that my logging config was eating the cause. It wasn't. The cause was never in the message to begin with.
Break it on purpose
You need JDK 8, 11, 17, 21 and 25. I used the Ubuntu 24.04 OpenJDK packages: 1.8.0_504, 11.0.32.1, 17.0.20.1, 21.0.12.1 and 25.0.4.1. I couldn't verify Docker tags this run, so I'm not going to invent one. Any vendor build behaves the same; the JDK classes involved are OpenJDK's.
If your shell sets JAVA_TOOL_OPTIONS, clear it (export JAVA_TOOL_OPTIONS=) or the "Picked up..." banner lands in your output. Java 8 source level is fine for everything below.
Save this as Repro.java:
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.*;
public class Repro {
public static class Svc {
public Svc() {}
public void load(String id) {
// the real failure; reflection will bury it
if (id.isEmpty()) throw new IllegalArgumentException("id must not be empty");
}
public void boom() throws java.io.IOException { throw new java.io.IOException("disk gone"); }
public void err() { throw new AssertionError("invariant"); }
}
public static class BadCtor { public BadCtor() { throw new IllegalStateException("ctor failed"); } }
interface Api { void run() throws Exception; }
interface NoThrows { void run(); }
public static void main(String[] a) throws Throwable {
Method m = Svc.class.getMethod("load", String.class);
Svc s = new Svc();
System.out.println("--- 1 Method.invoke wraps");
try { m.invoke(s, ""); }
catch (InvocationTargetException e) {
System.out.println("getMessage()=" + e.getMessage());
System.out.println("toString()=" + e);
System.out.println("getCause()=" + e.getCause());
System.out.println("getTargetException()==getCause(): " + (e.getTargetException() == e.getCause()));
StackTraceElement[] st = e.getStackTrace();
System.out.println("wrapper frames=" + st.length + " top=" + st[0]);
StackTraceElement[] ct = e.getCause().getStackTrace();
System.out.println("cause frames=" + ct.length);
for (StackTraceElement f : ct) System.out.println(" " + f);
}
System.out.println("--- 2 checked and Error also wrapped");
for (String n : new String[]{"boom", "err"}) {
try { Svc.class.getMethod(n).invoke(s); }
catch (InvocationTargetException e) { System.out.println(n + " -> " + e.getCause()); }
}
System.out.println("--- 3 not wrapped: wrong arg / null receiver / null arg");
try { m.invoke(s, 42); } catch (Exception e) { System.out.println(e); }
try { m.invoke(null, ""); } catch (Exception e) { System.out.println(e); }
try { m.invoke(s, (Object) null); } catch (Exception e) { System.out.println(e.getClass().getSimpleName() + " cause=" + e.getCause()); }
System.out.println("--- 4 Constructor.newInstance vs Class.newInstance");
try { BadCtor.class.getConstructor().newInstance(); }
catch (InvocationTargetException e) { System.out.println("Constructor.newInstance: ITE cause=" + e.getCause()); }
try { @SuppressWarnings("deprecation") Object o = BadCtor.class.newInstance(); }
catch (Throwable e) { System.out.println("Class.newInstance: " + e); }
System.out.println("--- 5 MethodHandle does not wrap");
MethodHandle mh = MethodHandles.lookup().findVirtual(Svc.class, "load", MethodType.methodType(void.class, String.class));
try { mh.invoke(s, ""); } catch (Throwable e) { System.out.println("MH: " + e); }
System.out.println("--- 6 Proxy: undeclared checked exception");
Api p = (Api) Proxy.newProxyInstance(Repro.class.getClassLoader(), new Class<?>[]{Api.class}, (pr, mt, ar) -> { throw new java.sql.SQLException("x"); });
try { p.run(); } catch (Throwable e) { System.out.println("declared Exception -> " + e); }
NoThrows p2 = (NoThrows) Proxy.newProxyInstance(Repro.class.getClassLoader(), new Class<?>[]{NoThrows.class}, (pr, mt, ar) -> { throw new java.sql.SQLException("x"); });
try { p2.run(); } catch (Throwable e) { System.out.println("undeclared -> " + e + " cause=" + e.getCause()); }
System.out.println("--- 7 nested reflection");
Method outer = Repro.class.getMethod("viaReflection", Method.class, Object.class);
try { outer.invoke(null, m, s); } catch (InvocationTargetException e) {
Throwable t = e; int depth = 0;
while (t instanceof InvocationTargetException && t.getCause() != null) { t = t.getCause(); depth++; }
System.out.println("depth=" + depth + " root=" + t);
}
System.out.println("--- 8 uncaught print");
m.invoke(s, ""); // dies here, exit code 1
}
public static void viaReflection(Method m, Object o) throws Exception { m.invoke(o, ""); }
}Then, for each JDK N in 8, 11, 17, 21, 25 (use -source 8 -target 8 instead of --release 8 on the JDK 8 javac):
$ /usr/lib/jvm/java-N-openjdk-amd64/bin/javac -Xlint:all --release N -d outN Repro.java
$ /usr/lib/jvm/java-N-openjdk-amd64/bin/java -cp outN ReproIt compiles with no lint warnings on all five. Step 8 ends the program with exit code 1 and the trace at the top of this post. Nothing here is shrunk or forced; it fails in milliseconds.
Same-bug check: if your trace has UndeclaredThrowableException instead, that's step 6, a different wrapper from a different place (a java.lang.reflect.Proxy). If the process exits with no Java trace at all, that's not this.
What each JDK prints
Message, cause and wrapping are the same on all five. What moves is the frames, and the NPE text.
| 8 | 11 | 17 | 21 | 25 | |
|---|---|---|---|---|---|
getMessage() of the ITE | null | null | null | null | null |
Real exception in getCause() | yes | yes | yes | yes | yes |
| Frames under your method | NativeMethodAccessorImpl.invoke0, .invoke, DelegatingMethodAccessorImpl.invoke, Method.invoke | same, jdk.internal.reflect | same | DirectMethodHandleAccessor.invoke, Method.invoke | same as 21 |
After the 16th call on the same Method | GeneratedMethodAccessor1 | GeneratedMethodAccessor1 | GeneratedMethodAccessor1 | no change | no change |
| NPE inside target has a message | no | no | yes | yes | yes |
-Djdk.reflect.useDirectMethodHandle=false | n/a | n/a | n/a | restores old frames | ignored |
JEP 416 is the one that did it: it reimplemented Method, Constructor and Field on method handles, delivered in 18. The JEP's own wording for going back is the system property in the last row, and it says that property "will stop working" when the old implementation is removed. On 25.0.4.1 it already does nothing. I didn't bisect which release removed it, so I won't name one.
If you're parsing reflection frames out of logs, JDK 18 is where your regexes die. That alone is a reason to stop parsing them.
Why Method.invoke can't let it through
Method.invoke can't let your method's exception escape as-is, because the compiler never told the caller to expect it. So the JDK catches everything the target throws, checked or not, Error included, and hands it back inside a checked InvocationTargetException. The wrapper has no message of its own. The thing you actually want is getCause().
Where the null comes from, and the frames that keep changing
Open DirectMethodHandleAccessor.java on 21 and the whole thing is on one screen:
try {
return invokeImpl(obj, args);
} catch (ClassCastException | WrongMethodTypeException e) {
if (isIllegalArgument(e)) {
// No cause in IAE to be consistent with the old behavior
throw new IllegalArgumentException("argument type mismatch");
} else {
throw new InvocationTargetException(e);
}
} catch (NullPointerException e) {
if (isIllegalArgument(e)) {
throw new IllegalArgumentException(e);
} else {
throw new InvocationTargetException(e);
}
} catch (Throwable e) {
throw new InvocationTargetException(e);
}Line 118 is that last throw. That's why the wrapper's top frame sits in JDK internals: the exception was constructed there, in the catch block. Throwable captures the stack where it's created, not where its cause was thrown. So the wrapper's trace tells you nothing about your bug, and that's the half of the trace people read.
The isIllegalArgument bit is the part I had to stare at. Reflection can throw IllegalArgumentException on its own (you passed a 42 where a String goes, you passed a null receiver) and it can also have your target throw a ClassCastException or an NPE. Both are plain exceptions. The JDK tells them apart by checking where the exception was thrown. I ran both. m.invoke(s, 42) gives a bare IllegalArgumentException: argument type mismatch, and a target that throws its own ClassCastException comes back as an ITE with the CCE as the cause. The practical upshot: a bare IllegalArgumentException out of a reflective call is the reflection layer complaining about your arguments. The target's own IllegalArgumentException is always inside an ITE (step 1). Same type, opposite meaning, depending on whether it's wrapped.
The constructor itself explains the null message. From InvocationTargetException.java:
public InvocationTargetException(Throwable target) {
super((Throwable)null); // Disallow initCause
this.target = target;
}It passes null up as the cause and keeps the real one in its own target field. getCause() is overridden to return target, and getTargetException() is the same field under the pre-1.4 name. The Javadoc for it says the cause method is "the preferred means". I checked: getTargetException() == getCause() prints true on all five JDKs. No detail message gets set anywhere, so getMessage() is null by construction, not by accident.
Now the surprise. The old accessor did something that isn't visible on JDK 18+ at all.
On 8, 11 and 17, the first calls to a Method go through NativeMethodAccessorImpl, a JNI call. After a threshold number of calls the JDK generates a bytecode class, GeneratedMethodAccessor1, and swaps it in. I measured it: calls 1 to 16 show NativeMethodAccessorImpl.invoke0 under the target frame, call 17 and later show GeneratedMethodAccessor1.invoke. Same method, same exception, different frames, and the switch happens at runtime after warmup. I'd been grepping a log for NativeMethodAccessorImpl to count reflective failures and it worked beautifully for the first sixteen. Then the count flatlined. That cost me an afternoon, mostly because I assumed the fixes I'd made were working.
On 18+ none of that exists for the normal path. DirectMethodHandleAccessor stays put on every call, which I confirmed on 21 and 25. The JEP does say native accessors are still used "only during early VM startup, before the method-handle mechanism is initialized", and I saw DirectMethodHandleAccessor$NativeAccessor frames on 21 and 25 when I forced it with -Djdk.reflect.useNativeAccessorOnly=true. I haven't tried to hit that path in real code.
One more thing the JEP warns about: method-handle invocation can use more stack than the old path, so deep reflective recursion can throw a StackOverflowError that used to fit. I didn't try to produce that.
The detail I liked least: Class.newInstance() (deprecated since 9) doesn't wrap. Its source catches the ITE and calls Unsafe.getUnsafe().throwException(e.getTargetException()), which throws it raw, checked or not, with no throws clause saying so. Constructor.newInstance() wraps. Same constructor, same exception, two different shapes. If you migrate old Class.newInstance() code to getDeclaredConstructor().newInstance(), your catch (IllegalStateException e) stops catching and an ITE goes up instead. Compiles fine. Fails at 3am.
Unwrap first, then log
Unwrap before you do anything with the exception. Then log the unwrapped one, with its stack, not its message.
// before
try { m.invoke(target, args); }
catch (Exception e) { log.error("Handler failed: " + e.getMessage()); } // "Handler failed: null"
// after
try { m.invoke(target, args); }
catch (InvocationTargetException e) { log.error("Handler failed", e.getCause()); throw ... }
catch (IllegalAccessException e) { log.error("Cannot access " + m, e); throw ... }If you want callers to see what the method threw, as if reflection wasn't there, this helper does it. It compiles and runs the same on all five JDKs (identical output, checked by hash):
@SuppressWarnings("unchecked")
static <T extends Throwable> RuntimeException sneaky(Throwable t) throws T { throw (T) t; }
static Object call(Method m, Object target, Object... args) {
try {
return m.invoke(target, args);
} catch (InvocationTargetException e) {
Throwable real = e.getCause() != null ? e.getCause() : e;
throw Fix.<RuntimeException>sneaky(real);
} catch (IllegalAccessException e) {
throw new IllegalStateException("cannot access " + m, e);
}
}Output of the full Fix.java, same on 8 through 25:
naive log line : null
CCE inside target -> ITE cause: ClassCastException
unwrapped : java.lang.IllegalArgumentException: id must not be empty
checked, raw : java.io.IOException: disk gone
MethodHandle : java.lang.IllegalArgumentException: id must not be emptyWhat that costs you: the sneaky rethrow lets a checked IOException fly out of a method that doesn't declare it. That's what Class.newInstance() did and why it got deprecated. If that bothers you, wrap in your own unchecked exception and keep the cause. Either is fine. Losing the cause is not.
Nested reflection stacks the wrappers. My step 7 (a reflective call to a method that itself calls Method.invoke) gave depth 2. One getCause() returns another ITE with a null message. Loop until the thing you're holding isn't an ITE.
Things people copy that don't fix it:
e.getMessage()in a log line.null, always.e.getLocalizedMessage(). Alsonull; it delegates togetMessage().- Catching
Exceptionand rethrowingnew RuntimeException(e.getMessage()). You've now made a second exception whose message isnull, with the cause thrown away. - Turning on
-Djdk.reflect.useDirectMethodHandle=falsehoping the old path unwraps. It wraps identically. It only changes which frames you see, and it's gone on 25.
Skip Method.invoke where you can
Skip Method.invoke where you control both sides. A MethodHandle doesn't wrap anything: step 5 gives you the target's own IllegalArgumentException directly. Look it up once, keep it in a static final, and invoke it with invokeExact or invokeWithArguments.
static final MethodHandle LOAD;
static {
try {
LOAD = MethodHandles.lookup().unreflect(Svc.class.getMethod("load", String.class));
} catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
}
// LOAD.invokeWithArguments(svc, "") -> IllegalArgumentException, unwrappedIf it's a framework calling your handlers by reflection and you can't change it, the contract is on you: assume every exception it hands you is an ITE until proven otherwise, unwrap in one place, and make that place the only one that logs.
The check that would have caught this
Grep for getMessage() next to anything reflective, and for any catch (Exception e) around invoke( or newInstance(. I haven't found a bundled static check that flags it, so put it in code review. A test is better: call your reflective path with a target that throws, assert you get that exact type back, and assert the log line contains the target's message.
For upgrades:
- 8 to 17: nothing changes for the exception itself. Anything that matches reflection frames by name will see
GeneratedMethodAccessorNafter warmup. It's always been that way. - 17 to 21: frames change (JEP 416, in 18). The wrapping contract doesn't.
- 25:
-Djdk.reflect.useDirectMethodHandle=falseis silently ignored. If you set it in 21 to keep old stack shapes, you lose them on the next LTS.
The behavior I'm describing is version-neutral from 8 through 25 on the exception semantics, and I ran all five. I didn't test 22 to 24 or any early-access builds.
If you remember nothing else
InvocationTargetException.getMessage()is null by design. ReadgetCause().- The wrapper's stack trace is the JDK's catch block. Your bug is in the "Caused by".
- A target's own
IllegalArgumentExceptionalways arrives wrapped. A bare one is from the reflection layer. Class.newInstance()doesn't wrap,Constructor.newInstance()does. Migrating one to the other changes what you catch.- Don't match reflection frame names in logs. They differ on 8/11/17 vs 18+, and change mid-run on the old ones.
You might have searched for
These are search phrasings, not Stack Overflow titles. SO was blocked for this run.
How do I get the actual exception from InvocationTargetException?
Call getCause() (or getTargetException(), same object). If you've got reflective calls nested inside each other, keep unwrapping while the current throwable is still an InvocationTargetException. I measured two layers deep in one test.
Why does InvocationTargetException getMessage return null?
Its constructor passes null up to Throwable and keeps the real exception in a private target field. No detail message is ever set unless you use the two-argument constructor, which the JDK's own reflection code doesn't. So getMessage() is null by construction.
What's the difference between InvocationTargetException and UndeclaredThrowableException?
ITE comes from Method/Constructor.invoke, and wraps anything the target threw. UndeclaredThrowableException comes from a java.lang.reflect.Proxy when the handler throws a checked exception the interface method doesn't declare. I reproduced both in the same file. Different layer, different fix.
Is InvocationTargetException checked?
Yes. It extends ReflectiveOperationException. That's why Method.invoke forces you to catch it, and why your own checked exception can't propagate through it.
Sources
- JEP 416: Reimplement Core Reflection with Method Handles, https://openjdk.org/jeps/416 (Closed/Delivered, release 18).
- OpenJDK 21.0.12.1
src.zip, read locally:java/lang/reflect/InvocationTargetException.java,jdk/internal/reflect/DirectMethodHandleAccessor.java,java/lang/Class.java(newInstance). - All outputs above came from
Repro.java,Inflate.javaandFix.javarun on OpenJDK 1.8.0_504, 11.0.32.1, 17.0.20.1, 21.0.12.1 and 25.0.4.1 (Ubuntu 24.04 packages), 2026-10-02. - No Stack Overflow thread or view count was used. SO tag pages and the SE API were unreachable from this run. Search page one for the class name: Baeldung, Rollbar, and unrelated mirror sites.