GraalVM Native Image can build applications that start faster than traditional JVM applications because it performs ahead-of-time (AOT) compilation, eliminating the Just-In-Time (JIT) compilation warm-up phase.

Let’s see a quick example. Imagine a simple Java application that just prints "Hello, World!":

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
    // Simulate some work that might happen during startup
    static {
        try {
            Thread.sleep(50); // Simulate a short delay
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Normally, you’d compile this with javac HelloWorld.java and run it with java HelloWorld. This involves the JVM starting up, loading classes, and potentially JIT-compiling hot code paths.

With GraalVM Native Image, you first need GraalVM installed and configured. Then, you can build a native executable:

$GRAALVM_HOME/bin/native-image --input-directory . --output HelloWorld HelloWorld.java

This command tells native-image to analyze HelloWorld.java (and any code it depends on) and produce a standalone executable named HelloWorld. The --input-directory . is important if your source files are in the current directory.

Now, you can run the native executable directly:

./HelloWorld

You’ll notice that "Hello, World!" appears almost instantaneously, and the simulated 50ms delay from the static initializer is also effectively gone because the code is already compiled.

The core problem Native Image solves is the startup latency inherent in the traditional JVM model. JVMs are designed for long-running server applications where the initial startup time is amortized over a long execution period. For microservices, serverless functions, or command-line tools, this startup overhead can be a significant bottleneck. Native Image shifts the compilation work from runtime to build time.

Internally, the native-image tool performs a complex process called "reachability analysis." It starts from your application’s entry point (main method) and recursively determines all the code (classes, methods, fields) that is actually reachable from that entry point. It doesn’t include unused libraries or code paths. This is crucial because it allows for aggressive optimization and a smaller executable size. During this analysis, it also collects information about reflection, JNI, and proxy classes that your application might use, as these are hard to detect statically.

The primary levers you control are the configuration options passed to the native-image command. These include:

  • --initialize-at-run-time: For classes that must be initialized at runtime (e.g., due to reflection or dynamic class loading), you can explicitly tell Native Image not to initialize them during the build.
  • --report-unsupported-elements-at-runtime: This flag helps identify Java features that are not fully supported by Native Image and will cause runtime errors if not handled.
  • --spring-configuration-metadata-location: For Spring Boot applications, this points to the metadata file that helps Native Image understand the Spring configuration.
  • --enable-url-protocols: To include support for specific URL protocols (like http, https) that might not be statically discoverable.

The most surprising true thing about Native Image is that it doesn’t perform JIT compilation at all. Instead, it uses a sophisticated static analysis to determine which code will be executed and compiles that directly to native machine code. This means that the "warm-up" period you’re accustomed to with JVM applications, where the JIT compiler optimizes frequently executed code, is entirely absent. The performance is "hot" from the very first instruction.

The next concept you’ll likely encounter is managing dependencies that rely heavily on dynamic features like reflection or class generation, which require explicit configuration for Native Image.

Want structured learning?

Take the full Jvm course →