GraalVM Native Images: Millisecond Startup

A regular Spring Boot application takes 2–10 seconds to start. A GraalVM native image of the same application starts in under 100 milliseconds. For serverless functions, batch jobs, and CLI tools, this is the difference between viable and unusable.

What Is a Native Image?

GraalVM’s native image compiler performs ahead-of-time (AOT) compilation. Instead of shipping a JAR that the JVM interprets at runtime, you ship a standalone executable that:

  • Contains only the code your application actually uses
  • Has no JVM startup overhead
  • Uses much less memory (no JIT compiler, no class metadata)
  • Starts in milliseconds

The tradeoff: compile time increases from seconds to minutes. And the JVM’s dynamic features (reflection, dynamic proxies, runtime class loading) must be declared at compile time.

Spring Boot AOT Support

Spring Boot 3+ includes built-in AOT support that handles most of the native image compatibility automatically:

<!-- pom.xml -->
<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>

Spring Boot’s parent POM includes the native plugin. You just need to activate it.

Setup

Install GraalVM:

# Install via SDKMAN (recommended)
sdk install java 21.0.3-graalce
sdk use java 21.0.3-graalce

# Verify
java -version
# java version "21.0.3" 2024-04-16 LTS
# Java(TM) SE Runtime Environment GraalVM CE 21.0.3+7.1

Or use Docker (no local installation needed):

# Build native image in Docker
docker build -f Dockerfile.native -t order-service:native .

Building the Native Image

# Maven — build native executable
./mvnw native:compile -Pnative

# Output: target/order-service (single executable, ~60–100MB)

Or build a native Docker image using Cloud Native Buildpacks:

./mvnw spring-boot:build-image -Pnative
# Creates: docker.io/library/order-service:0.0.1-SNAPSHOT (based on paketobuildpacks/native-image)

Running the native executable:

./target/order-service
# Started OrderServiceApplication in 0.081 seconds (process running for 0.106)

Spring Boot AOT Processing

When you build natively, Spring runs AOT processing at build time:

Build time:
  Source code → AOT processor → Generated Java/Kotlin sources → native-image compiler → executable

Runtime:
  executable → application (no Spring context initialization overhead)

The AOT processor:

  • Analyzes your bean definitions and configuration
  • Generates explicit bean factories (no more reflection)
  • Detects required reflection metadata (JPA entities, Jackson DTOs)
  • Generates reflect-config.json, proxy-config.json, resource-config.json

This handles ~90% of native image compatibility automatically. The remaining 10% requires manual configuration.

Common Compatibility Issues

Reflection

GraalVM cannot see runtime reflection unless declared:

// Spring Boot AOT handles @Component, @Service, @Entity automatically.
// But third-party libraries that use reflection may need hints.

@Configuration
public class NativeHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Register a class for reflection
        hints.reflection()
            .registerType(PaymentGatewayResponse.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.DECLARED_FIELDS);

        // Register resources
        hints.resources()
            .registerPattern("payment-schema.json");

        // Register JDK proxies
        hints.proxies()
            .registerJdkProxy(PaymentGateway.class);

        // Register serialization
        hints.serialization()
            .registerType(OrderEvent.class);
    }
}

// Register the registrar
@ImportRuntimeHints(NativeHints.class)
@Configuration
public class AppConfig { }

@NativeHint Annotations

Spring Boot provides annotation-based hints:

@Entity
@Table(name = "orders")
public class Order {
    // Spring AOT registers JPA entities automatically
}

// For custom serialization:
@JsonSerialize
@Reflective   // Spring Boot 3 — marks for reflection registration
public record OrderEvent(UUID orderId, String type, Instant timestamp) {}

Testing Native Compatibility

Run a simulation of native image behavior without building the native image:

@SpringBootTest
@TestPropertySource(properties = "spring.aot.enabled=true")
class NativeCompatibilityTest {
    // Tests run in AOT mode — catches most compatibility issues
}

Or run actual native tests:

./mvnw test -PnativeTest
# Builds native test executable and runs it

Application Properties for Native

# application.yml
spring:
  aot:
    enabled: true    # enable AOT processing (automatic in native builds)

  jpa:
    # Use 'create-drop' in test, 'validate' in prod — same as JVM mode
    hibernate:
      ddl-auto: validate

  # Hibernate needs reflection hints for entity classes
  # Spring Boot handles this automatically for @Entity classes

JPA with Native Images

JPA works natively with Spring Boot 3+, but requires Hibernate 6.2+ (included in Spring Boot 3.2+):

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Lombok @Getter, @Setter work in native images
    // JPA proxies for lazy loading are generated at build time
}

Lazy loading works in native images — Spring Boot generates the necessary Hibernate bytecode enhancement at build time instead of runtime.

Memory Comparison

ModeStartupHeap (idle)Heap (under load)Container size
JVM3–8s200–400MB500MB–2GB200MB JRE + JAR
Native50–100ms30–80MB100–400MB60–100MB executable

Native images use significantly less memory at idle — critical for scale-to-zero serverless workloads.

Dockerfile for Native Images

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

WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -q

COPY src/ src/
RUN ./mvnw native:compile -Pnative -q

# Minimal runtime image — no JVM needed
FROM debian:bookworm-slim

WORKDIR /app
COPY --from=builder /app/target/order-service .

EXPOSE 8080
ENTRYPOINT ["./order-service"]

Or use distroless for an even smaller image:

FROM gcr.io/distroless/base-debian12

COPY --from=builder /app/target/order-service /order-service
ENTRYPOINT ["/order-service"]

Final image size: ~80MB vs ~250MB for a JVM-based image.

Lambda / Serverless

Native images are ideal for AWS Lambda (cold start is the main pain point with JVM):

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-function-adapter-aws</artifactId>
</dependency>
@SpringBootApplication
public class OrderFunctionApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderFunctionApplication.class, args);
    }

    @Bean
    public Function<OrderRequest, OrderResponse> processOrder(OrderService service) {
        return request -> service.process(request);
    }
}
./mvnw native:compile -Pnative
# Deploy target/order-function to Lambda (no JVM layer needed)
# Cold start: ~100ms vs 8-15s for JVM

When to Use Native Images

Good candidates:

  • CLI tools and batch jobs (millisecond startup, low memory)
  • Serverless functions (Lambda, Cloud Run, Azure Functions)
  • Microservices with scale-to-zero requirements
  • Container workloads with tight memory limits

Not ideal for:

  • Long-running services with significant warm-up time (JVM JIT eventually beats native throughput)
  • Applications with heavy dynamic class loading or reflection (e.g., Groovy scripts, dynamic plugins)
  • Rapid development cycles (3–5 minute native compile vs 10s JVM compile)

For most production services: JVM with virtual threads is the better default. Consider native when cold start or memory matters specifically.

What You’ve Learned

  • GraalVM native images start in under 100ms and use half the memory of JVM mode
  • Spring Boot 3+ AOT processing handles reflection, proxies, and JPA entities automatically
  • Use RuntimeHintsRegistrar to declare reflection or resources for third-party libraries
  • Test native compatibility with @TestPropertySource(properties = "spring.aot.enabled=true")
  • ./mvnw native:compile -Pnative builds the executable; spring-boot:build-image -Pnative builds a container
  • Native images are ideal for serverless and CLI tools; JVM is better for long-running, throughput-optimized services

This completes Part 7: Performance. You now have the tools to make Spring Boot applications fast — both response-time optimizations (JPA, caching, async) and deployment optimizations (native images, virtual threads).


Next: Part 8 — Messaging and Event-Driven Architecture starts with Article 42: Introduction to Messaging with Apache Kafka.