Back to Advanced Topics
Q26Question 26 of 41

What is GraalVM Native Image, how does it change the memory/startup model, and when do you use it?

Key Concepts
  • GraalVM Native Image ahead-of-time compiles a Java application into a standalone native binary with a minimal embedded runtime (SubstrateVM).
  • The closed-world assumption means all reachable code must be known at build time; unreachable code is discarded.
  • Dynamic features like reflection, dynamic proxies, and resource loading require explicit configuration hints (JSON files).
  • Startup is in milliseconds and memory footprint is a fraction of the JVM because there is no class loading, interpretation, or JIT warmup.
  • The default GC in the community edition is Serial GC, suited for small heaps; G1 is available in the paid edition.
  • Use native image for serverless, CLI tools, and container-dense deployments; stay on the JVM for peak throughput in long-running services.

Definition

GraalVM Native Image AOT-compiles a Java application into a standalone native binary with millisecond startup and low memory, at the cost of a closed-world assumption that forces dynamic features to be configured at build time.
  • Ahead-of-time compilation – the Graal compiler compiles all reachable code to native machine code at build time, producing a standalone executable.
  • SubstrateVM – a minimal embedded runtime bundled in the binary, providing GC, thread scheduling, and exception handling.
  • Closed-world assumption – the universe of code is fixed at build time; anything not statically reachable is discarded.
  • Reflection hints – JSON configuration files listing classes and members used via reflection, dynamic proxies, JNI, and resources.
  • Serial GC – the default collector in the community edition, suited for small heaps typical of serverless and CLI workloads.
  • Trade-off – millisecond startup and low memory vs. longer build times, lower peak throughput than a warmed JVM, and framework compatibility work.

The normal JVM is like National Bank of India opening a branch each morning by unpacking boxes, hiring staff, and gradually getting faster. GraalVM Native Image is like prefabricating the branch at a factory, fully staffed, then placing it ready to serve instantly. It opens in seconds and takes a fraction of the space. The catch is the factory must know every room and tool in advance; if the branch needs a special tool at runtime (reflection), you must tell the factory up front.

Understanding the Concepts

What is GraalVM Native Image?

GraalVM Native Image is an ahead-of-time compilation technology that turns a Java application into a standalone native executable. The build process starts with a static reachability analysis: starting from your main method, the tool computes every class, method, and field that could be reached. Unreachable code is discarded. The reachable code is compiled to native machine code.

BASH
# Build a native image from a jar
native-image -jar balance-service.jar -o balance-service

# Run it: starts in milliseconds
./balance-service

How does the closed-world assumption work?

The closed-world assumption means the entire universe of code is fixed and fully known at build time. This enables aggressive dead-code elimination and optimization, but it clashes with Java's dynamic features.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class BalanceService {
    // This works in native image: fully static
    public BigDecimal getBalance(String accountId) {
        return new BigDecimal("50000.00");
    }

    // This breaks without a reflection hint:
    // Class.forName("com.bank.model.Account")
    // The static analysis cannot see the class name is a string
}

What are reflection hints and why are they needed?

Reflection that looks up a class or method by a string name cannot be discovered by static analysis. You must provide configuration hints, typically JSON files.

JSON
[
  {
    "name": "com.bank.model.Account",
    "allDeclaredConstructors": true,
    "allPublicMethods": true,
    "allDeclaredFields": true
  },
  {
    "name": "com.bank.model.TransferReceipt",
    "allDeclaredConstructors": true,
    "allPublicMethods": true
  }
]
BASH
# Place hint files in META-INF/native-image/
src/main/resources/META-INF/native-image/
  reflect-config.json
  resource-config.json
  proxy-config.json

Modern frameworks automate this. Spring Boot's AOT processing, Quarkus, and Micronaut precompute reflection hints at build time. The tracing agent can also generate hints by observing a running application.

BASH
# Generate hints automatically with the tracing agent
java -agentlib:native-image-agent=config-output-dir=hints \
     -jar balance-service.jar

# Then build with the generated hints
native-image -jar balance-service.jar \
     -H:ConfigurationFileDirectories=hints

How does the startup and memory model change?

The standard JVM loads classes, interprets bytecode, and JIT-compiles hot methods over time. This gives excellent peak throughput after warmup but slow startup and high memory.

Native Image inverts this. Because the binary is already machine code with its runtime baked in, there is no class loading, no interpretation, and no JIT warmup.

| Metric | Standard JVM | Native Image |
|--------|-------------|---------------|
| Startup | Seconds | Milliseconds |
| Memory (RSS) | 200-500 MB | 30-80 MB |
| Peak throughput | Highest (after warmup) | Lower (no runtime profiling) |
| Build time | Fast (javac) | Slow (minutes) |
| Dynamic features | Full support | Requires hints |

