GraalVM Native Image

Understand ahead-of-time compilation with GraalVM Native Image and the Substrate VM runtime for near-instant startup and minimal memory footprint.

published: reading time: 30 min read author: GeekWorkBench

GraalVM Native Image

Traditional JVM applications pay a startup penalty: the JVM must launch, load classes, interpret bytecode, and warm up the JIT compiler before reaching peak performance. For short-lived processes, serverless functions, and containerized workloads where instances scale rapidly, this warmup period translates directly to cost and latency. GraalVM Native Image addresses this by compiling Java applications ahead-of-time (AOT) into standalone native executables that start instantly and have a dramatically smaller memory footprint.

Introduction

GraalVM Native Image is an ahead-of-time (AOT) compilation technology that transforms Java bytecode into native executables. Rather than relying on a JVM at runtime, Native Image embeds a minimal Substrate VM runtime alongside the compiled application code, giving you a self-contained binary that starts in milliseconds instead of seconds. This is particularly valuable in environments where cold start latency drives cost — serverless platforms, containerized microservices that scale horizontally, and CLI tools where users notice every delay.

The technology matters because modern cloud architecture increasingly penalizes slow startup. Serverless functions are billed per millisecond of execution, and Kubernetes-based services often scale in response to load within seconds. A JVM cold start can take several seconds; a Native Image binary starts in tens of milliseconds. The memory footprint drops proportionally — a typical Spring Boot application might consume 200-400MB RSS as a JVM process, but the same application as a Native Image often runs in 50-80MB RSS. These gains come with tradeoffs: peak throughput for long-running workloads is typically lower than JIT-compiled JVM code, and dynamic features like reflection require upfront configuration.

This post covers how Native Image works under the hood, the build process and configuration files you need, trade-offs around memory and performance, common production failure scenarios, and practical implementation guidance for getting a Native Image build working in practice.

When to Use Native Image

Native Image excels in scenarios where startup time and memory consumption are critical:

  • Serverless functions — AWS Lambda, Azure Functions, Google Cloud Functions charge by execution time and memory; instant startup directly reduces bills
  • Containerized microservices — Kubernetes pods that scale rapidly benefit from near-instant readiness
  • CLI tools — Developer tools written in Java should start in milliseconds, not seconds
  • Embedded systems — Resource-constrained environments cannot afford a full JVM
  • Short-lived jobs — Batch jobs that process small inputs and exit quickly benefit from no warmup

When Not to Use Native Image

Native Image is not always the right choice:

  • Long-running servers — Applications that run for hours or days warm up the JIT and reach peak performance; the JIT often produces faster code than AOT for sustained workloads
  • Heavy reflection usage — Dynamic features require additional configuration and increase image size
  • Dynamic class loading — Applications that load plugins at runtime need extra setup for native image
  • Applications requiring latest JVM features — Native Image may lag behind the latest JVM releases

How Native Image Works

Native Image performs static analysis of your application at build time. It starts from your main entry point and recursively traces all reachable code — classes, methods, resources — and eliminates everything that is not used. This “closed-world assumption” enables aggressive optimization but requires upfront configuration for dynamic features.

flowchart TD
    subgraph BuildTime["Native Image Build Time"]
        A["Java Source Code"] --> B[Build Process]
        B --> C["Static Analysis\nTrace all reachable code"]
        C --> D["Heap Snapshot\nPreinitialize reachable classes"]
        D --> E["Native Code Generation\nCompile to ELF/PE/Mach-O"]
    end

    subgraph Runtime["Runtime (Substrate VM)"]
        F["Native Executable"]
        F --> G["No JVM needed"]
        F --> H["Instant startup"]
        F --> I["Minimal heap\nPre-initialized objects"]
    end

Build Process Phases

  1. Initialization — Native Image launches a JVM to run its own builder, which loads your application classes
  2. Static analysis — Starting from the entry point, the analyzer traces every reachable method, field, and type. It constructs a complete graph of what the application needs
  3. Heap snapshotting — All objects that exist at build time (static fields, string constants) are serialized into the image heap
  4. Native code generation — GraalVM compiles all reachable bytecode to native machine code
  5. Image linking — The compiled code, image heap, and Substrate VM runtime are linked into a single executable

The Substrate VM

