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
This commit is contained in:
Claude
2026-09-20 11:03:09 +00:00
parent e4b5636f7c
commit 8cdfcd4d8d
23 changed files with 627 additions and 0 deletions
+5
View File
@@ -21,3 +21,8 @@ spring-batch-partitioning/data/
# (maven-dependency-plugin's build-classpath goal) for the standalone MigrationCli
# and LoadGenerator entry points - machine-specific, regenerated on every build
db-migrations-expand-contract/cp.txt
# graalvm-native-images: raw native-image-agent output directory (the relevant
# entries are copied by hand into src/main/resources/META-INF/native-image/...;
# this raw directory is a build-time scratch artifact, not documentation)
graalvm-native-images/agent-output/
+60
View File
@@ -0,0 +1,60 @@
# GraalVM native images of a Spring Boot 4.1 application
Companion project for [**GraalVM Native Images for Spring Boot 4.1: AOT Processing, the Tracing
Agent, and a Real Reflection Failure**](https://ankurm.com/building-native-images-of-spring-boot-applications-with-graalvm-a-step-by-step-guide/)
on ankurm.com.
A small Spring Boot 4.1 web app, built and run three ways -- a plain `java -jar`, the same jar
with Spring's AOT processing applied, and a GraalVM native image -- with every startup time,
memory number, and failure message in this README and the linked post captured from a real run,
not estimated.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| JDK (JVM runs) | Temurin 25.0.4.1 |
| GraalVM (native builds) | GraalVM Community Edition for JDK 25, build 25.0.2+10.1 |
| `native-maven-plugin` | 1.1.8 (pinned by `spring-boot-starter-parent:4.1.1`'s dependency management; 1.1.14 is the latest on Maven Central as of this writing, but the parent's pinned version is what actually runs unless overridden) |
The previous version of this project and its post assumed Spring Boot 3.x, JDK 17, and GraalVM
22.3 -- two major generations behind. See [docs/01-versions-and-setup.md](docs/01-versions-and-setup.md)
for how each version claim here was checked.
## Quickstart
```bash
# 1. Plain JVM run (any JDK 25 build)
mvn -DskipTests package
java -jar target/app.jar
# 2. Native image (needs a GraalVM distribution -- native-image ships bundled, no `gu install` step)
export JAVA_HOME=/path/to/graalvm-25.0.2
mvn -Pnative -DskipTests native:compile # NOT `mvn -Pnative package` -- see docs/02-building-the-image.md
./target/graalvm-native-images
```
## What's in this chapter
| Doc | Covers |
|---|---|
| [01-versions-and-setup.md](docs/01-versions-and-setup.md) | Verified version claims; why `gu install native-image` is obsolete |
| [02-building-the-image.md](docs/02-building-the-image.md) | AOT processing's real generated output; why `-Pnative package` alone does not build a native image |
| [03-the-reflection-trap.md](docs/03-the-reflection-trap.md) | A real `ClassNotFoundException` from a runtime-computed `Class.forName`, the tracing-agent fix, and the metadata-collision trap that fix runs into |
| [04-honest-limits.md](docs/04-honest-limits.md) | What native images cost you, and where Project Leyden fits as the non-full-native alternative |
Every number quoted in the post is in [docs/output/](docs/output) as a trimmed, real transcript.
## Measured, not estimated
| | plain JVM | native image |
|---|---|---|
| Startup ("Started ... in") | 3.472 s | 0.091 s (0.104 s with reflection hints added) |
| RSS at idle | ~190.8 MB | ~105.5 MB |
| Artifact size | 22 MB jar (+ a JDK install) | 93 MB self-contained executable |
| Build time | ordinary `mvn package` | 4m 14s&ndash;4m 19s on 2 vCPU / 8 GB |
Full transcripts: [docs/output/01-jvm-baseline.txt](docs/output/01-jvm-baseline.txt),
[docs/output/02-native-build.txt](docs/output/02-native-build.txt),
[docs/output/05-native-startup-fixed.txt](docs/output/05-native-startup-fixed.txt).
@@ -0,0 +1,46 @@
# 01 — Versions and setup
[Back to README →](../README.md) | [Next: 02 — building the image →](02-building-the-image.md)
## The version this project's own earlier post got wrong
The previous version of the linked post assumed Spring Boot 3.x, JDK 17, and "GraalVM 22.3" --
installed the old way, via `sdk install java 22.3.r17-grl` followed by a separate
`gu install native-image` step.
Checked directly rather than assumed:
- **GraalVM ships `native-image` bundled by default now.** Downloading GraalVM Community Edition
for JDK 25 (build 25.0.2, verified by downloading it from
`github.com/graalvm/graalvm-ce-builds/releases/download/jdk-25.0.2/...` and running
`native-image --version`) and checking `$JAVA_HOME/bin/` shows `native-image`,
`native-image-configure`, and `jnativescan` already present. The `gu install native-image` step
the old post described was retired years ago; running it against a current GraalVM distribution
fails because the `gu` tool itself was removed.
- **The current official docs' local-build command is `mvn -Pnative native:compile`**, not
`./mvnw clean package -Pnative` (what the old post used). Chapter 02 shows exactly why that
distinction matters -- `package` alone silently does not invoke the GraalVM compiler at all in
this verified setup.
- **`spring-boot-starter-parent:4.1.1` already wires up `native-maven-plugin` with zero extra
configuration.** The old post's manual `<profile>` block, hardcoded `<native-buildtools.version>`
property, and explicit `<mainClass>` are all unnecessary now -- this project's
[pom.xml](../pom.xml) needs only a bare `<profile><id>native</id>` with the plugin declared and
no version, and `mvn help:effective-pom -Pnative` confirms Maven resolves it to version 1.1.8,
which is what `spring-boot-starter-parent` pins via its own dependency management (1.1.14 is the
newest release on Maven Central as of this writing, per `native-maven-plugin`'s
`maven-metadata.xml`, but the parent's pinned version is what actually runs unless you override
it).
## What this project pins, and why
| | Version | Checked how |
|---|---|---|
| Spring Boot | 4.1.1 | Matches the rest of this repository's other chapters |
| JDK for JVM runs | Temurin 25.0.4.1 | `java -version` |
| GraalVM for native builds | Community Edition for JDK 25, build 25.0.2+10.1 | Downloaded directly, `native-image --version` |
| `native-maven-plugin` | 1.1.8 | `mvn help:effective-pom -Pnative`; Maven Central's `maven-metadata.xml` lists 1.1.14 as newest, confirming 1.1.8 is a deliberate, slightly older pin from Spring Boot's own dependency management, not the newest available |
A plain JDK (Temurin, in `$JDK25_HOME`) is enough for every JVM-mode measurement in this project.
Only the native-image build itself needs the separate GraalVM distribution in `$GRAALVM_HOME`
(see [scripts/run-all.sh](../scripts/run-all.sh)) -- and switching `JAVA_HOME` back and forth
between the two is the only genuinely fiddly part of the whole setup.
@@ -0,0 +1,53 @@
# 02 — AOT processing, and why `package` alone doesn't build the image
[← Previous: 01 — versions and setup](01-versions-and-setup.md) | [Back to README →](../README.md) | [Next: 03 — the reflection trap →](03-the-reflection-trap.md)
## What Spring's AOT processing actually generates
`mvn spring-boot:process-aot` (bound to the `package` phase automatically, no profile needed) is
what makes a Spring Boot native image possible at all -- it runs the application's startup logic
once, at build time, and writes down what it learns as plain Java source instead of leaving it to
be rediscovered by reflection at every future startup. On this project it produced, in
`target/spring-aot/main/`:
- **95 generated `.java` source files** -- one `*__BeanDefinitions.java` per auto-configuration
class and application bean, including
[`HelloController__BeanDefinitions.java`](../src/main/java/com/ankurm/graalvmdemo/HelloController.java)
and `ReportController__BeanDefinitions.java`, plus a
`GraalvmDemoApplication__ApplicationContextInitializer.java` that builds the whole
`ApplicationContext` as ordinary Java calls instead of classpath scanning.
- **`META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json`** -- the
unified reflection/resource/proxy configuration format GraalVM now uses (a single file, not the
older separate `reflect-config.json` / `resource-config.json` / `proxy-config.json` trio).
- **`native-image.properties`** -- build arguments the plugin feeds straight to `native-image`.
## Why `mvn -Pnative package` alone does not produce a binary
Reflected in this exact setup, not assumed: running `mvn -Pnative -DskipTests package` completes
in under nine seconds and its log contains `add-reachability-metadata`, `process-aot`, `jar`, and
`repackage` -- and nothing else. Grepping that log for `native-image` or `Generating '` returns
zero matches. `mvn help:effective-pom -Pnative` confirms why: the `native` profile this project
(and a default Spring Initializr project) declares binds *no* execution of its own to any phase --
it only adds the plugin so its own `add-reachability-metadata` goal, bound in the plugin's own
metadata, participates in the build. The goal that actually invokes the GraalVM compiler,
`native:compile`, has to be run explicitly:
```
mvn -Pnative -DskipTests native:compile
```
which matches Spring's own current documentation for local (non-Docker) native builds. The
`./mvnw clean package -Pnative` command in tutorials and in the earlier version of this project's
post does not do this -- confirmed by testing it directly, not by reading the plugin's changelog.
Full transcripts of both runs, side by side:
[docs/output/06-package-does-not-compile-native.txt](output/06-package-does-not-compile-native.txt)
and [docs/output/02-native-build.txt](output/02-native-build.txt).
## The build itself, measured
On this project's 2 vCPU / 8 GB build machine, `native-image` analysis found 20,126 reachable
types across 29,154 fields and 91,938 methods, registered 7,495 types for reflection, and finished
in 4 minutes 14 seconds to 4 minutes 19 seconds across repeated runs, peaking at 4.36 GB resident
memory during compilation. The resulting executable is 93 MB, versus a 22 MB jar that still needs
a JRE installed alongside it to run at all. Full transcript:
[docs/output/02-native-build.txt](output/02-native-build.txt).
@@ -0,0 +1,77 @@
# 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.
@@ -0,0 +1,84 @@
# 04 — What native images cost you, and where Leyden fits
[← Previous: 03 — the reflection trap](03-the-reflection-trap.md) | [Back to README →](../README.md)
## The costs this chapter's numbers already show
Chapters 02 and 03 measured two of the real costs directly, so this chapter does not repeat them
as claims -- it names them plainly and adds the ones the rest of this small project did not happen
to hit:
- **Build time.** 4m14s&ndash;4m19s for a single-endpoint demo app on 2 vCPU / 8 GB
([docs/output/02-native-build.txt](output/02-native-build.txt)). This scales with the size of
the reachable call graph, not the size of your source -- a real Spring Boot application pulling
in JPA, Kafka clients, and a dozen starters routinely takes native builds into double-digit
minutes on CI hardware. That is minutes added to every single build in the pipeline, not a one-time
setup cost.
- **Binary size.** 93 MB versus a 22 MB jar
([docs/output/02-native-build.txt](output/02-native-build.txt)) -- and the 22 MB jar still needs
a JRE sitting next to it in the container image, so the size comparison is closer than it looks,
but the native binary is a single self-contained file with nothing else to install, which matters
more for container layer caching and cold-pull time than for raw megabytes.
- **The closed-world assumption, beyond reflection.** This project demonstrated one shape of it --
a class name computed at runtime -- but the same static-analysis boundary affects anything the
build cannot prove is reachable from `main()`: JDK dynamic proxies and CGLIB proxies (Spring's
own `@Transactional` and `@Async` machinery relies on these; `native-image` needs proxy classes
named explicitly, the same way it needed the reflection hints in chapter 03), JNI calls into
native libraries, and anything based on running a scripting engine or loading classes from bytes
at runtime (Groovy, Nashorn-style engines, hand-rolled plugin loaders). None of that appears in
this project's own source, so no transcript here demonstrates it directly -- naming it without a
captured failure is the one claim in this chapter not backed by this project's own run, and it is
flagged as such rather than presented as measured.
- **Library compatibility is real but improving.** Spring Boot's own starters, and most of the
library ecosystem tracking Spring Boot 4.1, ship reachability metadata now, which is why this
demo's own Spring-managed beans needed zero hand-written hints -- only the application's own
runtime-computed `Class.forName` did. A library with no shipped metadata and no metadata in the
[GraalVM Reachability Metadata Repository](https://github.com/oracle/graalvm-reachability-metadata)
is still a real risk to budget time for, but it is no longer the default outcome it was in 2022.
## Where Project Leyden fits
[Project Leyden](https://ankurm.com/project-leyden-explained-aot-compilation-and-smart-caching-to-finally-fix-javas-cold-start/)
solves the same visible symptom -- slow JVM cold start -- from the opposite direction. GraalVM
`native-image` replaces the JVM with a static, closed-world-analyzed binary; Leyden keeps the
standard HotSpot JVM and instead caches what a *training run* already did -- 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 has generated for it automatically with
`-XX:+AutoCreateSharedArchive`.
That difference in approach is also the difference in trade-offs, and it runs in the opposite
direction from every cost in this chapter:
| | GraalVM native image (this project) | Project Leyden |
|---|---|---|
| Runtime | Substrate VM, no JVM | Standard HotSpot JVM |
| Reflection / dynamic class loading | Must be declared ahead of time (chapter 03) | Works unmodified -- it is the same JVM |
| Dynamic proxies (`@Transactional`, `@Async`) | Need explicit registration | Work unmodified, though Spring needs `-XX:+RecordDynamicProxyData` during training to capture proxy shapes for the cache |
| Build/train step | 4m14s&ndash;4m19s `native-image` compile, every build | A training run producing a `.jsa`, not a full recompile |
| Cold start (this project's measurement) | 0.091s&ndash;0.104s | Not measured in this project -- post 6035's own benchmarks put it well below plain-JVM startup but above native-image's near-instant figures |
| Portability | Platform- and architecture-specific binary | Platform-specific archive, but the JAR itself stays portable |
| Failure mode when assumptions don't hold | Build fails, or a `ClassNotFoundException` at runtime (chapter 03) | Archive validation (classpath fingerprint, JVM flags, module graph) fails closed -- it silently falls back to a normal cold JVM start rather than crashing |
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 is a
`ClassNotFoundException` in production if a reflective path was missed, recoverable only by adding
the hint and rebuilding. A team unwilling to own the closed-world discipline this whole chapter
describes, but still wanting materially faster starts than a cold JVM, has Leyden as a real
middle option -- not a hypothetical one, now that JEP 483 and JEP 484 have shipped.
## Should you actually do this?
<blockquote style="border-left:4px solid #999;padding:0.5em 1em;margin:1em 0;background:#f7f7f7;">
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 horizontally-scaled services that
restart often. They cost the most where the team is smallest relative to the surface area of
reflection-heavy libraries in play -- a large Spring Boot monolith pulling in a wide, occasionally
unmaintained dependency tree will spend real engineering time on reachability metadata that a
Leyden-based approach, or simply a well-tuned JVM with CDS, would not have asked for at all.
</blockquote>
## Going deeper
- [GraalVM Reachability Metadata Repository](https://github.com/oracle/graalvm-reachability-metadata) -- the shared source of hints for common libraries, consulted automatically by the Maven plugin
- [Project Leyden Explained](https://ankurm.com/project-leyden-explained-aot-compilation-and-smart-caching-to-finally-fix-javas-cold-start/) -- this project's sibling post, with its own detailed JEP 483/484 walkthrough and benchmark numbers
- [Spring's official native-image documentation](https://docs.spring.io/spring-boot/how-to/native-image/developing-your-first-application.html) -- the current source of truth this project's chapter 02 was checked against
@@ -0,0 +1,7 @@
$ 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
@@ -0,0 +1,18 @@
$ 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
@@ -0,0 +1,13 @@
$ ./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)
$ ps -o rss= -p 1234
107984
$ 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
$ curl -s "localhost:8082/report?format=json"
REFLECTION-FAILED class=com.ankurm.graalvmdemo.report.JsonReport exception=java.lang.ClassNotFoundException message=com.ankurm.graalvmdemo.report.JsonReport
@@ -0,0 +1,17 @@
$ /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": [] }
]
}
@@ -0,0 +1,10 @@
$ ./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
@@ -0,0 +1,12 @@
$ mvn -Pnative -DskipTests package
[INFO] --- native:1.1.8:add-reachability-metadata (add-reachability-metadata) @ graalvm-native-images ---
[INFO] --- resources:3.5.0:resources (default-resources) @ graalvm-native-images ---
[INFO] --- compiler:3.15.0:compile (default-compile) @ graalvm-native-images ---
[INFO] --- spring-boot:4.1.1:process-aot (process-aot) @ graalvm-native-images ---
[INFO] --- jar:3.5.1:jar (default-jar) @ graalvm-native-images ---
[INFO] --- spring-boot:4.1.1:repackage (repackage) @ graalvm-native-images ---
[INFO] BUILD SUCCESS
[INFO] Total time: 8.791 s
# No native-image invocation anywhere in this log -- confirmed by grepping the full log for
# "native-image" and "Generating '": zero matches. Only "mvn -Pnative native:compile" (run
# separately, see 02-native-build.txt) actually invokes the GraalVM compiler.
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>graalvm-native-images</artifactId>
<version>1.0.0</version>
<name>graalvm-native-images</name>
<description>GraalVM native images of a Spring Boot 4.1 application: AOT processing, the tracing agent, a real reflection failure and fix, and measured startup/memory numbers</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Reproduces every measurement behind the ankurm.com GraalVM native images post.
# Requires: JAVA_HOME pointed at a plain JDK 25 for the JVM steps, and a separate GraalVM
# distribution (with the bundled native-image tool) for the native steps -- see docs/00-versions.md.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== 1. plain JVM baseline =="
"$JDK25_HOME/bin/java" -jar target/app.jar --server.port=8081 &
JPID=$!
sleep 5
curl -s localhost:8081/hello; echo
ps -o rss= -p $JPID
kill $JPID
echo "== 2. build the native image (mvn -Pnative native:compile) =="
mvn -Pnative -DskipTests native:compile
echo "== 3. run the native image, reflection trap included =="
./target/graalvm-native-images --server.port=8082 &
NPID=$!
sleep 1
curl -s "localhost:8082/report?format=plain"; echo
ps -o rss= -p $NPID
kill $NPID
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Reproduces the tracing-agent fix for the /report reflection trap: run the plain jar under the
# GraalVM tracing agent, exercise both code paths, then rebuild the native image with the
# generated hints merged in under a namespace that does NOT collide with Spring's own AOT output.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf agent-output && mkdir -p agent-output
"$GRAALVM_HOME/bin/java" -agentlib:native-image-agent=config-output-dir=agent-output \
-jar target/app.jar --server.port=8083 &
AGENT_PID=$!
sleep 10
curl -s "localhost:8083/report?format=plain"; echo
curl -s "localhost:8083/report?format=json"; echo
kill $AGENT_PID
echo "Generated hints for our own classes:"
grep -A4 "com.ankurm.graalvmdemo.report" agent-output/reachability-metadata.json
@@ -0,0 +1,17 @@
package com.ankurm.graalvmdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion app for the ankurm.com GraalVM native images post. Built three ways from the same
* source: a plain {@code java -jar}, a Spring Boot AOT-processed JVM run, and a GraalVM native
* image. See docs/02-building-the-image.md for the exact commands behind each measurement.
*/
@SpringBootApplication
public class GraalvmDemoApplication {
public static void main(String[] args) {
SpringApplication.run(GraalvmDemoApplication.class, args);
}
}
@@ -0,0 +1,18 @@
package com.ankurm.graalvmdemo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The baseline endpoint used for every startup-time and memory measurement in this chapter --
* deliberately trivial, so the numbers reflect the framework and the runtime, not application
* work. See docs/01-building-the-image.md.
*/
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello from a GraalVM native image!";
}
}
@@ -0,0 +1,8 @@
package com.ankurm.graalvmdemo.report;
public class JsonReport implements ReportFormat {
@Override
public String render(String message) {
return "{\"report\":\"" + message + "\"}";
}
}
@@ -0,0 +1,8 @@
package com.ankurm.graalvmdemo.report;
public class PlainTextReport implements ReportFormat {
@Override
public String render(String message) {
return "REPORT: " + message;
}
}
@@ -0,0 +1,29 @@
package com.ankurm.graalvmdemo.report;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Loads a {@link ReportFormat} implementation by a class name built at request time from the
* {@code format} query parameter -- a stand-in for the real-world "pluggable strategy resolved by
* a string from configuration" pattern (a plugin name, a feature flag, a database column).
* Nothing here gives GraalVM's build-time static analysis a way to know which classes this
* reflective call can reach; see docs/02-the-reflection-trap.md for the real failure and its fix.
*/
@RestController
public class ReportController {
@GetMapping("/report")
public String report(@RequestParam(defaultValue = "plain") String format, @RequestParam(defaultValue = "quarterly numbers") String message) {
String simpleName = "plain".equals(format) ? "PlainTextReport" : "JsonReport";
String className = "com.ankurm.graalvmdemo.report." + simpleName;
try {
Class<?> clazz = Class.forName(className);
ReportFormat instance = (ReportFormat) clazz.getDeclaredConstructor().newInstance();
return "OK via reflection on " + className + ": " + instance.render(message);
} catch (ReflectiveOperationException e) {
return "REFLECTION-FAILED class=" + className + " exception=" + e.getClass().getName() + " message=" + e.getMessage();
}
}
}
@@ -0,0 +1,10 @@
package com.ankurm.graalvmdemo.report;
/**
* Deliberately loaded by class name at runtime, never referenced by a Spring bean definition or a
* static {@code new PlainTextReport()} anywhere in this codebase -- see docs/02-the-reflection-trap.md
* for why that specific shape is what breaks under GraalVM's closed-world analysis.
*/
public interface ReportFormat {
String render(String message);
}
@@ -0,0 +1,16 @@
{
"reflection": [
{
"type": "com.ankurm.graalvmdemo.report.PlainTextReport",
"methods": [
{ "name": "<init>", "parameterTypes": [] }
]
},
{
"type": "com.ankurm.graalvmdemo.report.JsonReport",
"methods": [
{ "name": "<init>", "parameterTypes": [] }
]
}
]
}
@@ -0,0 +1,13 @@
spring:
application:
name: graalvm-native-images
management:
endpoints:
web:
exposure:
include: health
logging:
pattern:
console: "%msg%n"