GraalVM Native Images for Spring Boot 4.1: The Reflection Trap, the Fix, and the Trap Inside the Fix
GraalVM native-image compiles a Spring Boot 4.1 app into a binary that starts in 0.09 seconds instead of 3.5 — but only after a real ClassNotFoundException, a fix using the tracing agent, and a metadata-collision trap that silently undoes that fix. Rewritten with verified Boot 4.1.1 and current GraalVM versions, honest costs, and where Project Leyden fits as the non-native alternative.
A Spring Boot service that takes three and a half seconds to say hello is not a bug — it is just what the JVM does. Classes get loaded, byte-code gets JIT-compiled, Spring’s auto-configuration scans and wires up beans, and Tomcat starts listening, all after the process forks. On a laptop that three and a half seconds is invisible. On a Kubernetes pod that gets killed and rescheduled every few minutes, or a serverless function billed by the millisecond it takes to answer the first request, it is real money and a real readiness-probe timeout waiting to happen.
GraalVM’s native-image compiler is one answer: instead of shipping a jar for the JVM to interpret and warm up, it ahead-of-time compiles your whole application — your code, the libraries you depend on, and a minimal runtime — into a single standalone executable that starts in well under a tenth of a second. This is a from-scratch rewrite of an older version of this article, and almost everything in that version has moved on: the old post assumed Spring Boot 3.x, JDK 17, and GraalVM 22.3 installed with a separate gu install native-image step. All three of those are gone or changed. This version verifies every version claim and every command against Spring Boot 4.1.1 and a GraalVM distribution downloaded today, and it adds something the old post did not have at all: a real reflection failure, its fix, and a second trap hiding inside that fix that cost a full extra native-image build to diagnose.
Versions used in this article. Spring Boot 4.1.1 • JDK for JVM runs: Temurin 25.0.4.1 • GraalVM for native builds: Community Edition for JDK 25, build 25.0.2+10.1 (downloaded fresh, not pre-installed) • native-maven-plugin 1.1.8, which is what spring-boot-starter-parent:4.1.1 pins via its own dependency management — Maven Central’s newest release is 1.1.14, but the parent’s pinned version is what actually runs unless you override it. Every command below was run for this article, not copied from documentation.
What three and a half seconds of startup actually costs you
Run a plain Spring Boot 4.1 jar with nothing special going on — one @RestController, no database, no message broker — and here is what a real run looks like on the machine this article was written on:
$ export JAVA_HOME=/opt/jdk25 && java -jar target/app.jar --server.port=8081
Starting GraalvmDemoApplication v1.0.0 using Java 25.0.4.1 with PID 796 (/tmp/sbd-work/spring-boot-demo/graalvm-native-images/target/app.jar started by root in /tmp/sbd-work/spring-boot-demo/graalvm-native-images)
Tomcat started on port 8081 (http) with context path '/'
Started GraalvmDemoApplication in 3.472 seconds (process running for 4.041)
$ ps -o rss= -p 796
195356
Full transcript: docs/output/01-jvm-baseline.txt. 3.472 seconds and 195 MB resident just to be ready for the first request — before this process has served a single byte of actual work. Multiply that by fifty pod replicas restarting during a rolling deployment, or by every cold invocation of a serverless function, and it stops being a laptop curiosity and starts being a line item.
There are two different ways to make that number small. This article is mostly about the first one, GraalVM native-image, because it is the one with sharp edges worth mapping in detail. It closes with the second one, Project Leyden, which keeps the JVM and takes a very different set of trade-offs to get most of the same win.
The mental model: one build step, then two different last steps
The part that is easy to get backwards: GraalVM native-image does not replace what Spring Boot’s own AOT engine does — it depends on it. spring-boot:process-aot runs automatically during mvn package, no profile needed, and it does the same job either way: it starts your application’s context-building logic once, at build time, and writes down what it learns as plain generated Java source and a metadata file, instead of leaving Spring to rediscover the same wiring by classpath scanning at every future startup. What happens after that is where the two paths split.
Both bottom paths start from the exact same AOT-processed classes and the exact same metadata file — only the last step differs, and that last step is where four minutes of build time buys three seconds of startup time. Chapter 02 — building the image walks through exactly what process-aot generates (95 source files on this project, including a GraalvmDemoApplication__ApplicationContextInitializer.java that builds the whole ApplicationContext from plain Java calls instead of scanning) before native-image ever sees any of it.
The smallest thing that works: building and running the native image
A default Spring Initializr project generated against spring-boot-starter-parent:4.1.1 needs almost nothing extra to be native-image-ready. The whole configuration is a bare profile:
No version number, no explicit <mainClass>, no hand-set native-buildtools.version property — all three were needed by the old version of this article and none of them are needed now. mvn help:effective-pom -Pnative confirms this resolves to native-maven-plugin 1.1.8, pinned by the parent. Full file: pom.xml.
The trap that catches almost everyone coming back to this after a while: mvn -Pnative package does not build a native image. It looks like it should — the profile is active, the plugin is on the classpath — but it finishes in under nine seconds having only run add-reachability-metadata, process-aot, jar, and repackage. Grepping that log for native-image or Generating ' returns zero matches, confirmed directly rather than assumed:
The native profile only adds the plugin so its own add-reachability-metadata goal, bound in the plugin’s own metadata, participates in the build — it binds no execution of native:compile to any phase. The goal that actually invokes the GraalVM compiler has to be named explicitly, and it matches Spring’s own current documentation for local (non-Docker) builds:
$ export JAVA_HOME=/opt/graalvm25 && mvn -Pnative -DskipTests native:compile
[2/8] Performing analysis... [*****] (94.1s @ 2.35GB)
20,126 types, 29,154 fields, and 91,938 methods found reachable
7,495 types, 4,032 fields, and 16,651 methods registered for reflection
68 types, 71 fields, and 58 methods registered for JNI access
0 downcalls and 0 upcalls registered for foreign access
4 native libraries: dl, pthread, rt, z
[6/8] Compiling methods... [**********] (99.2s @ 2.18GB)
30.4s (11.7% of total time) in 1838 GCs | Peak RSS: 4.36GB | CPU load: 1.87
Build artifacts:
/tmp/sbd-work/spring-boot-demo/graalvm-native-images/target/graalvm-native-images (executable)
Finished generating 'graalvm-native-images' in 4m 19s.
[INFO] BUILD SUCCESS
[INFO] Total time: 04:31 min
$ ls -lh target/graalvm-native-images target/app.jar
-rwxr-xr-x 1 root root 93M Sep 20 10:35 target/graalvm-native-images
-rw-r--r-- 1 root root 22M Sep 20 10:31 target/app.jar
Full transcript: docs/output/02-native-build.txt. That is a real 4 minute 19 second build on 2 vCPU / 8 GB, analysing 20,126 reachable types across nearly 92,000 methods, peaking at 4.36 GB resident memory, to produce a 93 MB self-contained executable — against a 22 MB jar that still needs a JRE installed next to it to run at all.
One more thing worth checking before anything else: native-image ships bundled with GraalVM Community Edition now. The old post’s gu install native-image step is gone — running it against a current distribution fails outright, because the gu tool itself was retired. See docs/01-versions-and-setup.md for how each version claim in this article was checked, and Spring’s own native-image guide for the source the native:compile command was verified against.
The reflection trap: a class name that does not exist until you look for it
The native-image build above analysed the whole application and found no problems — because nothing in this project’s source, from the build’s point of view, does anything unusual. That changes the moment code decides which class to use based on something only known at request time. This is the same shape as a plugin loaded by name from configuration, or a strategy class resolved from a database column, and it is common enough in real applications that it deserves its own section rather than a footnote.
Source: ReportController.java, alongside the two implementations it loads, PlainTextReport.java and JsonReport.java.
On the plain JVM this endpoint works exactly as written — reflection just resolves the class at runtime. On the first native build, with no hints added anywhere, hitting the same endpoint fails outright:
$ ./target/graalvm-native-images --server.port=8082
Starting AOT-processed GraalvmDemoApplication using Java 25.0.2 with PID 1234 (/tmp/sbd-work/spring-boot-demo/graalvm-native-images/target/graalvm-native-images started by root in /tmp/sbd-work/spring-boot-demo/graalvm-native-images)
Tomcat started on port 8082 (http) with context path '/'
Started GraalvmDemoApplication in 0.091 seconds (process running for 0.097)
$ curl -s "localhost:8082/report?format=plain"
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 the exception is ClassNotFoundException, not a milder access-denied error — the class was not merely restricted, it was left out of the binary entirely, because nothing reachable from main() proved it was ever needed. Spring’s own AOT engine registered reflection metadata for every @RestController and every bean it could see statically; a format query parameter deciding between two classes is invisible to it by construction.
Worth noticing on the way past: startup here is already 0.091 seconds, faster than the fixed version later in this article. The two extra reflectively-constructible classes this trap eventually adds are close to free once you are paying the native-image cost at all — the expensive part is discovering you need them, not adding them.
@RegisterReflectionForBinding is the annotation-based alternative to a hand-written or agent-generated JSON file for this exact shape of problem, covered in the same chapter.
The fix: watch real reflection happen, with the tracing agent
GraalVM ships a second tool for exactly this situation: native-image-agent, a JVMTI agent that attaches to a normal JVM run, watches every reflective call actually made, and writes out the same unified reachability-metadata.json format the AOT engine produces — the current format replaces the older separate reflect-config.json / resource-config.json / proxy-config.json trio.
$ /opt/graalvm25/bin/java -agentlib:native-image-agent=config-output-dir=agent-output -jar target/app.jar --server.port=8083
Started GraalvmDemoApplication in 5.552 seconds (process running for 6.51)
$ curl -s localhost:8083/hello
Hello from a GraalVM native image!
$ curl -s "localhost:8083/report?format=plain"
OK via reflection on com.ankurm.graalvmdemo.report.PlainTextReport: REPORT: quarterly numbers
$ curl -s "localhost:8083/report?format=json"
OK via reflection on com.ankurm.graalvmdemo.report.JsonReport: {"report":"quarterly numbers"}
$ grep -A4 '"com.ankurm.graalvmdemo.report.PlainTextReport"' agent-output/reachability-metadata.json
{
"type": "com.ankurm.graalvmdemo.report.PlainTextReport",
"methods": [
{ "name": "<init>", "parameterTypes": [] }
]
}
Full transcript: docs/output/04-tracing-agent.txt, driven by scripts/trace-reflection.sh. Notice the startup time: 5.552 seconds under the agent, against 3.472 seconds without it. Attaching the agent adds real overhead to every request path it watches — which is exactly why this belongs in a one-off exploratory run against a plain jar, or a dedicated test suite, and never in a production binary.
The agent only sees what you exercise. This run only succeeded because curl hit both format=plain and format=json after waiting long enough for the agent-attached JVM to finish starting. An earlier attempt at this exact run used a six-second sleep before curling and captured nothing for either class — the requests silently hit a server that was not listening yet, because curl -s swallows connection errors. If your generated metadata looks suspiciously empty, verify each request actually got an HTTP 200 before you doubt the agent.
The trap inside the fix: two files, one path, one winner
The obvious next step is to copy those generated entries into the project’s own resources so they ship with every future build. The obvious place to put them is under the project’s own Maven coordinates — and that is exactly what silently does nothing.
Copying the agent’s entries into src/main/resources/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json — the project’s real groupId/artifactId, which reads as the obviously correct place — rebuilt cleanly and changed nothing. The same ClassNotFoundException came back. Diffing target/classes/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json against target/spring-aot/main/resources/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json showed they were byte-for-byte identical, both 188 KB, neither containing the two hand-added entries: Spring’s own AOT engine writes its generated metadata to that exact same path, and whichever copy the build’s resource-copying step processes last is the one that survives.
The fix is a folder name that cannot collide with a real Maven coordinate — this project uses the Java package name plus an arbitrary label:
Source: src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json. GraalVM merges every reachability-metadata.json it finds anywhere under META-INF/native-image/ on the classpath regardless of what the intermediate folder names are — they exist purely so different jars’ metadata does not collide with each other, which is exactly the property this fix needs. With the file in this location, both endpoints succeed:
$ ./target/graalvm-native-images --server.port=8085
Started GraalvmDemoApplication in 0.104 seconds (process running for 0.111)
$ curl -s "localhost:8085/report?format=plain"
OK via reflection on com.ankurm.graalvmdemo.report.PlainTextReport: REPORT: quarterly numbers
$ curl -s "localhost:8085/report?format=json"
OK via reflection on com.ankurm.graalvmdemo.report.JsonReport: {"report":"quarterly numbers"}
$ ps -o rss= -p <pid>
108120
Full transcript: docs/output/05-native-startup-fixed.txt. Startup and memory barely move — 0.104s and 108 MB against 0.091s and 108 MB without the fix — confirming the earlier point that two more reflectively-constructible classes cost almost nothing once you have already paid to go native.
Going deeper: never hand-write hints at your own project’s exact groupId/artifactId path. If a hint you added seems to have no effect, diff your generated target/classes/META-INF/native-image/... file against target/spring-aot/main/resources/... before assuming the hint itself is wrong — see docs/03-the-reflection-trap.md.
What native images cost you beyond reflection
Reflection is the trap this project could reproduce end to end, but it is one instance of a broader rule: GraalVM’s closed-world static analysis only includes what it can prove is reachable from main(). The same boundary affects dynamic proxies — Spring’s own @Transactional and @Async machinery is built on JDK dynamic proxies and CGLIB, and native-image needs those proxy classes named explicitly the same way it needed the reflection hints above — along with JNI calls into native libraries and anything based on loading classes from bytes at runtime, such as a Groovy or scripting-engine integration or a hand-rolled plugin loader. None of those appear in this project’s own source, so nothing here demonstrates a captured failure for them the way the reflection trap has one; naming them without a transcript to back them up is the one claim in this article not backed by this project’s own run.
The two costs this project did measure directly are build time and binary size: 4 minutes 19 seconds to compile, against instant jar packaging, and a 93 MB executable against a 22 MB jar (which still needs a JRE installed beside it). Real applications with wider dependency trees push both numbers up, sometimes into double-digit build minutes on CI hardware — that is minutes added to every single pipeline run, not a one-time setup tax. The library ecosystem has genuinely improved here: this project’s own Spring-managed beans needed zero hand-written hints, only the application’s own runtime-computed Class.forName did, and the GraalVM Reachability Metadata Repository now covers a wide swath of common libraries that used to require exactly this kind of manual archaeology.
Going deeper: docs/04-honest-limits.md lays these costs out with the full measured table and names the untested-but-real restrictions above explicitly as such.
The alternative that never leaves the JVM: Project Leyden
Everything above solves cold start by replacing the JVM. Project Leyden solves the same visible symptom from the opposite direction: keep the standard HotSpot JVM, and cache what a training run already learned — class loading and linking (JEP 483), and now method compilation (JEP 484) — into a .jsa archive that a later run loads with -XX:SharedArchiveFile=app.jsa, or generates and reuses automatically with -XX:+AutoCreateSharedArchive.
The dimensions worth knowing before picking either one, drawn from this project’s own measurements and from the dedicated Leyden article‘s benchmarks:
GraalVM native image (this project)
Project Leyden
Runtime
Substrate VM, no JVM
Standard HotSpot JVM
Reflection / dynamic proxies
Must be declared ahead of time
Work unmodified (proxies need -XX:+RecordDynamicProxyData during training)
Build/train step
4m19s native-image compile, every build
A training run producing a .jsa, not a recompile
Cold start
0.091s–0.104s, measured here
40–70% faster than baseline JVM, per Leyden’s own benchmarks
Failure mode when assumptions don’t hold
Build fails, or a ClassNotFoundException in production
Archive validation fails closed — silently falls back to a cold JVM start
That last row is the practical reason to know both exist rather than picking one forever. Leyden’s failure mode is a slower start, recoverable by re-training. A native image’s failure mode, as this article’s own reflection trap showed directly, is a runtime exception in production if a reflective path was missed — recoverable only by adding the hint and rebuilding a binary that takes four minutes to produce. A team unwilling to take on the closed-world discipline this whole article describes, but still wanting materially faster starts than a cold JVM, has Leyden as a real option now that JEP 483 and JEP 484 have shipped, not a hypothetical one.
Should you actually do this?
Native images earn their cost where startup and memory are the metric that matters most directly: serverless functions billed by cold-start latency, CLI tools, and services that scale horizontally and restart often. They cost the most where the team is smallest relative to the surface area of reflection-heavy libraries in play — a large monolith pulling in a wide, occasionally unmaintained dependency tree will spend real engineering time on reachability metadata that Project Leyden, or simply a well-tuned JVM with CDS, would not have asked for at all. If you are not sure which side of that line you are on, the honest move is to build a native image of your actual application, on your actual dependency tree, before committing to it in production — the closed-world assumption either bites on your specific code or it does not, and there is no way to know from a blog post which one is true for you.
No Comments yet!