The Substrate VM is a minimal runtime included in every Native Image. Unlike a full JVM, it does not include:

  • JIT compiler (all compilation is ahead-of-time)
  • Garbage collector (it uses a simple, single-threaded collector optimized for short-lived processes)
  • Bytecode interpreter
  • Class loading (all classes are loaded at build time)

The Substrate VM does include:

  • Thread management
  • Stack frame management
  • Garbage collection (G1-like, single-threaded)
  • Reflection implementation (preconfigured at build time)
  • Native image heap with preinitialized objects

Configuration Files

Because Native Image cannot discover dynamic features automatically, you must provide hints for reflection, resources, and dynamic proxies.

reflect-config.json

[
  {
    "name": "com.example.MyClass",
    "fields": [{ "name": "value", "type": "int" }],
    "methods": [
      { "name": "setValue", "parameterTypes": ["int"] },
      { "name": "getValue", "returnType": "int" }
    ]
  }
]

resource-config.json

{
  "resources": {
    "includes": [
      { "pattern": ".*\\.properties$" },
      { "pattern": ".*/messages/.*\\.xml$" }
    ],
    "excludes": [{ "pattern": ".*/debug/.*" }]
  }
}

proxy-config.json

[
  {
    "interfaces": ["java.lang.reflect.InvocationHandler"]
  }
]

Build-Time Initialization

Native Image runs static initializers (static {} blocks and field initializers) at build time by default. This delivers instant startup — the cost is paid once during the build, not at runtime.

public class Config {
    static final Map<String, String> SETTINGS = new HashMap<>();
    static {
        SETTINGS.put("host", "production.example.com");
        SETTINGS.put("port", "443");
        // This map is built at build time, not runtime
    }
}

In a traditional JVM, this static block runs the first time the class is loaded at runtime. In Native Image, it runs during the build, and the populated map is stored directly in the image heap.

When Initialization Must Be Deferred

Some initialization must happen at runtime:

  • Reading environment variables that differ between environments
  • Accessing system properties that differ between builds
  • Building connections to external services
  • Thread creation that depends on runtime conditions

Use @Substitute and @InjectRuntimeInitialization to defer specific initializations.

Memory and Performance Characteristics

Native Image has a very different performance profile from JVM applications:

MetricJVMNative Image
Startup timeSeconds to minutesMilliseconds
Peak memory (heap)High (mutable, GC’d)Low (mostly static)
Total RSSHigherLower
JIT warmupHappens at runtimeNone — fully compiled
Peak throughputHigher (optimized JIT code)Lower (AOT optimizations limited)
Code cacheDynamicStatic, fixed

Memory Footprint

Native Image’s heap consists mostly of preinitialized objects from static fields. The GC does not need to allocate these at runtime — they are already in the image. This dramatically reduces the heap size needed for short-lived processes.

A typical Spring Boot application JVM process might use 200-400MB RSS at startup. The same application as a Native Image might use 50-80MB RSS.

Production Failure Scenarios

Missing Reflection Configuration

If your application uses reflection on types not listed in reflect-config.json, the Native Image throws a ClassNotFoundException or returns null from reflection lookups at runtime — even though the class exists in the source code.

Example: Using Class.forName() on a plugin class discovered at runtime:

// This works in JVM, fails in Native Image without configuration
String className = pluginRegistry.getPluginClass();
Class<?> clazz = Class.forName(className);  // Class not found in image

Fix: Add the plugin class to reflect-config.json, or use the agent to automatically generate configuration.

Missing Resources

Resources accessed via getResourceAsStream() must be listed in resource-config.json or bundled in the native image. Missing resources return null.

Static Initialization Accessing Runtime Data

If a static initializer reads a system property or environment variable that differs between build and runtime:

static final String ENV = System.getenv("DATABASE_URL");

This is evaluated at build time in Native Image. The value baked into the image may be wrong for the runtime environment.

Large Image Size

Applications with many dependencies produce large native executables. While the executable itself is typically smaller than a JVM distribution, it can still be tens or hundreds of megabytes. Use native-image --static for fully static linking, or -H:+DeadCodeElimination to remove more unused code.

Slow Build Time

Native Image builds are significantly slower than standard Java compilation — potentially minutes for large applications. This slows down CI/CD pipelines. Use native-image --patches to cache build artifacts, or use incremental build modes when available.

Failure Scenarios and Trade-Off Analysis

When AOT Produces Slower Peak Performance