JAVA
package com.bank.service;

public class StartupDemo {
    public static void main(String[] args) {
        long start = System.nanoTime();
        System.out.println("Balance service started in " +
            (System.nanoTime() - start) / 1_000_000 + " ms");
    }
}
TEXT
# On JVM:
Balance service started in 1200 ms

# As native image:
Balance service started in 12 ms

What is SubstrateVM?

SubstrateVM is the minimal runtime embedded in the native binary. It provides:

  • Garbage collector – Serial GC by default (community edition); G1 available in Oracle GraalVM.
  • Thread scheduling – managed by the runtime.
  • Exception handling – stack unwinding compiled in.

There is no bytecode interpreter and no JIT compiler in the image.

When should you use native image?

Use native image when startup and memory dominate cost:

  • Serverless functions – billed per invocation and per MB; millisecond cold start saves real money.
  • CLI tools – must feel instant to the user.
  • Scale-to-zero microservices – fast scaling up and down.
  • Container-dense deployments – low per-instance memory.

When should you stay on the JVM?

Stay on the JVM when:

  • Peak throughput matters for long-running services; the JIT compiler with runtime profiling outperforms AOT.
  • Heavy reflection without native support; debugging hint misses is painful.
  • Fast iterative builds – native builds take minutes vs. seconds for javac.
  • Operational maturity – JVM tooling (JFR, JMX, async profilers) is more mature.

What are the common pitfalls?

The most common pitfall is a reflective mapper or serializer failing at runtime because a hint was missing. Always test thoroughly.

JAVA
// This works on JVM but fails in native image without hints:
ObjectMapper mapper = new ObjectMapper();
Account account = mapper.readValue(json, Account.class);
// Fix: register Account in reflect-config.json

Build times are long (minutes), so develop on the JVM for fast iteration and build native images in CI.

What about Spring Boot and Quarkus?

Spring Boot 3+ supports native image with AOT processing. Quarkus was designed for native image from the start.

BASH
# Spring Boot native build
./mvnw -Pnative native:compile

# Quarkus native build
./mvnw package -Dnative

What should a senior engineer know?

Many teams do both: JVM in development for speed of iteration, native image in production for deployment benefits. Profile-guided optimization can narrow the peak-throughput gap. The Foreign Function and Memory API works well with native image because it is designed for AOT compatibility. Expect native-image support to improve as frameworks and the GraalVM team collaborate on reducing hint requirements.

Visual Overview

DIAGRAM
  Standard JVM:
  .class files ──▶ ClassLoader ──▶ Interpreter ──▶ JIT Compiler
                                                   (warmup over time)
  Startup: seconds | Memory: 200+ MB | Peak: highest

  GraalVM Native Image:
  .class files ──▶ Graal AOT Compiler ──▶ Native Binary + SubstrateVM
                   (build time: minutes)

  Native Binary:
  ┌──────────────────────────────┐
  │  Machine Code (pre-compiled) │
  │  SubstrateVM (minimal GC,    │
  │    threads, exceptions)      │
  │  No interpreter, no JIT      │
  └──────────────────────────────┘
  Startup: milliseconds | Memory: 30-80 MB | Peak: good (not highest)

  Closed-world: reflection, proxies, resources → hints required

Key Takeaway

GraalVM Native Image ahead-of-time compiles a Java application into a standalone native executable with a minimal SubstrateVM runtime. Because the binary is already machine code, there is no class loading, no interpretation, and no JIT warmup. Startup is in milliseconds, memory footprint is a fraction of the JVM, and no installed JVM is needed.

The cost is the closed-world assumption. All reachable code must be known at build time. Dynamic features like reflection, dynamic proxies, and resource loading require explicit configuration hints. Modern frameworks like Spring Boot AOT and Quarkus automate hint generation, and the tracing agent can capture them from a running application.

At National Bank of India, the clearest win was a serverless balance-check function invoked millions of times daily. JVM cold starts over a second and hundreds of megabytes of memory were both slow and expensive. Native Image cut startup to milliseconds and memory to a fraction. The one gotcha was a Jackson ObjectMapper that failed at runtime until Pankaj registered a reflection hint.

The decision framework is clear: use native image when startup and memory dominate (serverless, CLI, scale-to-zero). Stay on the JVM when peak throughput after warmup matters most (long-running services), when heavy reflection without native support makes hint management painful, or when fast iterative builds matter. Many teams do both: JVM in development, native image in CI for production.

Remember one sentence: GraalVM Native Image trades JIT warmup for AOT compilation, giving millisecond startup and low memory at the cost of a closed-world assumption that requires reflection hints for dynamic features.

---

Share
Use ← → arrow keys to navigate