GraalVM Native Images: Pitfalls and Solutions
Back to Blog

GraalVM Native Images: Pitfalls and Solutions

6 min read
Read in Deutsch

Four Years Later: What We've Learned

When we wrote our first article on GraalVM in 2020, Native Image was still largely impractical for Spring Boot applications. Much has changed since then. GraalVM Community Edition has been integrated into the OpenJDK project since 2023, Spring Boot 3.3 delivers mature native support, and we at encircle360 now have several services running productively as native images.

But Native Image is no walk in the park. The fundamental challenge -- the closed-world assumption, where everything must be known at build time -- hasn't gone away. It just manifests in different, more subtle problems than four years ago. This article is a collection of the pitfalls we've encountered in real projects and the solutions we've found for them.

Reflection: The Classic That Won't Go Away

Spring Boot 3.x with its AOT engine handles the bulk of reflection configuration for you. We described this in our Spring Boot 3 article. What the engine can't automatically detect, however, are reflection accesses in your own code and in third-party libraries that run outside the Spring context.

JPA entities are a typical example. Hibernate needs access to constructors and fields, and while Spring Data covers most cases, there are edge cases -- such as @Embeddable classes or entities projected into DTOs via native queries. When a ClassNotFoundException or a cryptic InstantiationException error suddenly appears at runtime, it's almost always a missing reflection registration.

The cleanest solution in Spring Boot 3.3 is @RegisterReflection:

@RegisterReflection(classes = {InvoiceProjection.class, AddressEmbeddable.class},
    memberCategories = {MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                        MemberCategory.INVOKE_DECLARED_METHODS,
                        MemberCategory.DECLARED_FIELDS})
@Configuration
public class NativeImageConfig {
}

For more complex cases, such as when a library internally instantiates classes via reflection, a RuntimeHintsRegistrar is the right approach:

public class JacksonNativeHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection()
            .registerType(CustomDeserializer.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)
            .registerType(ApiResponse.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.INVOKE_DECLARED_METHODS,
                MemberCategory.DECLARED_FIELDS);
    }
}

The hint is registered via @ImportRuntimeHints(JacksonNativeHints.class) on any @Configuration class. Our recommendation: create a dedicated configuration class for all native hints. This keeps things organized.

Resources: Files Missing from the Binary

An error that misled us more than once: Class.getResourceAsStream() returns null even though the file is on the classpath. The reason is simple -- the native image compiler only includes resources it explicitly knows about. Everything else is simply absent from the binary.

Spring Boot automatically registers application.properties, application.yml, and the common template paths. But custom files -- SQL migration scripts, JSON schemas, certificates -- must be configured manually. This can be done either via the RuntimeHintsRegistrar:

hints.resources().registerPattern("db/migration/*.sql");
hints.resources().registerPattern("schemas/*.json");

Or via a resource-config.json in the META-INF/native-image/ directory:

{
  "resources": {
    "includes": [
      {"pattern": "db/migration/.*\\.sql$"},
      {"pattern": "schemas/.*\\.json$"}
    ]
  }
}

Both approaches work, but the RuntimeHints variant is preferable in Spring Boot projects because it lives in the same ecosystem and is testable. The JSON configuration is useful for libraries that don't have a Spring context.

JNI and Native Libraries

As soon as a dependency accesses native libraries via JNI, things get uncomfortable. This applies to certain cryptography providers, SQLite bindings, or image processing libraries, for example. The native image compiler needs to know which JNI methods are called, and the native shared libraries must be available for the target platform of the binary.

In practice, this means: if the native image is supposed to run on Linux in a container, the .so files for Linux/amd64 must be present, even if the build happens on macOS. We solve this by consistently running native image builds in CI -- in a container that matches the target environment. We use local native builds on developer machines only for testing, not for deployments. The Jib-based build pipeline helps here because the regular JVM image continues to serve as a fallback.

Build-Time vs. Runtime Initialization