For long-running server applications that warm up to stable hot paths, the JIT compiler’s runtime-informed speculative optimizations outperform AOT’s conservative compile-time decisions. The JIT observes actual receiver types, branch probabilities, and working set data that AOT cannot know. A Spring Boot application running for hours typically achieves 10-30% higher throughput under JIT than under AOT-compiled Native Image because the JIT can specialize aggressively for the observed workload profile. Native Image is designed for scenarios where startup and memory outweigh sustained throughput — the tradeoff is explicit and intentional.

Image Size Growth from Unchecked Dependencies

Every dependency included in a Native Image increases the final binary size. Unlike a JVM where unused classes are never loaded, Native Image statically includes all reachable code. A single large library with many code paths can inflate the image by tens of megabytes. Use native-image --static and -H:+DeadCodeElimination=aggressive to minimize bloat, and regularly audit included dependencies with nm or size tools on the resulting binary.

AspectJVM RuntimeNative Image
----------------------------------
StartupSlow (seconds)Instant (milliseconds)
MemoryHigher RSSLower RSS
Peak throughputHigher (JIT optimizations)Lower (AOT limitations)
WarmupHappens at runtimeNot needed
ReflectionWorks out of the boxRequires configuration
Dynamic classloadingSupportedLimited, requires configuration
GCAdvanced (G1, ZGC, Shenandoah)Simple, single-threaded
Build timeFastSlow
Binary portabilityHigh (.class files run anywhere)Low (per-OS binary)
Supports all JVM featuresYesNo (preview features may lag)

Implementation Snippets

Install GraalVM and Native Image

# Using SDKMAN
sdk install java 22.3.0.r8-grl
gu install native-image

# Using Homebrew (macOS)
brew install graalvm/tap/graalvm-ce@22

# Verify installation
native-image --version

Build a Basic Native Image

# Compile Java
javac -o MyApp.class MyApp.java

# Build native image (simple mode — no configuration needed)
native-image MyApp

# Run the native executable
./myapp

Build a Spring Boot Application

# With Maven
./mvnw native:compile -Pnative

# With Gradle
./gradlew nativeCompile

# Or use the Spring Boot plugin
./mvnw spring-boot:build-image  # Creates OCI image with native executable

Generate Reflection Configuration Automatically

# Run the agent to capture reflection usage during a test run
java -agentlib:native-image-agent=config-output-dir=./src/main/resources/ \
     -cp target/myapp.jar com.example.MyApplication

# Run your application through its normal usage patterns
# Then rebuild the native image
native-image -H:ReflectionConfigurationResources=./src/main/resources/reflect-config.json

Build with Custom Heap Size

# Set initial and maximum heap for the native image runtime
native-image -H:InitialHeapSize=64m -H:MaximumHeapSize=128m -H:+JVMCOptions myapp

# Or at runtime (overrides build-time settings)
./myapp -Xmx128m

Observability Checklist

When deploying Native Image in production:

  • Monitor image size — A bloated image indicates unused code is being included
  • Profile build time — Long build times affect CI/CD; use caching
  • Test in staging with production-like reflection — Agent-generated configs may miss dynamic paths
  • Verify resource bundling — Confirm required resources are in the image
  • Check GC behavior — The Substrate VM GC is simple; monitor for GC pressure
  • Profile with perf — Use Linux perf to see CPU hotspots in the native binary
  • Test with realistic workloads — Startup benefits are clear; verify throughput meets SLA

Security Notes

Native Image provides a smaller attack surface than a full JVM. The eliminated components — bytecode interpreter, JIT compiler, dynamic classloading — are also elimination of potential exploit vectors. However, Native Image does not eliminate all attack surface:

  • Native code vulnerabilities — The native executable is a native process and can be vulnerable to memory corruption exploits
  • Configuration errors — Missing reflection configuration may cause unexpected behavior, but typically manifests as crashes rather than security issues
  • Build environment trust — The build process runs on your infrastructure; ensure build environments are secured

For high-security workloads, use --static linking to avoid dynamic library dependencies, and consider using a minimal operating system base image in containers.

