Files
spring-boot-demo/graalvm-native-images/docs/04-honest-limits.md
T
Claude 8cdfcd4d8d Add graalvm-native-images: Boot 4.1 + GraalVM CE for JDK 25, AOT processing,
the tracing agent, and a real reflection-collision trap

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
2026-09-20 11:03:09 +00:00

85 lines
6.7 KiB
Markdown

# 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