Scope
How Spring Boot attacks its historical weakness — startup time and memory footprint — without giving up the ecosystem. We cover why the JVM boots slowly, Spring AOT processing (build-time bean registration + reflection/proxy/resource hints), GraalVM native images and their closed-world trade-offs, the newer AOT-cache / CDS JVM speedups, CRaC, and virtual threads for I/O-bound concurrency. For the servlet-vs-reactive concurrency model virtual threads compete with, see 05 - Web Layer & Embedded Servers - MVC vs WebFlux; for shipping the resulting artifact, see 15 - Packaging, Containers & Deployment.
Related: Spring Boot, 05 - Web Layer & Embedded Servers - MVC vs WebFlux, 15 - Packaging, Containers & Deployment

Startup time compared: a JVM jar warming up over seconds — classpath scan, reflection, proxy generation, JIT climbing to peak — versus a GraalVM native image already serving traffic in milliseconds.
1. Why startup is slow on the JVM
A Spring Boot jar does a surprising amount of work between java -jar and the first served request. Four costs dominate, and naming them is the senior signal:
- Classpath scanning & condition evaluation. Auto-configuration (01 - Spring Boot Fundamentals & Auto-Configuration) reads every JAR’s
AutoConfiguration.imports, then evaluates hundreds of@Conditionalchecks against the live classpath and environment. This is deterministic but not free. - Runtime reflection. Bean instantiation, dependency injection,
@ConfigurationPropertiesbinding, and Jackson (de)serialization all lean on reflection — reading annotations and invoking constructors/setters byMethod/Fieldat runtime. - Dynamic proxy generation.
@Transactional,@Async,@Cacheable, Spring Data repositories, and AOP advice are backed by CGLIB / JDK dynamic proxies generated at startup — bytecode synthesised in memory the first time the context wires them. - JIT warm-up. The JVM starts interpreting bytecode, then C1/C2 compile hot paths only after profiling them. Peak throughput arrives seconds-to-minutes in, so the first thousands of requests run on cold, un-optimised code.
The framing interviewers want
“JVM startup cost is reflection, proxy generation, and classpath-driven condition evaluation happening at runtime; throughput ramps because the JIT profiles before it optimises. AOT moves the wiring work to build time; native images additionally drop the JVM; virtual threads and CDS attack different parts of the same problem.” Knowing which technique fixes which cost is the whole topic.
2. Spring AOT processing
Spring AOT (ahead-of-time) is a build-time engine that does at compile time what the container otherwise does at startup. It runs your ApplicationContext far enough to know every bean definition, then generates explicit Java source that registers those beans directly — no runtime condition evaluation, no reflective scanning.
It emits three kinds of output:
- Bean-registration code — generated
BeanDefinitionregistrations replacing runtime@Configurationprocessing. - Generated bytecode for the dynamic proxies that would otherwise be synthesised at startup.
- Hint files — GraalVM JSON under
META-INF/native-image/{groupId}/{artifactId}/(reflect-config.json,resource-config.json,proxy-config.json,serialization-config.json) declaring exactly what reflection, resources, and proxies the app needs.
# Run Spring AOT processing on its own (also runs automatically during a native build)
mvn spring-boot:process-aot
# → sources in target/spring-aot/main/sources, hints in .../META-INF/native-image/Crucially, AOT processing is useful on the plain JVM too — the generated code and pre-computed context shave startup even when you never build a native image. It is the shared foundation for both §3 (native) and §5 (AOT cache).
AOT is where reflection becomes explicit
Anything the framework knows about statically (its own beans, your
@Components, Spring Data repos) gets hints generated automatically. Anything your code reaches reflectively — deserializing a DTO you only name in a config string, loading a class by name, a library using reflection Spring can’t see — is invisible to AOT and needs a manual hint (§3). This gap is the root cause of most native failures.
3. GraalVM native image
A native image compiles your AOT-processed application ahead of time into a standalone OS executable — no JVM, no bytecode, no JIT. GraalVM’s native-image performs static analysis under the closed-world assumption: everything reachable must be known at build time. Code it can’t prove reachable is stripped; reflection/resources it can’t see simply aren’t in the binary.
Two build paths, both driven by the Spring Boot Maven/Gradle plugins:
# A) Native executable via GraalVM native-maven-plugin (the `native` profile is in the parent POM)
mvn -Pnative native:compile # runs process-aot, then native-image → target/<app>
# B) Native OCI container via Cloud Native Buildpacks (no local GraalVM needed)
mvn -Pnative spring-boot:build-image # Gradle: ./gradlew bootBuildImageWhen AOT can’t see a reflective access, you supply the hint yourself:
// Register a RuntimeHintsRegistrar and activate it on a @Configuration class.
public class OrderHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader cl) {
hints.reflection().registerType(LegacyOrder.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS);
hints.resources().registerPattern("db/migration/*.sql");
}
}
@Configuration
@ImportRuntimeHints(OrderHints.class) // activates the registrar above
class AppConfig { }
// For DTOs bound via RestClient/WebClient/Jackson, the concise declarative form:
@RegisterReflectionForBinding({ OrderDto.class, CustomerDto.class })
class BindingHints { }Gotcha #1 — reflection/proxies break under native, and only at native runtime
A
Class.forName(...), a library reflecting on a type, or a dynamic proxy Spring didn’t generate a hint for will compile into a native image with no warning. It fails only when that path executes on the deployed binary —ClassNotFoundException, a missing-resource error, or a null where a proxy should be. The JVM run was green; the native binary throws in production.
Mitigation: add aRuntimeHintsRegistrar/@RegisterReflectionForBindingfor every reflective/dynamic access your code owns; verify hints in tests withRuntimeHintsPredicates; and use the GraalVM tracing agent to capture hints for third-party libraries you can’t annotate.
4. Native image trade-offs
Native images are not a free upgrade — they are a different runtime with a different cost profile.
| Native image | |
|---|---|
| Startup | milliseconds (10-100×) — no classpath scan, no JIT |
| Memory | much lower RSS; no JIT/metadata overhead |
| Peak throughput | often lower than a warmed JVM — there is no JIT to reach C2 peak |
| Build time | minutes per image (static analysis is expensive) |
| Dynamism | closed-world: reflection/proxies/resources need hints; no runtime class loading |
| Observability | weaker — no JVMTI/JFR parity, harder profiling & debugging, no dynamic attach |
Gotcha #2 — assuming "native = faster" for throughput
The single most common misconception. Native wins startup and memory, but for a long-running service handling sustained load, a warmed JVM usually delivers higher peak throughput because C2 has profiled and optimised the hot paths — something an AOT-compiled binary with no JIT cannot do. Choosing native for a steady high-RPS backend can cost you requests-per-second.
Mitigation: pick native for cold-start-dominated workloads (§9), not for peak throughput. If you need both fast startup and JIT peak, look at the AOT cache (§5) or CRaC (§6), which keep the JVM.
Gotcha #3 — multi-minute native builds in CI
native-imagestatic analysis can take several minutes per module, dwarfing a normal jar build and inflating pipeline time and cost. Teams discover this only after wiring native into every PR build.
Mitigation: build native only on release/deploy branches, not every PR; run the fast JVM test suite on PRs; cache buildpack layers; parallelise. Keep the JVM jar as the day-to-day artifact.
5. The AOT cache & JVM CDS (keep the JVM, boot faster)
If the goal is faster startup without leaving the JVM, two related techniques front-load work while preserving the JIT.
- CDS / AppCDS (Class Data Sharing) writes a memory-mappable archive of parsed class metadata so restarts skip class loading/verification. Spring Boot can generate a training run archive; enable with
-XX:SharedArchiveFile=app.jsa. Modest but real (~roughly 30-50% startup reduction, workload-dependent). - AOT cache — Spring Boot 4’s newer capability built on Project Leyden-style JVM work: it extends Spring AOT by persisting build-time analysis (bean definitions, dependency graph) and reusing it across restarts, and seeds the JIT with prior observations. The app still runs on a full HotSpot JVM with all dynamic capabilities intact — the key advantage over native.
# CDS/AppCDS: a training run records the archive, then subsequent starts consume it
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar # (training / record)
java -XX:SharedArchiveFile=app.jsa -jar app.jar # (fast start / consume)Warning
The AOT cache is new and evolving: current reporting ties the Spring Boot 4 AOT-cache path to Java 25 (Project Leyden JEPs), with headline figures of ~50-70% startup reduction and 3-4× when combined with Spring AOT. These numbers, the required JDK, and the exact flags/goal names are not stable — treat every figure here as illustrative and re-verify against docs.spring.io and openjdk.org before quoting.
Mitigation: in an interview, describe the mechanism (“persist AOT analysis + CDS, reuse across restarts, keep the JVM/JIT”) rather than committing to a percentage or JDK version you can’t confirm.
6. CRaC — Coordinated Restore at Checkpoint
CRaC is an OpenJDK API (needing a CRaC-enabled JDK) that snapshots a fully warmed, running JVM to disk and restores it in milliseconds. Instead of re-running startup, you resume a process image whose context is already refreshed and — ideally — already JIT-warmed.
# Spring Boot: automatic checkpoint at the end of context refresh
java -Dspring.context.checkpoint=onRefresh -XX:CRaCCheckpointTo=./cr -jar app.jar # create
java -XX:CRaCRestoreFrom=./cr # restore (~ms)Spring exposes lifecycle hooks — @PreCheckpoint and @PostRestore — so beans can release/reopen resources around the snapshot. [HIGH-CHURN: CRaC support and exact properties have shifted across recent Boot 3.x/4.x releases — verify.]
CRaC's sharp edge: open resources
A checkpoint freezes open sockets, DB connections, and file handles — which are meaningless after restore (possibly on a different host). You must close connection pools and open resources in
@PreCheckpointand re-establish them in@PostRestore, or the restored process resumes with dead handles. There are also security implications: the image can capture secrets in memory.
7. Virtual threads (Java 21+)
Virtual threads (Project Loom) are JVM-scheduled threads multiplexed onto a small pool of platform carrier threads. A blocking call (JDBC, HTTP, file I/O) unmounts the virtual thread from its carrier so the carrier serves others — giving you thread-per-request simplicity with async-like scalability for I/O-bound work.
# Spring Boot 3.2+ : run the web server, @Async, and task executors on virtual threads
spring.threads.virtual.enabled=true # requires Java 21+This directly reframes the servlet-vs-reactive debate from 05 - Web Layer & Embedded Servers - MVC vs WebFlux: for I/O-bound APIs, blocking Spring MVC on virtual threads now reaches high concurrency with plain imperative code — you keep readable stack traces, try/finally, and debuggability that WebFlux’s reactive pipelines sacrifice. Reactive still wins for streaming/backpressure and truly enormous connection counts, but virtual threads remove concurrency as the default reason to go reactive.
Gotcha #5 — carrier PINNING and pooled/thread-local resources
A virtual thread that can’t unmount pins its carrier, collapsing the scalability benefit. On JDK 21-23, blocking inside a
synchronizedblock pins; this is largely fixed in JDK 24 by JEP 491 (monitor ownership tracked per virtual thread). Pinning still occurs when blocking inside native methods / the Foreign Function & Memory API. Separately, virtual threads don’t multiply scarce resources: a fixed DB connection pool still caps real concurrency, and heavyThreadLocaluse (now cheap to create by the million) can blow up memory.
Mitigation: run on JDK 24+ to shedsynchronizedpinning (or replacesynchronizedwithReentrantLockon older JDKs); size connection pools deliberately; auditThreadLocalusage; never pool virtual threads. [HIGH-CHURN: pinning status is JDK-version-sensitive — verify.]
8. Container & heap awareness
Startup and memory tuning are pointless if the JVM misreads its container. Modern JDKs are cgroup-aware: inside a container they size the heap and available-processor count from the cgroup limits, not the host’s — but only if you let them.
- Prefer
-XX:MaxRamPercentage(a fraction of the container limit) over a fixed-Xmxso the heap tracks the pod’s memory limit. - Native images and CRaC restores keep RSS low, which is what lets you pack more replicas per node — the memory win often matters more than the startup win.
- Set container memory requests/limits deliberately; an OOM-killed pod that never logged a Java
OutOfMemoryErroris almost always the kernel cgroup killer, not the JVM. See 15 - Packaging, Containers & Deployment.
9. Choosing a strategy
| Strategy | Startup | Peak throughput | Memory | Build cost | Maturity | When to pick which |
|---|---|---|---|---|---|---|
| JVM jar | slowest (sec) | highest (JIT) | highest | fast | highest | Default. Long-running, high-throughput services; day-to-day dev artifact |
| AOT cache / CDS | faster (JVM) | high (JIT kept) | ~JVM | fast–moderate | new/high-churn | Want quicker startup and JIT peak without leaving the JVM |
| CRaC | ~ms (restore) | high (warmed) | ~JVM | moderate (snapshot) | evolving | Fast restore of a warmed app; scale-out where per-instance warm-up hurts |
| GraalVM native | ~ms | lower (no JIT) | lowest | minutes | mature, closed-world | Serverless / scale-to-zero / functions; cold-start latency is the product constraint |
The committed recommendation
Default to the JVM jar, add AOT + CDS/AOT-cache when startup matters but you still want JIT peak throughput, and reach for GraalVM native only when cold start / scale-to-zero / memory density dominates (serverless, functions, high-replica-count) and you accept the closed-world and lower-peak-throughput costs. For an I/O-bound app that just needs concurrency, virtual threads on plain MVC is the cheapest win of all. Name the trade-off — “native trades peak throughput and dynamism for startup and footprint” — rather than treating native as a universal speed-up.
10. Caveats & risk mitigation (summary)
Gotcha #6 — testing on the JVM but shipping native
The most dangerous pattern: your CI runs the JVM test suite (green), but production runs a native binary with different reflection/resource/proxy behaviour. Native-only failures — missing hints, closed-world stripping — surface in production, not in tests.
Mitigation: run at least a smoke/integration suite against the native image (native:test/ the GraalVM test support) in the release pipeline before deploying. Treat “passes on the JVM” as necessary, not sufficient.
- Know which cost each tool fixes (§1). Reflection/wiring → AOT; the whole JVM → native; class-load cost → CDS; warm-up loss → CRaC/AOT-cache; I/O concurrency → virtual threads.
- Native ≠ throughput (§4). It wins startup and memory and loses JIT peak — choose it for cold-start-bound workloads only.
- Every reflective/dynamic access needs a hint under native (§3). Add registrars, verify with
RuntimeHintsPredicates, use the tracing agent for third-party libs. - Close resources around CRaC checkpoints (§6); re-open in
@PostRestore. - Run virtual threads on JDK 24+ (§7) to shed
synchronizedpinning; size pools; auditThreadLocal. - Version facts are volatile. Boot 4.1.x / Framework 7 / Jakarta EE 11 / JDK 17 baseline (Java 21/25 recommended). AOT cache, CRaC, and pinning status are fast-moving — re-verify before quoting numbers or JDK requirements.
11. In practice
// A native-safe web service: hints declared, virtual threads on, cheap startup.
package com.acme;
@SpringBootApplication
@ImportRuntimeHints(OrderHints.class) // §3 — reflective access made explicit
public class StoreApplication {
public static void main(String[] args) {
SpringApplication.run(StoreApplication.class, args);
}
}
@RegisterReflectionForBinding({ OrderDto.class }) // §3 — DTOs (de)serialized reflectively
class BindingHints { }# application.properties
spring.threads.virtual.enabled=true # §7 — I/O-bound concurrency, imperative code (Java 21+)
# JVM (containerised): let the heap track the cgroup limit — §8
# -XX:MaxRamPercentage=75.0mvn spring-boot:process-aot # §2 — AOT works on the JVM too
mvn -Pnative native:compile # §3 — native executable (release builds only — §4)Interview talking points to be able to defend:
- “JVM startup cost is runtime reflection, startup-time proxy generation, and classpath-driven condition evaluation; throughput ramps because the JIT profiles before it optimises.”
- “Spring AOT moves bean registration to build time and emits reflection/proxy/resource hints; it helps on the JVM and is the prerequisite for GraalVM native.”
- “Native wins startup and memory under a closed-world assumption, but loses peak throughput because there’s no JIT — so it’s for cold-start/serverless, not steady high-RPS.”
- “Anything I reach reflectively needs a
RuntimeHintsRegistraror@RegisterReflectionForBinding, or it fails only at native runtime — so I test the native image, not just the JVM.” - “If I want fast startup and JIT peak, I keep the JVM and use the AOT cache/CDS or CRaC instead of going native.”
- “For I/O-bound concurrency I enable virtual threads on plain MVC; on JDK 24+
synchronizedno longer pins, but pooled resources still cap real concurrency.”
12. Sources
- Spring Boot Reference — GraalVM Native Image support
- Spring Boot Reference — Advanced native topics (RuntimeHintsRegistrar, hints)
- Spring Framework Reference — Ahead-of-Time optimizations & runtime hints
- Spring Boot Reference — Checkpoint and Restore with the JVM (CRaC)
- Spring Boot blog — CDS support and Project Leyden anticipation
- JEP 491 — Synchronize Virtual Threads without Pinning (JDK 24)
- GraalVM — Native Image documentation
- GraalVM — Reachability & the closed-world assumption