Skip to main content
spring boot the mechanics of magic

AOT and Native Images

9 min read Chapter 24 of 24
Summary

This section examines Ahead-of-Time (AOT) compilation and native...

This section examines Ahead-of-Time (AOT) compilation and native images using GraalVM Native Image, focusing on optimizing build and deployment for speed, isolation, and production fidelity. It introduces the Closed World Assumption, where all code must be known at build time, enabling reachability analysis and dead code elimination. Spring AOT processing transforms @Configuration classes and bean definitions to replace runtime reflection with generated code, activated via spring.aot.enabled. Key comparisons show native images start in milliseconds with lower memory footprints versus JVM's seconds and higher overhead. Limitations include reflection, dynamic proxies, and resource loading requiring explicit configuration via JSON files or the RuntimeHints API. A thought experiment evaluates using native images for the LogisticsCore CLI tool, weighing faster startup and lower memory against build time and dynamic feature constraints. The section provides code examples demonstrating reflective calls, AOT-generated bean definitions, benchmarks, and proxy hints, alongside diagrams and a comparison table of JVM vs. Native Image characteristics.

AOT and Native Images

The logistics of delivering software involve not just the coding but also the processes that ensure the code is reliable, efficient, and properly packaged for deployment. In this section, we examine the mechanics of Ahead-of-Time (AOT) compilation and native images, with a focus on optimizing the build and deployment lifecycle for speed, isolation, and production fidelity using GraalVM Native Image.

Introduction to AOT Compilation and Native Images

GraalVM Native Image is a technology that compiles Java applications ahead-of-time into a standalone native executable. This process, known as AOT compilation, provides fast startup, low memory footprint, and reduced packaging size compared to JVM-based execution. The core principle behind Native Image is the Closed World Assumption, which assumes that all code that will ever be executed is known at build time. This allows the native image builder to perform aggressive static analysis and dead code elimination.

Example: Demonstrating the Closed World Assumption with a Reflective Call

// Example: Demonstrating the Closed World Assumption with a reflective call that fails in Native Image.
package com.logistics.core.nativeimage;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.lang.reflect.Method;

@SpringBootApplication
public class LogisticsCoreApplication implements CommandLineRunner {

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

    @Override
    public void run(String... args) throws Exception {
        // This reflective call works on the JVM but will fail in a native image
        // unless configured via reflect-config.json or RuntimeHints.
        Class<?> clazz = Class.forName("com.logistics.core.service.InventoryService");
        Method method = clazz.getMethod("getStockLevel", String.class);
        System.out.println("Reflectively accessed method: " + method.getName());
    }
}

// To make this work in Native Image, we need to provide a hint.
// Using Spring's RuntimeHints API (programmatic registration):
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.TypeHint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;

@Configuration
@ImportRuntimeHints(ReflectionHints.class)
class NativeConfiguration {}

class ReflectionHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Java 21: Using pattern matching for instanceof
        hints.reflection().registerType(
            TypeHint.built("com.logistics.core.service.InventoryService")
                .withMethod("getStockLevel", TypeHint.built(String.class))
        );
    }
}

Spring AOT Processing and Native Image Generation

Spring Boot 3.0+ provides built-in support for GraalVM Native Image generation through the Spring AOT and Native Build Tools plugins. The spring.aot.enabled property controls whether Spring AOT processing is activated during the build. Spring AOT analyzes @Configuration classes, bean definitions, and property bindings to replace runtime reflection with generated code, making the application more suitable for native image compilation.

Example: Spring AOT Processing Step During Build

// Example: Spring AOT processing step during build - Generated Bean Registration Code.
// Original @Configuration class:
package com.logistics.core.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false) // Lite mode is AOT-friendly
public class WarehouseConfig {
    
    @Bean
    public WarehouseRepository warehouseRepository() {
        return new InMemoryWarehouseRepository();
    }
    
    @Bean
    public InventoryService inventoryService(WarehouseRepository repo) {
        return new InventoryService(repo);
    }
}