Common Pitfalls / Anti-Patterns

  1. Assuming all classes are included — Native Image only includes reachable classes. If reflection or dynamic proxies reference classes that are not in the static analysis path, they are excluded. Use the agent to discover the full set.

  2. Confusing build-time and runtime initialization — Static blocks run at build time. Code that depends on environment-specific values baked into static fields at build time behaves incorrectly at runtime.

  3. Not testing the native image — Always test the compiled native image, not just the JVM version. Behavior differences between JVM and Native Image are common and must be caught before deployment.

  4. Expecting JIT-level peak performance — GraalVM AOT compilation cannot apply speculative optimizations based on runtime profiles that a JIT compiler gathers. Peak throughput for long-running workloads is typically lower than JIT.

  5. Forgetting to update configuration after code changes — When you add new reflection calls or resources, you must regenerate the configuration and rebuild the image. Stale configuration causes runtime failures.

Quick Recap Checklist

  • Native Image compiles Java to native executables ahead-of-time
  • Instant startup (milliseconds) — no JVM launch, no interpretation, no JIT warmup
  • Lower memory footprint — preinitialized objects live in the image heap
  • Closed-world assumption — all reachable code must be determined at build time
  • Reflection, resources, and dynamic proxies require configuration files
  • Use the native-image agent to auto-generate reflection configuration
  • Build time is significantly longer than JVM compilation
  • Peak throughput may be lower than JIT for long-running workloads
  • The Substrate VM replaces the JVM runtime with a minimal alternative
  • --static linking produces a fully self-contained executable

Interview Questions

1. How does Native Image achieve near-instant startup compared to a JVM application?

Native Image eliminates every phase of JVM startup that takes time. There is no JVM process to launch because the output is a native ELF/PE/Mach-O executable. There are no classes to load because every class the application ever uses is compiled and linked at build time. There is no interpreter because all bytecode is precompiled to native machine code. There is no JIT warmup because all optimization decisions are made at build time. The only remaining work is starting the Substrate VM runtime — a minimal threading and GC system — and jumping to the main method. This takes milliseconds instead of the seconds a JVM needs for its initialization phases.

2. What is the closed-world assumption in Native Image?

Native Image requires that all code reachable at runtime be determinable at build time. Starting from the main entry point, the static analyzer traces through every method call, field access, and type reference it can find. Any code not reachable through this trace is excluded from the final image. This enables aggressive optimizations — dead code elimination, aggressive inlining, removal of dynamic dispatch — but it means features that work at runtime in a JVM (dynamic class loading, runtime bytecode generation, reflection on unknown types) require explicit configuration or become unavailable. The closed-world assumption is what makes AOT compilation possible and what makes Native Image different from a traditional JVM.

3. Why might a Native Image application have lower peak performance than a JVM application?

The JIT compiler in a JVM has a crucial advantage over AOT compilation: it observes actual runtime behavior. It sees the real types flowing through a call site, which branch is taken most often, which methods are in the real hot path, and what the actual array lengths and object graphs look like. With this information, the JIT makes speculative optimizations that are only valid for the observed behavior. AOT compilation cannot make these speculative bets because it does not know the runtime profile. Additionally, JIT compilers can deoptimize and recompile when assumptions are violated, while Native Image cannot. For long-running applications that warm up to a stable profile, JIT's runtime-informed optimizations typically produce faster code than AOT's conservative compile-time optimizations.

4. How do you handle reflection in a Native Image application?

Reflection in Native Image requires upfront configuration because the static analyzer cannot discover which types will be reflected upon at runtime. You provide this configuration through JSON files — primarily reflect-config.json for Class.getDeclaredField and similar APIs, resource-config.json for resources accessed via getResourceAsStream, and proxy-config.json for dynamic proxy interfaces. The easiest approach is to run the native-image agent (-agentlib:native-image-agent) during testing — it observes reflection, resource, and proxy usage and automatically generates the configuration files. After generating the configuration, you rebuild the native image with those files included. Without this configuration, reflection on types not known at build time silently returns null or throws exceptions at runtime.

5. What is the Substrate VM and how does it differ from a traditional JVM?

The Substrate VM is the minimal runtime that Native Image links into every native executable. It replaces the traditional JVM runtime components with lightweight alternatives. The key differences: the Substrate VM has no JIT compiler (all compilation is AOT), no bytecode interpreter, no class loader (all classes are linked at build time), and a simple single-threaded GC instead of advanced collectors like G1 or ZGC. It does provide thread management, stack management, and implementations of reflection, native libraries, and other runtime features — but implemented in a way that is compatible with static analysis and closed-world compilation. The result is a runtime that starts instantly and uses minimal memory, at the cost of flexibility and advanced GC optimizations.

