Skip to main content
  1. Languages/
  2. Java Guides/

Mastering Java GraalVM Native Images: Compilation and Performance Tuning in 2025

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

The Java landscape has shifted dramatically. While the JIT (Just-In-Time) compiler remains the gold standard for long-running, monolithic applications requiring massive peak throughput, the cloud-native era demands something different. In 2025, instant startup times, low memory footprints, and instant scalability are non-negotiable for Kubernetes deployments and Serverless functions.

Enter GraalVM Native Image.

By compiling Java bytecode into a standalone binary ahead-of-time (AOT), we can eliminate the JVM startup overhead entirely. However, moving from the dynamic world of HotSpot to the “Closed World Assumption” of Native Image is not without challenges.

In this guide, we will move beyond the “Hello World” examples. We will build a realistic application, tackle the notorious reflection configuration issues using the Tracing Agent, and optimize the final binary using Profile-Guided Optimizations (PGO) to rival JIT performance.

Prerequisites
#

To follow this tutorial, ensure your development environment meets the following criteria:

  • OS: Linux (preferred for production simulation), macOS, or Windows (via WSL2).
  • Java: JDK 21 LTS (or JDK 23).
  • GraalVM: Oracle GraalVM for JDK 21 (Free under GFTC) or GraalVM Community Edition.
  • Build Tool: Maven 3.9+.

Environment Setup
#

The easiest way to manage GraalVM versions is via SDKMAN!.

# Install SDKMAN if you haven't
curl -s "https://get.sdkman.io" | bash

# List available Java versions
sdk list java

# Install Oracle GraalVM (highly recommended for performance features like G1GC and PGO)
sdk install java 21.0.2-graal

# Verify installation
java -version
# Output should mention "GraalVM"

Understanding the Architecture: JIT vs. AOT
#

Before writing code, it is crucial to understand why Native Image behaves differently.

Standard Java loads classes dynamically. It interprets bytecode, then profiles it, and eventually compiles hot paths into machine code (C1/C2 compilers).

GraalVM Native Image performs static analysis at build time. It determines which classes, methods, and fields are reachable starting from your main method. It then compiles only that reachable code into a native executable. This is the Closed World Assumption.

The following diagram illustrates the structural difference in the lifecycle:

flowchart TD subgraph "Standard JVM (JIT)" A[Java Source] --> B[Bytecode .class] B --> C[JVM Startup] C --> D[Interpreter] D --> E{Hot Code?} E -- Yes --> F[JIT Compilation C1/C2] F --> G[Peak Performance] E -- No --> D end subgraph "GraalVM Native Image (AOT)" H[Java Source] --> I[Bytecode .class] I --> J[Native Image Builder] J --> K[Points-to Analysis] J --> L[Heap Snapshotting] K --> M[AOT Compilation] L --> M M --> N[Standalone Executable] N --> O[Instant Startup] end style J fill:#e1f5fe,stroke:#01579b style N fill:#e8f5e9,stroke:#2e7d32

1. The Application: Handling Reflection
#

The most common hurdle in Native Image adoption is Reflection. Because the builder performs static analysis, it cannot “see” classes loaded purely via reflection at runtime unless explicitly told.

Let’s create a Maven project simulating a plugin system that loads classes dynamically.

pom.xml Configuration
#

We use the official native-maven-plugin.

<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.javadevpro</groupId>
    <artifactId>graal-native-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.graalvm.buildtools</groupId>
                <artifactId>native-maven-plugin</artifactId>
                <version>0.10.1</version>
                <extensions>true</extensions>
                <executions>
                    <execution>
                        <id>build-native</id>
                        <goals>
                            <goal>compile-no-fork</goal>
                        </goals>
                        <phase>package</phase>
                    </execution>
                </executions>
                <configuration>
                    <!-- Name of the final binary -->
                    <imageName>app-native</imageName> 
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

The Java Code
#

Create a simple interface and an implementation that we will load dynamically.

src/main/java/com/javadevpro/Processor.java:

package com.javadevpro;

public interface Processor {
    String process(String input);
}

src/main/java/com/javadevpro/impl/StringReverser.java:

package com.javadevpro.impl;

import com.javadevpro.Processor;

// This class is NOT referenced directly in code, only via String name
public class StringReverser implements Processor {
    @Override
    public String process(String input) {
        return new StringBuilder(input).reverse().toString();
    }
}

src/main/java/com/javadevpro/App.java:

package com.javadevpro;

import java.lang.reflect.Constructor;

public class App {
    public static void main(String[] args) {
        long start = System.nanoTime();
        
        String input = args.length > 0 ? args[0] : "Hello GraalVM";
        String className = "com.javadevpro.impl.StringReverser";

        try {
            System.out.println("Loading class: " + className);
            Class<?> clazz = Class.forName(className);
            Constructor<?> ctor = clazz.getConstructor();
            Processor processor = (Processor) ctor.newInstance();
            
            String result = processor.process(input);
            System.out.println("Result: " + result);
            
        } catch (Exception e) {
            System.err.println("Failed to load processor via reflection!");
            e.printStackTrace();
        }

        long end = System.nanoTime();
        System.out.printf("Execution time: %.2f ms%n", (end - start) / 1_000_000.0);
    }
}

2. The Metadata Problem (and Solution)
#

If you attempt to build this native image immediately, it will compile successfully, but fail at runtime.

mvn -Pnative package
./target/app-native

Output:

Loading class: com.javadevpro.impl.StringReverser
Failed to load processor via reflection!
java.lang.ClassNotFoundException: com.javadevpro.impl.StringReverser
...

