GraalVM Native Image: Faster Java Startup (2026)

GraalVM Native Image compiles Java applications ahead-of-time into a self-contained native executable. The result: startup times under 100ms, memory footprints 3–5x smaller than JVM deployments, and no JVM needed at runtime. This matters enormously for serverless functions, CLI tools, and Kubernetes pods that need fast scale-out.

How Native Image Compilation Works

Standard Java compiles source to bytecode, which the JVM JIT-compiles to machine code at runtime. Native Image inverts this: it performs ahead-of-time (AOT) compilation during the build, producing a platform-specific native binary.

The key concept is the closed-world assumption: the native image compiler must know at build time every class, method, and field that will ever be used at runtime. It performs static analysis (reachability analysis) starting from your main method and includes only reachable code in the binary. Anything not provably reachable is excluded — which is how native images achieve small binary sizes.

This closed-world assumption is also the source of most limitations: dynamic class loading, arbitrary reflection, JNI, and serialization all break the closed-world assumption unless you provide explicit configuration metadata.

Reflection Configuration

Reflection is the biggest compatibility hurdle. When your code (or a library) uses Class.forName(), getDeclaredMethods(), or instantiates classes by name, the native image compiler cannot know which classes are involved at build time.

The solution is reflect-config.json — a metadata file that declares which classes need reflection support:

// src/main/resources/META-INF/native-image/reflect-config.json
[
  {
    "name": "com.example.dto.OrderResponse",
    "allDeclaredConstructors": true,
    "allDeclaredFields": true,
    "allDeclaredMethods": true
  },
  {
    "name": "com.fasterxml.jackson.databind.ObjectMapper",
    "allDeclaredConstructors": true,
    "allPublicMethods": true
  },
  {
    "name": "com.example.config.AppConfig",
    "allDeclaredConstructors": true,
    "allDeclaredFields": true
  }
]

Similarly, JNI, resources, and dynamic proxies need their own config files:

  • jni-config.json — JNI method declarations
  • resource-config.json — classpath resources accessed via getResourceAsStream()
  • proxy-config.json — dynamic proxy interfaces (Proxy.newProxyInstance())
  • serialization-config.json — serializable classes
Note: Spring Boot 3 generates most of this config automatically through its AOT processing phase (mvn spring-boot:process-aot). For Spring apps you rarely need to write reflect-config.json by hand — Spring does it for you. Manual config is mainly needed for non-Spring libraries.

Build-Time vs Runtime Initialization

Native image can initialize classes at build time (static initializers run during compilation, result is snapshotted into the binary) or at runtime (normal behavior). Build-time initialization speeds up startup but can break classes that open sockets, read files, or depend on runtime state in their static initializers.

# Force a class to initialize at runtime (default for most things)
native-image --initialize-at-run-time=com.example.SomeClass

# Force build-time initialization for a package
native-image --initialize-at-build-time=com.example.utils

# In native-image.properties (cleaner for Spring Boot)
# src/main/resources/META-INF/native-image/native-image.properties
Args = --initialize-at-run-time=io.netty.channel.epoll.Epoll \
       --initialize-at-run-time=io.netty.channel.unix.IovArray

Spring Boot Native Image Build

Spring Boot 3 + the GraalVM build tools plugin is the easiest path to a native Spring application. The AOT processing step runs at Maven build time and generates all necessary reflection/resource configs.

# Prerequisites: GraalVM JDK 21+ installed
# Verify: java -version should show GraalVM
java -version
# GraalVM CE 21.0.2 (build 21.0.2+13-jvmci-23.1-b30)

# Step 1: process AOT (generates src/main/generated/ sources + META-INF configs)
mvn spring-boot:process-aot

# Step 2: compile native binary (~3-8 minutes, CPU-intensive)
mvn -Pnative native:compile

# Binary is at: target/your-app-name (Linux/Mac) or target/your-app-name.exe (Windows)
./target/myapp
# Started MyApplication in 0.052 seconds (process running for 0.071)
// Hint annotations for AOT — add to any @Configuration class
@Configuration
@ImportRuntimeHints(MyRuntimeHints.class)
public class AppConfig {
    // ...
}

// Register hints programmatically
public class MyRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Register reflection
        hints.reflection().registerType(MyDto.class,
            MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
            MemberCategory.DECLARED_FIELDS);
        // Register resources
        hints.resources().registerPattern("templates/*.html");
        // Register serialization
        hints.serialization().registerType(MySerializableClass.class);
    }
}

Docker Multi-Stage Build

For CI/CD pipelines, a Docker multi-stage build produces a minimal native container without requiring GraalVM installed locally:

# Dockerfile — multi-stage native build
FROM ghcr.io/graalvm/native-image-community:21 AS builder

WORKDIR /app
COPY . .

# Build native binary inside the container
RUN ./mvnw -Pnative -DskipTests native:compile