6. How does the Substrate VM's garbage collector differ from the G1GC or ZGC used in a traditional JVM, and what are the performance implications?

The Substrate VM uses a simple single-threaded garbage collector optimized for short-lived processes rather than the advanced multi-threaded collectors (G1, ZGC, Shenandoah) used in production JVMs. The Substrate GC is not incremental and does not have concurrent marking phases — it stops the world and collects everything in one pause. For short-lived workloads (serverless functions, CLI tools), this is acceptable because heap sizes are small and GC pauses are measured in milliseconds. For long-running services with large heaps, the simple collector would cause unacceptable stop-the-world pauses. The tradeoff reflects the Substrate VM's design goal: minimize startup latency and memory footprint for short-lived processes, not maximize throughput for sustained workloads. If you need better GC behavior in Native Image, you can configure the build to use a different GC algorithm, but this increases image size and initialization time.

7. What happens during the Native Image build process's static analysis phase, and why can it fail or produce incomplete results?

The static analysis phase, also called points-to analysis, starts from the application entry point and recursively traces all reachable code — methods, fields, types, resources. It builds a complete graph of everything the application needs at runtime. The analysis can fail or be incomplete for several reasons: reflection on types not known at build time produces no trace (the class is not included), dynamic proxy generation creates types not visible to static analysis, bytecode instrumentation libraries add code at runtime that the analyzer cannot see, and JNI calls to native libraries require manual configuration since the analyzer cannot trace native code. When the analyzer misses code, the resulting image silently fails at runtime — a method call throws a confusing exception or a reflected field returns null. Using the native-image agent during testing is the standard way to discover and fix these gaps before deployment.

8. How does build-time initialization affect environment-specific configuration in Native Image applications?

Native Image runs all static initializers and static field initializers at build time rather than runtime. This means if a static field reads an environment variable, a system property, or any runtime-only value, that value is captured and frozen into the image heap at build time. When the image runs in a different environment (different DATABASE_URL, different JVM options), it still uses the values from build time — causing failures that are difficult to diagnose because the symptom appears at runtime as if the wrong environment variable were set. The solution is to avoid reading environment-specific values in static initializers, and to defer initialization to runtime using annotations like @InjectRuntimeInitialization or by restructuring code to read configuration at startup rather than in static blocks. This is a fundamental difference from traditional JVM behavior that surprises developers who move existing applications to Native Image.

9. What is the difference between AOT (ahead-of-time) compilation and JIT (just-in-time) compilation, and why does AOT trade peak performance for startup speed?

JIT compilation occurs at runtime and observes actual execution behavior — which types flow through call sites, which branches are taken, what the working set looks like. This runtime data enables speculative optimizations that AOT cannot perform: the JIT assumes the observed behavior will continue and generates faster code for the common case. AOT compilation happens at build time with no runtime data — it must make conservative decisions that work for all cases, not optimized decisions for the observed case. This is why JIT-compiled code typically runs faster for long-running workloads: the JIT can specialize for the actual workload. AOT's advantage is startup: all compilation is done before the application runs, so there is no JIT warmup period. For short-lived processes, AOT wins because the startup savings outweigh the peak performance loss. For long-running servers that warm up to stable hot paths, JIT typically produces 10-30% higher throughput.

10. How does the native-image agent automatically generate reflection and resource configuration?

The native-image agent (-agentlib:native-image-agent) is a JVM agent that intercepts reflection calls, resource access, and proxy usage during test execution. As your application runs through its normal usage patterns, the agent records everything it sees and writes the corresponding JSON configuration files: reflect-config.json, resource-config.json, and proxy-config.json. The agent must be active during representative test runs — if you miss a code path in testing, its reflection usage will not be in the generated configuration. For web applications, this means running integration tests that exercise the API endpoints. For libraries, unit tests may not cover all reflection usage at runtime. The generated configuration is a starting point; you may need to manually add entries for dynamic paths the agent could not observe. Regenerate the configuration whenever you add new reflection calls or change dependencies.

11. What is the difference between the heap snapshot in Native Image and the JVM heap at runtime?

