Skip to main content
Java

Spring Boot 3.x Native: GraalVM in Production

Ravinder··8 min read
JavaSpring BootGraalVMNative ImagePerformance
Share:
Spring Boot 3.x Native: GraalVM in Production

Spring Boot 3.0 shipped with first-class GraalVM Native Image support in November 2022. By 2026, the tooling has matured considerably. Build times have dropped, the AOT hint coverage in the Spring ecosystem is broader, and the number of teams running native images in production has grown from "experimenters" to "early majority." But the tradeoffs have not disappeared — they've just become better understood. This post is the realistic assessment.

What Native Image Actually Does

GraalVM Native Image performs ahead-of-time (AOT) compilation of your Java application into a platform-native executable. The resulting binary:

  • Contains no JVM — it embeds a substrate VM (SubstrateVM) instead.
  • Has all reachable code compiled to machine code at build time.
  • Starts in milliseconds because there is no JIT warmup.
  • Uses significantly less memory at steady state.

The cost: anything that requires dynamic runtime behavior — reflection, dynamic class loading, JNI, serialization of unknown types — must be declared at build time via configuration files or programmatic hints.

flowchart LR A[Spring Boot App Source] --> B[Spring AOT Processor] B --> C[Generated Source + Hints] C --> D[GraalVM native-image compiler] D --> E[Native Executable] F[JVM Runtime] -.->|JIT compilation at startup| G[JVM Executable] subgraph Build Time - minutes B C D end subgraph Runtime - milliseconds startup E end subgraph Runtime - seconds to JIT warmup G end style E fill:#2ecc71,color:#000 style G fill:#3498db,color:#fff style D fill:#e74c3c,color:#fff

Project Setup

Spring Boot 3.x with GraalVM requires the native profile and the Spring AOT plugin:

<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.1</version>
</parent>
 
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>
 
<build>
    <plugins>
        <plugin>
            <groupId>org.graalvm.buildtools</groupId>
            <artifactId>native-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

Build the native executable:

# Requires GraalVM JDK 21+ with native-image tool
./mvnw -Pnative native:compile
 
# Or build a container image without installing GraalVM locally
./mvnw -Pnative spring-boot:build-image

The container image approach uses Paketo Buildpacks and a GraalVM builder image. It's slower (15–20 minutes for a typical service) but removes the GraalVM installation requirement from developer machines.

The Startup and Memory Numbers

For a Spring Boot 3.4 service with web, JPA, and one database connection pool (HikariCP):

Mode Startup time RSS at idle RSS under 1000 req/s
JVM (Java 21, G1GC) 3.2 s 320 MB 480 MB
JVM (Java 21, virtual threads) 3.4 s 330 MB 490 MB
Native image 0.08 s 62 MB 85 MB

The startup time difference matters for:

  • Serverless functions where cold start is directly billed or user-visible.
  • Kubernetes scale-out where a new pod must be healthy before it receives traffic.
  • CLI tools built on Spring Shell — 3 seconds to start a command-line tool is unacceptable.

The memory difference matters for:

  • High-density deployments — if you run 200 pods per node, 62 MB vs 320 MB per pod is the difference between fitting on fewer nodes.
  • Spot/preemptible instances — smaller memory footprint allows smaller instance types.

The Reflection Tax

Reflection is the primary friction in native image builds. Spring uses reflection extensively for bean creation, dependency injection, and JPA entity mapping. The Spring AOT processor handles most of this automatically — it analyzes your @Configuration classes at build time and generates explicit bean instantiation code.

What it does not handle automatically: reflection that is data-driven at runtime. An example:

// This works fine in native image — Spring AOT generates explicit wiring
@Service
public class OrderService {
    private final OrderRepository repo;
 
    public OrderService(OrderRepository repo) {
        this.repo = repo;
    }
}
 
// This will fail in native image without a hint
String className = config.getString("processor.class");
Class<?> processorClass = Class.forName(className); // runtime class loading — FAILS
Object processor = processorClass.getDeclaredConstructor().newInstance();

Fix it with a RuntimeHintsRegistrar:

@Component
@ImportRuntimeHints(OrderProcessorHints.class)
public class OrderProcessorConfig { ... }
 
public class OrderProcessorHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Register all possible processor classes
        hints.reflection()
            .registerType(StandardOrderProcessor.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)
            .registerType(PriorityOrderProcessor.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
    }
}

Or use @RegisterReflectionForBinding for serialization-related reflection:

@SpringBootApplication
@RegisterReflectionForBinding({OrderEvent.class, PaymentEvent.class})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Common Build Failures and Fixes

