Files
spring-boot-demo/graalvm-native-images/docs/03-the-reflection-trap.md
T
Claude 8cdfcd4d8d Add graalvm-native-images: Boot 4.1 + GraalVM CE for JDK 25, AOT processing,
the tracing agent, and a real reflection-collision trap

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
2026-09-20 11:03:09 +00:00

4.6 KiB

03 — A real reflection failure, its fix, and the trap inside the fix

← Previous: 02 — building the image | Back to README → | Next: 04 — honest limits →

The trap

ReportController builds a class name at request time from a query parameter and instantiates it reflectively -- PlainTextReport or JsonReport -- the same shape as a plugin loaded by name from configuration, or a strategy resolved from a database column. Spring's AOT engine registers reflection metadata for everything it can see statically -- every @RestController, every bean -- but it has no way to know that a format query parameter can resolve to one of these two classes; nothing in the source connects them at build time.

On the plain JVM, this works exactly as written. On the first native build (no hints added), hitting the endpoint fails outright:

REFLECTION-FAILED class=com.ankurm.graalvmdemo.report.PlainTextReport exception=java.lang.ClassNotFoundException message=com.ankurm.graalvmdemo.report.PlainTextReport

Full transcript: docs/output/03-native-startup-broken.txt. Note it is ClassNotFoundException, not merely a reflection-access error -- GraalVM's closed-world build excluded the class from the binary entirely, because nothing reachable from main() proved it was needed.

The fix: the tracing agent, run against the plain jar

native-image-agent, attached to a normal JVM run, watches real reflective calls and writes the same unified reachability-metadata.json format the AOT engine produces:

java -agentlib:native-image-agent=config-output-dir=agent-output -jar target/app.jar
curl "localhost:8083/report?format=plain"
curl "localhost:8083/report?format=json"

Full transcript: docs/output/04-tracing-agent.txt. The agent adds real overhead to startup while attached -- 5.552 s here versus the 3.472 s baseline -- which is why it belongs in a one-off exploratory run or a dedicated test suite, not in production.

The trap inside the fix

Copying the agent's generated entries for PlainTextReport and JsonReport into src/main/resources/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json -- the project's own Maven coordinates, which felt like the obviously correct place -- silently did nothing. Rebuilding and re-testing still failed with the identical ClassNotFoundException. The cause, confirmed by diffing the two files byte-for-byte: Spring's own AOT engine writes its generated metadata to that exact same path, and whichever copy the build processes last wins. target/classes/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json turned out to be Spring's 188 KB generated file, identical to the one in target/spring-aot/, with the two hand-added entries nowhere in it.

The fix is to put hand-written hints under a namespace that cannot collide with a real Maven coordinate -- this project uses src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json. GraalVM merges every META-INF/native-image/**/reachability-metadata.json it finds on the classpath regardless of what the intermediate folder names are -- they exist purely so different jars' metadata files don't collide with each other, which is exactly the property this fix needs. With that in place, the same two endpoints succeed:

OK via reflection on com.ankurm.graalvmdemo.report.PlainTextReport: REPORT: quarterly numbers
OK via reflection on com.ankurm.graalvmdemo.report.JsonReport: {"report":"quarterly numbers"}

Full transcript: docs/output/05-native-startup-fixed.txt. Startup and memory barely moved (0.104 s, ~105.7 MB RSS versus 0.091 s and ~105.5 MB without the fix) -- two extra reflectively-constructible classes are noise against the rest of the image.

  • Never hand-write hints at your own project's exact groupId/artifactId path -- verify with diff against target/spring-aot/main/resources/... if a hint you added seems to have no effect.
  • @RegisterReflectionForBinding on the calling code is the alternative to a hand-written or agent-generated JSON file, and does not have this collision risk since it feeds Spring's own AOT-generated file rather than a second one.