The Native Image heap snapshot is a preinitialized heap containing all objects that exist at build time — static field values, string constants, and objects created during static initializer execution. When the image starts, this snapshot is loaded directly into memory as a contiguous block, giving the application immediate access to initialized objects without runtime allocation. The JVM heap at runtime is dynamic — objects are allocated as needed and garbage collected when no longer reachable. In a JVM, static fields start as default values and are initialized at class loading time; in Native Image, they are already populated in the snapshot. The snapshot eliminates the "warmup" phase where static initializers run and populate the heap on first access. For large applications, the snapshot can be tens of megabytes, but it replaces what would otherwise be dynamic allocation and initialization cost at startup.

12. How does the Substrate VM handle thread management and stack frames compared to a traditional JVM?

The Substrate VM includes a thread management system similar to the JVM's: threads are created, scheduled, and managed by the runtime. Stack frames in the Substrate VM are simpler than in the JVM because there is no interpreter — all code is precompiled. Each thread has a native stack (on the OS), and the compiled code uses standard calling conventions for that platform. The key difference is that the Substrate VM does not have the complexity of deoptimization, OSR transitions, or safepoint polling in compiled code — since all code is statically compiled, there is no need for these JIT-era mechanisms. Thread coordination (locks, wait/notify) uses the same Java semantics but implemented through native synchronization primitives rather than JVM runtime services. For short-lived processes, the simpler stack model reduces overhead compared to JVM threads.

13. Why does Native Image produce platform-specific executables while JVM applications are portable?

Native Image compiles Java bytecode to native machine code for a specific target platform — Linux x86-64, Windows x86-64, macOS ARM64, etc. The output is an ELF/PE/Mach-O binary that runs directly on the OS and CPU without any JVM layer. This means you must build a separate image for each target platform, just like any native C/C++ application. JVM applications are portable because the JVM interprets or JIT-compiles bytecode at runtime, abstracting away the platform differences. A .class file or JAR runs on any JVM that supports that Java version, regardless of the underlying OS and CPU. For teams deploying to multiple platforms, Native Image adds CI/CD complexity — you need to build and test on each target platform, or use cross-compilation infrastructure. The tradeoff is explicit: Native Image trades cross-platform portability for startup speed and memory footprint.

14. What is the impact of using `--static` linking in a Native Image build, and when would you use it?

--static linking produces a native executable that has no external library dependencies — all libc, libpthread, and other system libraries are linked statically into the image. This makes the image fully self-contained and portable within its target OS family (e.g., Linux x86-64). You would use --static in containerized environments where the base image should not rely on the host's system libraries, in security-sensitive deployments where dynamic library loading is a concern, or when deploying to minimalist environments without standard library packages. The tradeoff is image size: static linking increases the binary size because library code is included. Additionally, static linking may not work with all native libraries (especially those usingdlopen-style runtime loading) because the linking must be resolved at build time. For most serverless and containerized microservice use cases, --static is recommended because it simplifies deployment and reduces attack surface.

15. How does Native Image handle dynamic proxy generation compared to the JVM's java.lang.reflect.Proxy?