Problem: ClassNotFoundException or NoSuchMethodException at runtime in native image.
Cause: Reflective access to a class or method not declared in hints.
Fix: Use the GraalVM agent to collect hints automatically:

# Run with the tracing agent — generates reflection-config.json
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar target/app.jar
 
# Then exercise all code paths (run your integration test suite)
# The agent captures every reflective access and serialization operation

The generated reflect-config.json, resource-config.json, and serialization-config.json files go into META-INF/native-image on the classpath and are picked up automatically by the native-image compiler.

Problem: JPA / Hibernate fails in native image.
Cause: Hibernate uses extensive bytecode generation and reflection.
Fix: Hibernate 6.2+ has native image support built in. Declare your entity classes:

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
 
    // Spring AOT + Hibernate native hints handle the rest
    // as long as you're on Hibernate 6.2+
}

Add to application.properties:

spring.jpa.properties.hibernate.bytecode.provider=none

Disabling Hibernate's bytecode enhancement loses lazy loading proxies — use FetchType.LAZY at the query level instead.

Build Time Reality

Native image compilation is expensive:

Project size AOT processing native-image compile Total build
Small (5 dependencies) 12 s 2 min ~2.5 min
Medium (30 dependencies) 45 s 7 min ~9 min
Large (80 dependencies) 2 min 18 min ~21 min

This means:

  • Native image is not for your inner development loop. Develop and test on JVM, build native in CI only.
  • Incremental native builds do not exist — every change triggers a full recompile.
  • CI caching of the GraalVM toolchain and dependency JARs helps but does not eliminate the AOT compilation time.

Practical CI workflow:

# .github/workflows/build.yml (excerpt)
jobs:
  jvm-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - run: ./mvnw test  # fast, full test coverage
 
  native-build:
    runs-on: ubuntu-latest
    needs: jvm-tests  # only build native after JVM tests pass
    steps:
      - uses: actions/checkout@v4
      - uses: graalvm/setup-graalvm@v1
        with: { version: 'latest', java-version: '21' }
      - run: ./mvnw -Pnative native:compile -DskipTests  # tests already ran
      - run: ./mvnw -Pnative -Dskip.unit.tests test  # native integration tests

When JIT Still Wins

Native image is not always the right answer. The JIT compiler has a critical advantage: it knows your actual runtime profile and optimizes for it. Optimizations that the JIT applies at steady state that AOT cannot:

  • Speculative devirtualization: if 99% of calls to an interface go to one implementation, the JIT inlines it. AOT must compile all possible implementations.
  • Dead code elimination based on runtime configuration: the JIT can eliminate paths that are never taken. AOT compiles everything reachable.
  • Profile-guided optimization: the JIT recompiles hot paths with knowledge of actual data shapes.

For throughput-sensitive services that run continuously:

Workload JVM (warmed JIT) Native image
JSON API, 10 ms avg latency 12,400 req/s 9,800 req/s
Compute-heavy, CPU-bound 100% (baseline) 70–80%
I/O-heavy, virtual threads 11,200 req/s 10,100 req/s

A warmed JIT on a sustained workload beats native image throughput by 15–30%. If your service runs 24/7 and cold start time is irrelevant, the JVM wins on raw throughput.

Use native image for:

  • Serverless functions, CLI tools, scale-to-zero workloads
  • Memory-constrained deployments
  • Services where start-up time is a user-visible latency (e.g., init containers)

Stick with JVM for:

  • Long-running services with high throughput requirements
  • Applications with extensive dynamic reflection that is difficult to hint
  • Services using libraries without native image compatibility (check GraalVM reachability metadata repository first)

Key Takeaways

  • Spring Boot 3.x with GraalVM native image delivers 40–80 ms startup times and 60–80% RSS reduction compared to JVM mode — wins that are decisive for serverless and scale-to-zero workloads.
  • The reflection tax is real but manageable: Spring AOT handles standard dependency injection automatically; data-driven reflection requires explicit RuntimeHintsRegistrar declarations.
  • Use the GraalVM tracing agent against your integration test suite to capture runtime hints for code paths that AOT analysis misses.
  • Build times for large native image projects run 15–20 minutes; keep the JVM path for the development loop and run native only in CI.
  • A warmed JIT consistently beats native image throughput by 15–30% — if your service runs continuously and cold start is irrelevant, the standard JVM is still the right runtime.
  • Check the GraalVM reachability metadata repository before committing to native; library support varies, and Hibernate 6.2+, Jackson 2.15+, and most Spring ecosystem libraries are well-covered.