// After AOT processing, Spring generates a class that registers bean definitions programmatically,
// eliminating the need for runtime reflection. The generated code uses Spring Framework's
// BeanDefinition API to describe beans statically.
package com.logistics.core.config;

import org.springframework.beans.factory.support.BeanDefinition;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.context.annotation.ImportAware;
import org.springframework.context.annotation.ConfigurationClassPostProcessor;

public class WarehouseConfig__BeanDefinitions {
    
    public static BeanDefinition warehouseRepositoryBeanDefinition() {
        RootBeanDefinition beanDef = new RootBeanDefinition(InMemoryWarehouseRepository.class);
        beanDef.setScope("singleton");
        beanDef.setLazyInit(false);
        return beanDef;
    }
    
    public static BeanDefinition inventoryServiceBeanDefinition() {
        RootBeanDefinition beanDef = new RootBeanDefinition(InventoryService.class);
        beanDef.setScope("singleton");
        beanDef.setLazyInit(false);
        beanDef.setAutowireMode(2); // AUTOWIRE_CONSTRUCTOR
        beanDef.getConstructorArgumentValues().addGenericArgumentValue("warehouseRepository");
        return beanDef;
    }
}

Comparing Startup Time and Memory Footprint

A native image typically starts in milliseconds, compared to seconds for a JVM application, due to the absence of JVM startup and JIT warmup. The memory footprint of a native image is generally lower than a JVM process because it includes only the reachable code and a minimal runtime.

Example: Comparing Startup Time and Memory Footprint Using a Simple Benchmark

// Example: Comparing startup time and memory footprint using a simple benchmark.
package com.logistics.core.benchmark;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;

@SpringBootApplication
public class NativeVsJvmBenchmark implements CommandLineRunner {
    
    public static void main(String[] args) {
        long start = System.nanoTime();
        SpringApplication.run(NativeVsJvmBenchmark.class, args);
        long end = System.nanoTime();
        System.out.printf("Startup time (JVM): %.3f seconds%n", (end - start) / 1_000_000_000.0);
    }

    @Override
    public void run(String... args) {
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
        long usedHeap = memoryBean.getHeapMemoryUsage().getUsed();
        long usedNonHeap = memoryBean.getNonHeapMemoryUsage().getUsed();
        System.out.printf("Memory usage - Heap: %d MB, Non-Heap: %d MB%n",
                usedHeap / (1024 * 1024), usedNonHeap / (1024 * 1024));
        // In a native image, memory usage would be measured using Runtime.getRuntime().totalMemory()
        // as JVM-specific MXBeans are not available.
    }
}

// For Native Image, the main method serves as the native executable entry point.
// The startup time is measured from process start to `run()` method execution.
// Expected results:
// - JVM: Startup ~2-5 seconds, Memory ~100-200 MB heap + JVM overhead.
// - Native: Startup ~0.05-0.1 seconds, Memory ~50-80 MB total (including runtime). 

Limitations with Dynamic Proxies and Workaround Using @ProxyHint

Dynamic proxies, whether JDK or CGLIB, have limitations in native images. Spring AOT can generate hints for many Spring internals, but custom dynamic proxies may require explicit configuration via proxy-config.json files or the @ProxyHint annotation.

Example: Limitations with Dynamic Proxies and Workaround Using @ProxyHint

// Example: Limitations with Dynamic Proxies and workaround using @ProxyHint.
package com.logistics.core.service;

import org.springframework.aot.hint.ProxyHint;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TransactionalService {
    
    @Transactional // This annotation triggers creation of a JDK Dynamic Proxy for the interface.
    public void performOperation() {
        // Business logic
    }
}

// If TransactionalService implements an interface, Spring will use a JDK Dynamic Proxy.
// For Native Image, we must declare the proxy hint.
@ImportRuntimeHints(TransactionalServiceHints.class)
class ServiceConfiguration {}

class TransactionalServiceHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Java 21: Using type references
        // Correct way using ProxyHint builder:
        hints.proxies().addProxy(
            ProxyHint.built()
                .forTypes(TransactionalService.class, org.springframework.transaction.interceptor.TransactionalProxy.class)
        );
    }
}

