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

78 lines
4.6 KiB
Markdown

# 03 — A real reflection failure, its fix, and the trap inside the fix
[← Previous: 02 — building the image](02-building-the-image.md) | [Back to README →](../README.md) | [Next: 04 — honest limits →](04-honest-limits.md)
## The trap
[`ReportController`](../src/main/java/com/ankurm/graalvmdemo/report/ReportController.java) builds
a class name at request time from a query parameter and instantiates it reflectively --
[`PlainTextReport`](../src/main/java/com/ankurm/graalvmdemo/report/PlainTextReport.java) or
[`JsonReport`](../src/main/java/com/ankurm/graalvmdemo/report/JsonReport.java) -- 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](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](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`](../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](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.