AOT and Native Images
SummaryThis section examines Ahead-of-Time (AOT) compilation and native...
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]
- Launch JVM Process
- Load JVM Runtime (libjvm)
- Parse JAR, Load Classes (Class Loading)
- Bytecode Verification
- Interpret Bytecode
- JIT Compilation (HotSpot) -> Native Code
- Application Logic Execution -> Time: Seconds -> Memory: High (JVM overhead + heap)
[Native Image Startup]
- Launch Native Executable
- Load Minimal Runtime (Substrate VM)
- Execute Pre-Compiled Native Code
- 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
| Aspect | JVM (with JIT) | GraalVM Native Image |
|---|---|---|
| Startup Time | 2-10 seconds (depends on app size) | 10-100 milliseconds |
| Memory Footprint | High (JVM overhead ~50-100MB + heap) | Low (~30-80MB total) |
| Peak Performance | High (after JIT warmup) | Good, but may be lower than JIT-optimized code |
| Build Time | Fast (just packaging) | Slow (AOT compilation, analysis) |
| Executable Size | Small JAR + JRE | Larger standalone binary |
| Reflection | Fully dynamic, no configuration needed | Requires explicit configuration (hints) |
| Dynamic Class Loading | Fully supported | Not supported (Closed World) |
| Dynamic Proxies | Runtime generation (JDK/CGLIB) | JDK proxies configurable, CGLIB not supported |
| Resource Loading | Dynamic via classpath scan | Requires resource-config.json |
| Debugging | Standard Java debugging tools | Limited, requires native debuggers |
| Monitoring (JMX) | Full support | Limited or requires custom agent |
| Profile-Guided Optimization (PGO) | JIT does this dynamically | Possible 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:
- 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.
- 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. - Proxy Issues: The application uses
@Transactionalon service methods. JDK Dynamic Proxies will be needed. Spring AOT should generateproxy-config.jsonhints. However, if any bean uses CGLIB proxying (e.g.,@Configuration(proxyBeanMethods=true)), those configurations must be switched to lite mode (proxyBeanMethods=false). - Resource Loading: The app loads
application.propertiesand some XML mapping files. These need to be listed inresource-config.json. The tracing agent can help discover these. - Build Time Impact: The native image build might take several minutes versus seconds for a JAR. CI/CD pipeline needs adjustment.
- 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.
- 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.