A pitfall that can become security-relevant: classes that are initialized at build time bake their state into the binary. If a class generates a random value in a static initializer, reads an environment variable, or stores a timestamp, that value is frozen in the binary -- every instance of the binary starts with the same value.

This sounds abstract but has concrete consequences. A statically initialized SecureRandom produces the same sequence on every startup. A database password read at build time ends up in the binary. An embedded timestamp always shows the build time.

GraalVM tries to initialize as many classes as possible at build time because this further reduces startup time. You can control this per class:

--initialize-at-run-time=com.example.security.TokenGenerator

In Spring Boot, this is configured via native-image.properties or as an argument in the Gradle/Maven plugin. Our rule of thumb: anything that reads secrets, random values, or external configuration must be initialized at runtime.

Serialization: The Forgotten Configuration Area

Besides reflection and resources, there's a third configuration area that's easily overlooked: serialization. Classes that are serialized via java.io.Serializable need their own configuration. This particularly affects session objects, cache entries, and anything transported via RMI or certain messaging systems.

In most modern projects, Java serialization has become rare -- JSON via Jackson or Protocol Buffers have largely replaced it. But if you're using Spring Session with Redis, for example, and haven't switched the default serialization to JSON, the native image will fail at runtime. The error message is rarely helpful in these cases.

Garbage Collection and Memory Behavior

The memory behavior of a native image differs fundamentally from a HotSpot JVM. Native images use the Serial GC by default, which is optimized for small heaps. For services with low memory requirements -- and that includes many microservices -- this is ideal. The low memory overhead is, after all, one of the main reasons for building native images.

But for services that hold larger amounts of data in memory, the Serial GC can become a bottleneck. Since GraalVM 22.3, the G1 GC is available as an alternative but must be explicitly enabled:

--gc=G1

We've had good experiences with the Serial GC for services whose heap stays below 256 MB. Above that, it's worth testing the G1 GC and comparing pause times.

Debugging Without JVM Tooling

A point that's often underestimated in practice: the familiar JVM debugging toolkit doesn't work with native images. No jstack for thread dumps, no jmap for heap dumps, no JMX for remote monitoring, no VisualVM. The binary simply isn't a JVM anymore.

For heap analysis on OutOfMemoryErrors, there's a build flag:

-H:+DumpHeapAndExit

Signal-based thread dumps can be activated, and GraalVM has supported basic monitoring via JFR (Java Flight Recorder) in native images for several versions now. But the tooling gap is real and noticeable, especially when tracking down a sporadic problem in production.

Our approach: we run every native image service in parallel as a JVM variant in a staging environment. When a problem occurs that we can't diagnose in the native image, we reproduce it there. It's a compromise, but a pragmatic one.

Checking Third-Party Compatibility

Not every Java library works in a native image. Libraries that extensively use reflection, runtime bytecode generation, or sun.misc.Unsafe are potential problem candidates. The GraalVM community maintains a compatibility list, and many popular libraries now ship their own GraalVM metadata -- the GraalVM Reachability Metadata Repository on GitHub is the central resource here.

Our recommendation: before adding a new dependency to a project that's built as a native image, quickly check whether GraalVM metadata exists. Five minutes of research saves hours of debugging.

Conclusion: Is It Worth It?

After four years and several productive services as native images, our answer is: yes, but with eyes wide open. Startup times under 100 milliseconds and low memory consumption are a real advantage in Kubernetes environments with autoscaling. Build times are long, debugging is limited, and every new dependency must be checked for compatibility.

Spring Boot 3.3 has massively lowered the barrier. What was a research project in 2020 is realistic production operation in 2024. The closed-world assumption remains the central constraint, but you learn to work with it. For new microservices, we now evaluate Native Image by default. For existing, complex services with many dependencies, the JVM often remains the more pragmatic choice.

The crucial point is knowing the pitfalls before going to production -- not after.

Patrick Hütter

Written by

Patrick Hütter

Founder & Software Architect

Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.