# --- runtime stage: tiny distroless image ---
FROM gcr.io/distroless/base-debian12

WORKDIR /app
COPY --from=builder /app/target/myapp /app/myapp

EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
# Build and run
docker build -t myapp-native .
docker run -p 8080:8080 myapp-native

# Image size comparison:
# JVM image (eclipse-temurin:21-jre): ~350 MB
# Native image (distroless):          ~75 MB

Cold Start Benchmarks

MetricJVM (JDK 21)Native Image
Cold start time (Spring Boot REST app)~1.8–2.5 s~40–80 ms
Heap memory at idle~250 MB~55 MB
Docker image size~350 MB~70–90 MB
Build time~10 s~3–8 min
Peak throughput (warmed up)Higher (JIT optimizes hot paths)Lower (~10–30% for CPU-heavy work)
AWS Lambda cold start (512 MB)~3–5 s~100–200 ms
Tip: Native image wins on startup and idle memory. JVM wins on peak throughput for long-running, CPU-intensive workloads because JIT profiling produces highly optimized machine code over time. For API servers with mixed workloads, the difference in throughput is usually within 5–15%.

Native Image Limitations

  • Dynamic class loading: Class.forName() with a runtime-computed string fails unless the class is listed in reflect-config.
  • JNDI and RMI: not supported. Avoid frameworks that depend on JNDI lookups (older app servers).
  • Java agents: most bytecode-manipulation agents (e.g., JRebel, some APM agents) cannot attach to native binaries.
  • Serialization: Java serialization works only for classes declared in serialization-config.json.
  • Finalizers: Object.finalize() is not called. Use Cleaner API instead.
  • Build time: 3–8 minute builds are painful for tight dev loops. Use JVM mode for development and native only for release builds.

Framework Comparison: Spring Native vs Quarkus vs Micronaut

FeatureSpring Boot NativeQuarkusMicronaut
Native image supportYes (Boot 3+)Yes (first-class)Yes (first-class)
Cold start (simple REST app)~70 ms~30 ms~25 ms
Memory at idle (native)~55 MB~35 MB~30 MB
AOT approachSpring AOT (build-time bean processing)Quarkus extension modelCompile-time DI (no reflection)
Ecosystem / library supportLargest (Spring ecosystem)Large (Quarkus extensions)Good
Developer experienceFamiliar Spring styleHot reload (dev mode)AOT-first design
Best forMigrating existing Spring appsNew cloud-native microservicesMicroservices, serverless, CLI

Tracing Agent for Config Generation

The GraalVM tracing agent runs your application on the JVM while monitoring all reflection, JNI, and resource access. It produces the config files automatically — the most reliable way to handle complex third-party libraries.

# Run your app with the tracing agent attached
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar target/myapp.jar

# Exercise your app: make HTTP calls, trigger all code paths
curl http://localhost:8080/api/users
curl http://localhost:8080/api/orders
# ... run your integration test suite here

# Stop the app — agent writes these files:
# reflect-config.json
# jni-config.json
# resource-config.json
# proxy-config.json
# serialization-config.json

# Now build with native:
mvn -Pnative native:compile
Note: The tracing agent can over-include — it captures everything accessed during the run, including test-only code paths. Review the generated configs and prune entries that only appear in tests. Commit the cleaned configs to source control so native builds are reproducible in CI.

FAQ

Do I need GraalVM CE or Oracle GraalVM?
GraalVM Community Edition (free, open-source) is sufficient for native image builds. Oracle GraalVM adds Profile-Guided Optimization (PGO) which can improve native image performance by 10–20% for CPU-heavy work. For most applications, CE is fine. Both are available as JDK distributions via SDKMAN or Homebrew.
Can I use native image with Spring Data JPA / Hibernate?
Yes. Spring Boot 3's AOT processing registers Hibernate entities and repositories automatically. You may need to annotate entity classes with @RegisterReflectionForBinding if you use custom types, but most standard JPA usage works out of the box.
How do I debug a native image application?
Build with -g flag to include debug symbols: mvn -Pnative native:compile -Dnative.debug=true. Then use GDB (Linux) or LLDB (Mac) to attach. For application-level debugging, structured logging with correlation IDs is more practical than a debugger for production native binaries.
Will native image work with my existing Spring Boot 2 application?
No. Native image with Spring requires Spring Boot 3+ and Java 17+. You must complete the Boot 2 → Boot 3 migration first (Jakarta EE namespace rename, Security 6 API changes). Plan for 1–3 days of migration work depending on codebase size before attempting native compilation.
Is profile-guided optimization (PGO) worth the effort?
For latency-sensitive production workloads, PGO can improve native image throughput by 10–20%. The workflow: build a PGO-instrumented binary, run your load test suite against it to collect profiles, then rebuild with --pgo=profiles/. This is only available in Oracle GraalVM (not CE). Most teams skip PGO until they have a native deployment working correctly first.