Why? The GraalVM builder saw Class.forName(className) but couldn’t determine the value of className statically. To save space, it removed StringReverser from the final binary because it appeared unused.

Solution: The Tracing Agent
#

Instead of writing JSON configuration files manually, we use the Tracing Agent. This agent attaches to the standard JVM, watches your application run, and records all reflection, JNI, and proxy usage.

Step 1: Run with the Agent

We need to create a directory for the configuration and run the app on the standard HotSpot JVM.

# Create directory
mkdir -p src/main/resources/META-INF/native-image

# Run the JAR with the agent attached
# The 'config-merge-dir' option appends to existing config rather than overwriting
java -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image \
     -cp target/graal-native-demo-1.0.0.jar \
     com.javadevpro.App "Test Input"

If you check src/main/resources/META-INF/native-image/reflect-config.json, you will see that StringReverser has been automatically added.

Step 2: Rebuild Native Image

The Native Image builder automatically picks up configuration found in META-INF/native-image.

mvn -Pnative package

Step 3: Verify

./target/app-native "JavaDevPro"
# Output: Result: orPveDavaJ

It works! The reflection data is now embedded in the binary.


3. Performance Optimization: PGO
#

Native Images start fast, but historically, their peak throughput was lower than HotSpot’s C2 compiler. This is because the AOT compiler doesn’t see runtime profiles (e.g., branch probabilities, type profiles) that the JIT uses to aggressively optimize code.

Profile-Guided Optimization (PGO) bridges this gap. It is a multi-step process:

  1. Build an Instrumented image.
  2. Run the instrumented image to generate profiles (default.iprof).
  3. Build the Optimized image using those profiles.

Note: PGO is a feature of Oracle GraalVM (free for production usage as of late 2024/2025).

Step-by-Step PGO Implementation
#

1. Build Instrumented Image

Pass the --pgo-instrument flag via the Maven plugin or command line.

# We can do this via command line arguments for the plugin
mvn -Pnative package -DbuildArgs="--pgo-instrument"

2. Run to Generate Profiles

Run your application under a representative workload. For a web server, you would run load tests. For our CLI, we run it a few times.

./target/app-native "Run 1"
./target/app-native "Run 2"
# This creates 'default.iprof' in the current directory
ls -lh default.iprof

3. Build Optimized Image

Now, we feed the profile back into the builder.

mvn -Pnative package -DbuildArgs="--pgo=default.iprof"

The resulting binary effectively contains “JIT-like” optimizations baked into the AOT code.


4. Advanced Tuning: Garbage Collection
#

By default, Native Image uses the Serial GC. This is optimal for small microservices (low memory footprint, fast startup). However, for data-intensive applications, it can cause latency spikes.

In 2025, the G1 Garbage Collector is fully available in Native Image (Oracle GraalVM).

To enable G1GC, add the --gc=G1 flag:

<!-- In pom.xml configuration -->
<configuration>
    <buildArgs>
        <buildArg>--gc=G1</buildArg>
        <buildArg>-O3</buildArg> <!-- Max optimization level -->
    </buildArgs>
</configuration>

When to use which GC:

Feature Serial GC (Default) G1 GC
Best For CLI tools, AWS Lambda, small microservices High-throughput APIs, Data Processing
Memory Footprint Extremely Low Moderate (needs heap for regions)
Latency Possible long pause times Predictable, low pause times
Throughput Lower Higher on multi-core machines

5. Benchmarks and Comparison
#

Let’s look at the theoretical difference between the running modes. The data below represents a typical Microservice (Spring Boot or Micronaut) behavior in 2025.

Metric HotSpot JVM (JIT) Native Image (Default) Native Image (PGO + G1)
Startup Time ~1500 ms ~30 ms ~35 ms
Memory (RSS) ~250 MB ~60 MB ~80 MB
First Request Latency High (Cold) Low Low
Peak Throughput 100% (Baseline) ~70-80% ~95-105%
Build Time Fast (< 10s) Slow (2-3 mins) Slowest (2 builds req.)

Key Takeaway: With PGO, Native Image can match or even exceed JIT throughput because it can perform aggressive inlining and escape analysis across the entire application boundary, which JIT sometimes cannot do due to deoptimization constraints.


Common Pitfalls and Best Practices
#

  1. Static Initializers: Code in static { ... } blocks is executed at build time by default. Do not open file handles, start threads, or connect to databases in static blocks. If you must, verify if you need to configure --initialize-at-run-time.

  2. Resources: ClassLoader.getResource() works, but you must instruct Native Image which files to include using resource-config.json (The Tracing Agent handles this too!).

  3. Serialization: Standard Java Serialization is supported but requires extensive configuration. Prefer JSON/Protobuf libraries that are Native-Image friendly (like Jackson or Micronaut Serialization).

  4. Logging: Some logging backends use heavy reflection. SLF4J with Logback generally works well now, but ensure you are using updated dependencies.

Conclusion
#

In 2025, GraalVM Native Image has matured from an experimental feature to a cornerstone of Java’s cloud-native strategy. It fundamentally changes the economics of running Java in the cloud by slashing memory costs and enabling scale-to-zero architectures.

Summary of steps for success:

  1. Develop on the standard JDK.
  2. Profile using the Tracing Agent during integration tests to catch reflection usage.
  3. Build the Native Image for production artifacts.
  4. Optimize with PGO if peak throughput is a KPI.

The ecosystem support from Spring Boot 3, Quarkus, and Micronaut makes this transition smoother than ever. However, understanding the underlying compilation model, as we did here with a vanilla application, empowers you to debug issues when “magic” frameworks fail.

Further Reading
#


Did this deep dive help you optimize your Java applications? Share your Native Image benchmarks in the comments below!