DEV Community

Cover image for # GraalVM Native Image: Optimize Java Applications with Ahead-of-Time Compilation
Aarav Joshi
Aarav Joshi

Posted on

# GraalVM Native Image: Optimize Java Applications with Ahead-of-Time Compilation

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Java Native Image is transforming how we develop Java applications by enabling ahead-of-time compilation. As a Java developer with years of experience, I've seen firsthand how this technology can dramatically improve application performance. Let me share what I've learned about building efficient applications with GraalVM Native Image.

Understanding Java Native Image

GraalVM Native Image compiles Java applications into standalone native executables. Unlike traditional Java applications that require a JVM to interpret bytecode at runtime, Native Image performs compilation ahead-of-time. The result is a binary executable that starts faster and consumes less memory.

The technology analyzes your application's class files and their dependencies to determine which classes, methods, and fields are reachable. It then compiles this closed world into native code optimized for your target platform.

I remember my first Native Image build - the startup time decreased from several seconds to milliseconds. This performance gain comes from eliminating JVM warmup time and optimizing the code at build time rather than runtime.

Handling Reflection Effectively

Reflection presents a significant challenge for Native Image. Since the compilation happens at build time, the compiler needs to know about all reflective access in advance.

In regular Java applications, we often use reflection without thinking twice:

Method method = MyClass.class.getDeclaredMethod("privateMethod");
method.setAccessible(true);
method.invoke(myInstance);
Enter fullscreen mode Exit fullscreen mode

However, Native Image can't automatically detect this dynamic behavior. We need to provide explicit configuration.

Create a reflection configuration file (reflection-config.json) to declare classes, methods, and fields accessed through reflection:

[
  {
    "name": "com.example.MyClass",
    "allDeclaredConstructors": true,
    "allPublicConstructors": true,
    "allDeclaredMethods": true,
    "allPublicMethods": true,
    "allDeclaredFields": true,
    "allPublicFields": true
  }
]
Enter fullscreen mode Exit fullscreen mode

For more selective configuration, specify only the exact methods or fields needed:

[
  {
    "name": "com.example.MyClass",
    "methods": [
      { "name": "privateMethod", "parameterTypes": [] }
    ],
    "fields": [
      { "name": "privateField" }
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

You can then include this configuration at build time:

native-image -H:ReflectionConfigurationFiles=reflection-config.json ...
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can use the @RegisterForReflection annotation from the GraalVM SDK:

import org.graalvm.nativeimage.annotation.RegisterForReflection;

@RegisterForReflection
public class MyClass {
    private String privateField;

    private void privateMethod() {
        // Method implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

Through experience, I've found that starting with minimal reflection configurations and gradually adding what's necessary produces the most efficient native images.

Managing Resources and Resource Bundles

Resources in Java applications are typically loaded dynamically at runtime. Native Image requires explicit registration of resources that need to be included in the final executable.

Create a resource configuration file (resource-config.json):

{
  "resources": [
    {"pattern": "com/example/resources/.*\\.properties"},
    {"pattern": "META-INF/services/.*"},
    {"pattern": "static/.*"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

Include this configuration at build time:

native-image -H:ResourceConfigurationFiles=resource-config.json ...
Enter fullscreen mode Exit fullscreen mode

For resource bundles used in internationalization, create a dedicated configuration:

{
  "bundles": [
    {"name": "messages.Messages"},
    {"name": "errors.ErrorMessages"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

Pass this configuration to the native-image tool:

native-image -H:IncludeResourceBundles=messages.Messages,errors.ErrorMessages ...
Enter fullscreen mode Exit fullscreen mode

I once spent hours debugging an application that worked perfectly in the JVM but failed in native execution. The culprit? Missing resource configurations. Always check your application's resource usage when moving to Native Image.

Configuring Dynamic Proxies

Dynamic proxies are another Java feature that requires special handling in Native Image. We need to explicitly register interfaces that will be used with java.lang.reflect.Proxy.

Create a proxy configuration file (proxy-config.json):

[
  ["com.example.MyInterface"],
  ["com.example.service.ServiceInterface", "com.example.service.AnotherInterface"]
]
Enter fullscreen mode Exit fullscreen mode

The first example registers a single interface, while the second registers a proxy that implements multiple interfaces.

Include this configuration at build time:

native-image -H:DynamicProxyConfigurationFiles=proxy-config.json ...
Enter fullscreen mode Exit fullscreen mode

When using frameworks that heavily rely on proxies (like Spring), consider using framework-specific tools that generate these configurations automatically.

Serialization Handling

Java serialization is another feature that requires explicit configuration for Native Image. Create a serialization configuration file (serialization-config.json):

[
  {
    "name": "com.example.SerializableClass",
    "allPublicConstructors": true,
    "allPublicMethods": true
  }
]
Enter fullscreen mode Exit fullscreen mode

Include this configuration at build time:

native-image -H:SerializationConfigurationFiles=serialization-config.json ...
Enter fullscreen mode Exit fullscreen mode

Modern applications often use alternatives to Java serialization like JSON libraries. These typically work better with Native Image as they don't rely on Java's reflection-based serialization mechanism.

Build-Time Initialization

One powerful technique for improving startup time is moving initialization from runtime to build time. This approach performs expensive initialization operations during the native image build process rather than each time the application starts.

Use the --initialize-at-build-time option to specify classes or packages that should be initialized during build:

native-image --initialize-at-build-time=com.example.util ...
Enter fullscreen mode Exit fullscreen mode

For finer control, use annotations to mark specific classes for build-time initialization:

import org.graalvm.nativeimage.annotation.BuildTimeInitialized;

@BuildTimeInitialized
public class ConfigurationCache {
    private static final Map<String, String> SETTINGS = loadSettings();

    private static Map<String, String> loadSettings() {
        // Load configuration data
    }
}
Enter fullscreen mode Exit fullscreen mode

Be cautious with build-time initialization - it only makes sense for code that produces deterministic results regardless of the execution environment. For example, parsing and caching configuration files is suitable, while code that depends on environment variables or external services typically isn't.

Implementing Runtime Fallbacks

Not all JVM features are available in Native Image. Class loading, JIT compilation, and certain reflection capabilities have limitations. Design your application to function without these features or implement fallbacks.

For example, instead of dynamic class loading:

// Problematic for Native Image
Class<?> dynamicClass = Class.forName(className);
Object instance = dynamicClass.newInstance();
Enter fullscreen mode Exit fullscreen mode

Consider a factory pattern with explicit registration:

// Better for Native Image
public class PluginRegistry {
    private static final Map<String, Supplier<Plugin>> PLUGINS = new HashMap<>();

    static {
        PLUGINS.put("basic", BasicPlugin::new);
        PLUGINS.put("advanced", AdvancedPlugin::new);
    }

    public static Plugin createPlugin(String type) {
        Supplier<Plugin> supplier = PLUGINS.get(type);
        if (supplier == null) {
            throw new IllegalArgumentException("Unknown plugin type: " + type);
        }
        return supplier.get();
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach explicitly registers all possible implementations at compile time, which works well with Native Image's closed-world assumption.

Testing Native Image Applications

Testing is critical when building Native Image applications. The native execution environment can behave differently from the JVM, particularly for code that relies on reflection, resources, or JVM-specific features.

Implement comprehensive tests that run in both JVM and native modes:

@Test
public void testReflection() {
    // Test reflection functionality
    MyClass instance = new MyClass();
    Method method = MyClass.class.getDeclaredMethod("privateMethod");
    method.setAccessible(true);
    method.invoke(instance);
    // Verify expected behavior
}
Enter fullscreen mode Exit fullscreen mode

Build a native test executable and run the same tests:

native-image --no-server -cp target/classes:target/test-classes \
    -H:ReflectionConfigurationFiles=reflection-config.json \
    -H:Class=org.junit.platform.console.ConsoleLauncher \
    -H:Name=native-tests
Enter fullscreen mode Exit fullscreen mode

Then execute the tests:

./native-tests execute --scan-classpath
Enter fullscreen mode Exit fullscreen mode

This dual testing approach helps identify issues early in the development process.

Using Assisted Configuration

Configuring Native Image manually can be tedious and error-prone. Fortunately, GraalVM provides tools to assist with this process.

The Native Image agent can generate configuration files by observing application behavior at runtime:

java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
    -jar your-application.jar
Enter fullscreen mode Exit fullscreen mode

Run your application through typical usage scenarios while the agent is active. The agent will record reflective access, resource usage, proxy creation, and other dynamic behavior, generating the necessary configuration files automatically.

These files can then be included in your application resources, where the Native Image build process will find them automatically.

Optimizing Native Image Size

Native executables are typically larger than Java bytecode. Here are techniques to reduce the size:

  1. Use the -H:+ReportAnalysisFinalness option to identify classes that can be marked as final, reducing the generated code:
native-image -H:+ReportAnalysisFinalness ...
Enter fullscreen mode Exit fullscreen mode
  1. Implement dead code elimination by excluding unused code:
native-image --no-fallback -H:+RemoveSaturatedTypeFlows ...
Enter fullscreen mode Exit fullscreen mode
  1. Use compressed class paths and G1GC for the build process:
native-image -J-XX:+UseCompressedClassPointers -J-XX:+UseCompressedOops -J-XX:+UseG1GC ...
Enter fullscreen mode Exit fullscreen mode
  1. Apply platform-specific optimizations:
native-image -H:+StripDebugInfo -H:+UseCEXT ...
Enter fullscreen mode Exit fullscreen mode

In one project, I reduced the executable size from 78MB to 32MB by applying these techniques in combination.

Framework Integration

Many popular Java frameworks have adapted to support Native Image. Spring Boot, Quarkus, Micronaut, and Helidon offer first-class support.

For Spring Boot applications, the Spring Native extension generates the necessary configurations:

<dependency>
    <groupId>org.springframework.experimental</groupId>
    <artifactId>spring-native</artifactId>
    <version>${spring-native.version}</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Quarkus was designed from the ground up with Native Image in mind:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-core</artifactId>
    <version>${quarkus.version}</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Building a Quarkus native application is straightforward:

./mvnw package -Pnative
Enter fullscreen mode Exit fullscreen mode

Micronaut also provides excellent Native Image support:

./gradlew nativeImage
Enter fullscreen mode Exit fullscreen mode

I've found that frameworks designed with Native Image support from the beginning typically provide the best experience.

Containerization of Native Images

Native Images work exceptionally well in containerized environments. The fast startup and low memory footprint are ideal for microservices and serverless architectures.

Create a multi-stage Dockerfile for building and packaging your Native Image application:

FROM ghcr.io/graalvm/graalvm-ce:latest AS builder
WORKDIR /app
COPY . /app
RUN ./mvnw package -Pnative

FROM frolvlad/alpine-glibc
WORKDIR /app
COPY --from=builder /app/target/my-application /app/application
ENTRYPOINT ["/app/application"]
Enter fullscreen mode Exit fullscreen mode

This approach results in a small container image that starts nearly instantly. I've seen container sizes reduce from hundreds of megabytes to tens of megabytes, and startup times decrease from seconds to milliseconds.

Performance Considerations

While Native Image offers significant advantages, it's important to understand the performance trade-offs:

  1. Startup time: Native Image excels here, often starting 10-100x faster than JVM applications.

  2. Peak throughput: Long-running applications might achieve higher throughput on the JVM due to just-in-time compilation optimizations not available to Native Image.

  3. Memory usage: Native Image typically uses less memory, especially during startup.

Monitor your application's performance characteristics in both environments to make informed decisions about which approach best suits your needs.

Java Native Image represents an exciting evolution in Java development. By understanding these techniques and applying them thoughtfully, we can create applications that combine Java's developer productivity with the runtime efficiency of native code. The journey may require some adaptation, but the performance benefits make it worthwhile for many use cases.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Heroku

Amplify your impact where it matters most — building exceptional apps.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (0)

AWS GenAI LIVE image

How is generative AI increasing efficiency?

Join AWS GenAI LIVE! to find out how gen AI is reshaping productivity, streamlining processes, and driving innovation.

Learn more

👋 Kindness is contagious

If this post resonated with you, feel free to hit ❤️ or leave a quick comment to share your thoughts!

Okay