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

6.7 KiB
Raw Blame History

04 — What native images cost you, and where Leyden fits

← Previous: 03 — the reflection trap | Back to README →

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. 4m14s4m19s for a single-endpoint demo app on 2 vCPU / 8 GB (docs/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) -- 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 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 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 4m14s4m19s native-image compile, every build A training run producing a .jsa, not a full recompile
Cold start (this project's measurement) 0.091s0.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