diff --git a/.gitignore b/.gitignore index 2050e13..f6ff9b6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/graalvm-native-images/README.md b/graalvm-native-images/README.md new file mode 100644 index 0000000..db0c34d --- /dev/null +++ b/graalvm-native-images/README.md @@ -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). diff --git a/graalvm-native-images/docs/01-versions-and-setup.md b/graalvm-native-images/docs/01-versions-and-setup.md new file mode 100644 index 0000000..13b2781 --- /dev/null +++ b/graalvm-native-images/docs/01-versions-and-setup.md @@ -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 `` block, hardcoded `` + property, and explicit `` are all unnecessary now -- this project's + [pom.xml](../pom.xml) needs only a bare `native` 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. diff --git a/graalvm-native-images/docs/02-building-the-image.md b/graalvm-native-images/docs/02-building-the-image.md new file mode 100644 index 0000000..60b63eb --- /dev/null +++ b/graalvm-native-images/docs/02-building-the-image.md @@ -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). diff --git a/graalvm-native-images/docs/03-the-reflection-trap.md b/graalvm-native-images/docs/03-the-reflection-trap.md new file mode 100644 index 0000000..4a01b73 --- /dev/null +++ b/graalvm-native-images/docs/03-the-reflection-trap.md @@ -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. diff --git a/graalvm-native-images/docs/04-honest-limits.md b/graalvm-native-images/docs/04-honest-limits.md new file mode 100644 index 0000000..1ca6a46 --- /dev/null +++ b/graalvm-native-images/docs/04-honest-limits.md @@ -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? + +
+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. +
+ +## 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 diff --git a/graalvm-native-images/docs/output/01-jvm-baseline.txt b/graalvm-native-images/docs/output/01-jvm-baseline.txt new file mode 100644 index 0000000..dc5d4b4 --- /dev/null +++ b/graalvm-native-images/docs/output/01-jvm-baseline.txt @@ -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 diff --git a/graalvm-native-images/docs/output/02-native-build.txt b/graalvm-native-images/docs/output/02-native-build.txt new file mode 100644 index 0000000..824b9d8 --- /dev/null +++ b/graalvm-native-images/docs/output/02-native-build.txt @@ -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 diff --git a/graalvm-native-images/docs/output/03-native-startup-broken.txt b/graalvm-native-images/docs/output/03-native-startup-broken.txt new file mode 100644 index 0000000..0b94512 --- /dev/null +++ b/graalvm-native-images/docs/output/03-native-startup-broken.txt @@ -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 diff --git a/graalvm-native-images/docs/output/04-tracing-agent.txt b/graalvm-native-images/docs/output/04-tracing-agent.txt new file mode 100644 index 0000000..d289c75 --- /dev/null +++ b/graalvm-native-images/docs/output/04-tracing-agent.txt @@ -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": "", "parameterTypes": [] } + ] +} diff --git a/graalvm-native-images/docs/output/05-native-startup-fixed.txt b/graalvm-native-images/docs/output/05-native-startup-fixed.txt new file mode 100644 index 0000000..6fbc278 --- /dev/null +++ b/graalvm-native-images/docs/output/05-native-startup-fixed.txt @@ -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 +108120 diff --git a/graalvm-native-images/docs/output/06-package-does-not-compile-native.txt b/graalvm-native-images/docs/output/06-package-does-not-compile-native.txt new file mode 100644 index 0000000..ea3adc8 --- /dev/null +++ b/graalvm-native-images/docs/output/06-package-does-not-compile-native.txt @@ -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. diff --git a/graalvm-native-images/pom.xml b/graalvm-native-images/pom.xml new file mode 100644 index 0000000..ef7bc98 --- /dev/null +++ b/graalvm-native-images/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + graalvm-native-images + 1.0.0 + graalvm-native-images + 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 + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + app + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + native + + + + org.graalvm.buildtools + native-maven-plugin + + + + + + diff --git a/graalvm-native-images/scripts/run-all.sh b/graalvm-native-images/scripts/run-all.sh new file mode 100644 index 0000000..de73e70 --- /dev/null +++ b/graalvm-native-images/scripts/run-all.sh @@ -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 diff --git a/graalvm-native-images/scripts/trace-reflection.sh b/graalvm-native-images/scripts/trace-reflection.sh new file mode 100644 index 0000000..b29f4f8 --- /dev/null +++ b/graalvm-native-images/scripts/trace-reflection.sh @@ -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 diff --git a/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/GraalvmDemoApplication.java b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/GraalvmDemoApplication.java new file mode 100644 index 0000000..5af13a8 --- /dev/null +++ b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/GraalvmDemoApplication.java @@ -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); + } +} diff --git a/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/HelloController.java b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/HelloController.java new file mode 100644 index 0000000..1272af6 --- /dev/null +++ b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/HelloController.java @@ -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!"; + } +} diff --git a/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/JsonReport.java b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/JsonReport.java new file mode 100644 index 0000000..4388771 --- /dev/null +++ b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/JsonReport.java @@ -0,0 +1,8 @@ +package com.ankurm.graalvmdemo.report; + +public class JsonReport implements ReportFormat { + @Override + public String render(String message) { + return "{\"report\":\"" + message + "\"}"; + } +} diff --git a/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/PlainTextReport.java b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/PlainTextReport.java new file mode 100644 index 0000000..7484577 --- /dev/null +++ b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/PlainTextReport.java @@ -0,0 +1,8 @@ +package com.ankurm.graalvmdemo.report; + +public class PlainTextReport implements ReportFormat { + @Override + public String render(String message) { + return "REPORT: " + message; + } +} diff --git a/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/ReportController.java b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/ReportController.java new file mode 100644 index 0000000..c027408 --- /dev/null +++ b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/ReportController.java @@ -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(); + } + } +} diff --git a/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/ReportFormat.java b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/ReportFormat.java new file mode 100644 index 0000000..6ae6f87 --- /dev/null +++ b/graalvm-native-images/src/main/java/com/ankurm/graalvmdemo/report/ReportFormat.java @@ -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); +} diff --git a/graalvm-native-images/src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json b/graalvm-native-images/src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json new file mode 100644 index 0000000..cf35e02 --- /dev/null +++ b/graalvm-native-images/src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json @@ -0,0 +1,16 @@ +{ + "reflection": [ + { + "type": "com.ankurm.graalvmdemo.report.PlainTextReport", + "methods": [ + { "name": "", "parameterTypes": [] } + ] + }, + { + "type": "com.ankurm.graalvmdemo.report.JsonReport", + "methods": [ + { "name": "", "parameterTypes": [] } + ] + } + ] +} diff --git a/graalvm-native-images/src/main/resources/application.yml b/graalvm-native-images/src/main/resources/application.yml new file mode 100644 index 0000000..330cd2e --- /dev/null +++ b/graalvm-native-images/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: graalvm-native-images + +management: + endpoints: + web: + exposure: + include: health + +logging: + pattern: + console: "%msg%n"