In the JVM, java.lang.reflect.Proxy generates proxy classes at runtime using bytecode instrumentation — the JVM creates a new class file on the fly that implements the target interfaces and dispatches to an InvocationHandler. In Native Image, this dynamic generation cannot happen at runtime because all classes must be known at build time. Native Image handles this through proxy-config.json, which tells the analyzer which interface pairs should have proxy implementations generated at build time. The analyzer generates the proxy classes as part of the static analysis and includes them in the image. If you need runtime-generated proxies (like Spring's interface-based proxying), you must either configure them in proxy-config.json or use compile-time solutions like Spring's AspectJ weaving instead of runtime proxy generation. Missing proxy configuration causes UnsupportedOperationException or silent null returns at runtime.

16. What is the relationship between GraalVM's Truffle framework and Native Image, and why does Native Image support Truffle languages?

GraalVM's Truffle framework is a language implementation toolkit that uses GraalVM as the execution engine for dynamically interpreted languages (JavaScript, Ruby, Python, etc.). Truffle interpreters are themselves Java programs that get compiled by GraalVM — either via JIT for maximum performance or via Native Image for fast startup. When a Truffle language is compiled with Native Image, the interpreter and its runtime support all compile to native code, giving guest language programs near-instant startup without a JVM layer. This is how GraalVM can run JavaScript, Python, and Ruby with startup times competitive with native implementations. Native Image's closed-world assumption is compatible with Truffle because the interpreter and all language runtime classes are known at build time — the dynamic behavior comes from the guest programs, not from the language implementation itself. The combination enables polyglot applications where each language component starts instantly.

17. What are the implications of Native Image's lack of class loading for security and memory management?

Since all classes are loaded and initialized at build time, Native Image has no runtime class loading — there is no ClassLoader, no Class.forName() loading new classes, and no bytecode interpretation. This has security benefits: there is no way to load untrusted bytecode at runtime, reducing an entire attack vector. It also has memory benefits: no class loader metadata, no metaspace, no class unloading overhead. However, it means plugins or modules that load classes at runtime (like JDBC drivers, which are loaded via ServiceLoader) require special configuration. JDBC driver JARs must be included in the native image build, and the driver must be registered via static initialization rather than runtime loading. This is a common gotcha: a JAR that works fine on a JVM fails on Native Image because its class loading mechanism is not configured. Security-aware deployments benefit from the immutable, closed-world nature of the image — but must account for the class loading implications.

18. How does Native Image handle JNI (Java Native Interface) calls and native library integration?

JNI calls in Native Image work through static linking of native libraries at build time. The native-image tool uses the JNI export from the compiled Java code to determine which native functions are needed, then links against the specified native libraries. You provide native library files (.h, .lib, .a, .so) and header files as inputs to the build. At runtime, the native functions are directly callable from Java through the normal JNI mechanism, but without a JVM interpreter — the call goes directly to the native code. The key difference from JVM-based JNI is that there is no lazy loading of native libraries at runtime; all linked native code is included in the image. JNI is commonly used for performance-critical operations (crypto, compression), interfacing with legacy C/C++ libraries, or accessing OS-specific features. Native Image supports JNI but requires that all native dependencies be known at build time and linked statically.

19. What is the relationship between `-H:+DeadCodeElimination` and image size optimization in Native Image?

-H:+DeadCodeElimination=aggressive instructs the Native Image compiler to remove not only unreachable code detected by static analysis, but also code that is reachable but provably not used in the observed execution context. The standard dead code elimination removes methods and classes that the static analyzer cannot reach from the entry point. Aggressive mode goes further by looking for code paths that are reached but never actually execute based on constant conditions — for example, a method that is called but only with arguments that make it take an early return path. Aggressive dead code elimination can significantly reduce image size for large applications with extensive frameworks, but it increases build time and may remove code that is actually needed via reflection in ways the analyzer could not detect. Always test the resulting image to ensure functionality is preserved.

20. How does the image heap in Native Image interact with garbage collection, and why are preinitialized objects not GC'd?

The image heap contains all objects that existed at build time — these are "frozen" objects that the GC treats as roots throughout the process lifetime. They are not garbage collected because they are part of the image file itself (loaded as a memory-mapped region) and are not allocated at runtime. The Substrate VM's GC manages only the runtime-allocated heap — objects allocated by the application during execution are collected normally. The preinitialized objects are accessible as roots for GC tracing. This design means the static memory footprint of a Native Image is predictable: the image heap size is fixed at build time and does not grow at runtime from static initialization. For short-lived processes, the runtime heap may be small or unused entirely if no new objects are allocated. The tradeoff compared to JVM behavior is that in a JVM, static fields are allocated on the heap and managed by the GC — in Native Image, they are in the image heap and not GC-managed, reducing GC pressure significantly.

Further Reading

Conclusion

You now understand how Native Image compiles Java applications ahead-of-time into native executables with near-instant startup and minimal memory footprint. Use Native Image for serverless functions, CLI tools, and containerized microservices where startup speed matters more than peak JIT throughput. Remember that dynamic features require upfront configuration, and expect lower peak performance for long-running workloads compared to a JIT-warmed JVM. Explore JIT Compilation Internals to contrast AOT with the JIT compilation model.

Category

Related Posts

Async-Profiler: Low-Overhead CPU and Memory Profiling

Learn async-profiler for low-overhead CPU and memory profiling in production. Generate flame graphs, analyze allocations, and diagnose JVM bottlenecks.

#jvm #async-profiler #cpu-profiling

CMS and G1 Collectors: Low-Latency Garbage Collection

How CMS and G1 garbage collectors reduce pause times through concurrent marking, region-based heap layout, and incremental compaction.

#jvm #garbage-collection #cms

Deoptimization Debugging: When JIT Compiled Code Reverts

Learn what causes the JVM to deoptimize JIT-compiled code, how to detect deoptimization events, and how to fix the underlying issues.

#jvm #jit #deoptimization