GraalVM native images can actually make your Java applications slower on initial startup if you’re not careful, even though they promise instant cold starts.

Let’s watch a typical Spring Boot application transform. We’ll start with a standard JAR, then build a native image, and see the difference.

First, the standard JAR.

// src/main/java/com/example/demo/DemoApplication.java
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class DemoApplication {

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

    @GetMapping("/")
    public String hello() {
        return "Hello from Spring Boot!";
    }
}

Build it with Maven:

mvn clean package

And run it:

java -jar target/demo-0.0.1-SNAPSHOT.jar

On my machine, this takes about 3.5 seconds to respond to curl localhost:8080.

Now, let’s make a native image. You’ll need GraalVM installed and configured. The core tool is native-image.

First, we need to tell native-image how to build our Spring Boot app. Spring has excellent support for this through its Maven plugin. Add this to your pom.xml:

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <image>
                    <builder>spring-aot</builder>
                </image>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.graalvm.nativeimage</groupId>
            <artifactId>native-image-maven-plugin</artifactId>
            <version>21.3.0</version> <!-- Use your GraalVM version -->
            <executions>
                <execution>
                    <id>build-native</id>
                    <phase>package</phase>
                    <goals>
                        <goal>native-image</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Then, build with the native image goal:

mvn -Pnative clean package

This process can take several minutes. It analyzes your code, performs ahead-of-time compilation, and generates a self-contained executable.

After the build, you’ll find a native executable in target/. Let’s run it:

./target/demo

Now, curl localhost:8080. This time, the response is instantaneous. The startup time is now under 100 milliseconds.

The magic behind this is ahead-of-time (AOT) compilation and static analysis. Unlike the JVM, which uses Just-In-Time (JIT) compilation and performs optimizations at runtime, GraalVM’s native-image tool analyzes your application’s code, its dependencies, and configuration before execution. It determines exactly which classes, methods, and resources are needed, eliminating reflection, dynamic proxies, and other JVM features that are hard to AOT compile. This results in a smaller, self-contained executable that doesn’t need a JVM to run.

The key levers you control are primarily through configuration:

  • native-image.properties: This file (or equivalent configuration in your build tool) is where you specify hints for the native-image builder. This includes things like which reflection configurations, JNI configurations, resource configurations, and proxy configurations are needed.
  • Spring AOT: For Spring Boot applications, the spring-aot builder automates much of this configuration. It analyzes Spring’s beans and configuration, generating hints for GraalVM.
  • @RegisterReflectionForBinding: For specific types that need reflection (e.g., for JSON serialization), you can annotate them to ensure they are included.
  • @SerializationHint: Similar to reflection, for types used with Java serialization.
  • --enable-url-protocols: If your application uses custom URL protocols (like http or https), you might need to explicitly enable them during the build.

The most surprising thing about native images is how much they simplify runtime dependencies. You don’t need a separate JRE or JDK installed on the target machine. The generated executable contains everything necessary, making deployment incredibly straightforward. It’s a single, statically linked binary.

What often trips people up is the reachability analysis. The native-image tool performs a conservative analysis. If a class or method is only ever called through reflection, and that reflection isn’t explicitly configured, the tool won’t include it. This means that even though your code looks like it should work, the absence of a runtime hint can cause ClassNotFoundException or NoSuchMethodException at runtime. You’ll often see build warnings from native-image about potential issues; these should be investigated.

The next hurdle you’ll encounter is managing the build configuration for more complex applications, especially those with many third-party libraries that may not have first-class native image support.

Want structured learning?

Take the full Java course →