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:
@@ -21,3 +21,8 @@ spring-batch-partitioning/data/
|
|||||||
# (maven-dependency-plugin's build-classpath goal) for the standalone MigrationCli
|
# (maven-dependency-plugin's build-classpath goal) for the standalone MigrationCli
|
||||||
# and LoadGenerator entry points - machine-specific, regenerated on every build
|
# and LoadGenerator entry points - machine-specific, regenerated on every build
|
||||||
db-migrations-expand-contract/cp.txt
|
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/
|
||||||
|
|||||||
@@ -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–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–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–4m19s `native-image` compile, every build | A training run producing a `.jsa`, not a full recompile |
|
||||||
|
| Cold start (this project's measurement) | 0.091s–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.
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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);
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
Reference in New Issue
Block a user