// For CGLIB proxies (class-based), Spring AOT typically replaces them with
// generated code, as CGLIB is not supported in native images.

JVM vs Native Image Startup Process

The following diagram illustrates the key differences in the startup process between JVM and native image executions:

Diagram: JVM vs Native Image Startup Process

[JVM Startup]

  1. Launch JVM Process
  2. Load JVM Runtime (libjvm)
  3. Parse JAR, Load Classes (Class Loading)
  4. Bytecode Verification
  5. Interpret Bytecode
  6. JIT Compilation (HotSpot) -> Native Code
  7. Application Logic Execution -> Time: Seconds -> Memory: High (JVM overhead + heap)

[Native Image Startup]

  1. Launch Native Executable
  2. Load Minimal Runtime (Substrate VM)
  3. Execute Pre-Compiled Native Code
  4. Application Logic Execution -> Time: Milliseconds -> Memory: Low (only reachable code + data)

Comparison Table of JVM vs Native Image Characteristics

The following table summarizes the key differences between JVM and native image executions:

Table: Comparison of JVM and Native Image Characteristics

AspectJVM (with JIT)GraalVM Native Image
Startup Time2-10 seconds (depends on app size)10-100 milliseconds
Memory FootprintHigh (JVM overhead ~50-100MB + heap)Low (~30-80MB total)
Peak PerformanceHigh (after JIT warmup)Good, but may be lower than JIT-optimized code
Build TimeFast (just packaging)Slow (AOT compilation, analysis)
Executable SizeSmall JAR + JRELarger standalone binary
ReflectionFully dynamic, no configuration neededRequires explicit configuration (hints)
Dynamic Class LoadingFully supportedNot supported (Closed World)
Dynamic ProxiesRuntime generation (JDK/CGLIB)JDK proxies configurable, CGLIB not supported
Resource LoadingDynamic via classpath scanRequires resource-config.json
DebuggingStandard Java debugging toolsLimited, requires native debuggers
Monitoring (JMX)Full supportLimited or requires custom agent
Profile-Guided Optimization (PGO)JIT does this dynamicallyPossible via separate PGO build step

Thought Experiment: Evaluating Native Image for LogisticsCore

Scenario: The LogisticsCore team is considering building a native image for their warehouse management CLI tool. The tool is invoked periodically for batch processing (e.g., nightly inventory reconciliation). Currently, startup takes several seconds on the JVM, and memory usage is substantial. The team wants faster startup and lower memory to run more frequent, smaller batches.

Considerations:

  1. Closed World Analysis: The application uses Spring Data JPA with Hibernate. Hibernate relies heavily on reflection and bytecode generation (proxies). The native image builder will not automatically analyze all entity mappings and query methods without explicit hints.
  2. Spring AOT: Enable Spring AOT (spring.aot.enabled=true). This will generate hints for many Spring internals, but may not cover custom repository queries or dynamic JPA metamodel usage.
  3. Proxy Issues: The application uses @Transactional on service methods. JDK Dynamic Proxies will be needed. Spring AOT should generate proxy-config.json hints. However, if any bean uses CGLIB proxying (e.g., @Configuration(proxyBeanMethods=true)), those configurations must be switched to lite mode (proxyBeanMethods=false).
  4. Resource Loading: The app loads application.properties and some XML mapping files. These need to be listed in resource-config.json. The tracing agent can help discover these.
  5. Build Time Impact: The native image build might take several minutes versus seconds for a JAR. CI/CD pipeline needs adjustment.
  6. Runtime Behavior: The native executable will start in tens of milliseconds and use significantly less memory. However, if a new entity class is added, the native image must be rebuilt. Dynamic class loading is impossible.
  7. Trade-off Decision: For a batch CLI tool where startup time is critical and the codebase is relatively stable, native image offers clear benefits. For a long-running server where peak throughput is key, JVM may be better.

Conclusion: The team should proceed with a proof-of-concept, using the GraalVM tracing agent to generate initial hints, enabling Spring AOT, and testing the native executable with their batch jobs.