Bridging the Gap: Resource and Dynamic Class Loading in GraalVM Native Images

GraalVM Native Image technology compiles Java applications ahead-of-time into standalone native executables, offering lightning-fast startup and minimal memory footprint. However, this transformative technology requires a fundamental shift in thinking, especially regarding how resources and classes are loaded. The traditional dynamic, reflective nature of Java clashes with the static, closed-world assumption of native image compilation.

This article explores the challenges of resource inclusion in native images and provides comprehensive strategies for configuring your application to work correctly.


The Fundamental Challenge: Closed-World vs. Open-World

In a standard JVM environment, resources and classes can be discovered and loaded dynamically at runtime. The JVM operates on an "open-world" assumption. Native Image, by contrast, performs a static analysis of your application to determine all reachable code and resources. This "closed-world" analysis is what allows it to create an optimized, self-contained executable.

Anything not discovered during this static analysis—be it a resource file, a class loaded by name from a string, or a method accessed via reflection—will be unavailable at runtime.

Common Symptoms of Resource Exclusion

  • FileNotFoundException or NullPointerException when trying to access a resource via getResourceAsStream().
  • ClassNotFoundException for classes loaded dynamically using Class.forName().
  • Missing configuration files (e.g., .properties, .xml) that were bundled in the JAR.
  • Failures in libraries that rely heavily on reflection (e.g., JPA, Jackson, Spring DI).

Strategy 1: The Native Image Build Configuration File

The primary mechanism for informing the Native Image builder about dynamic elements is the JSON-based configuration file. When you build your native image, the builder looks for these files to understand what it couldn't detect statically.

There are three main types, but for resources, we focus on Resource Configuration.

Resource Configuration

This tells the native image builder which resource files and patterns to include in the final executable.

Location: META-INF/native-image/<group-id>/<artifact-id>/resource-config.json

Manual Creation Example:

Suppose your application needs to access all .properties files in the config directory and a specific banner.txt file.

{
"resources": {
"includes": [
{
"pattern": "config/.*\\.properties$"
},
{
"pattern": "banner\\.txt$"
},
{
"pattern": "META-INF/services/.*"  // For ServiceLoader interfaces
}
],
"excludes": [
{
"pattern": "config/dev-.*\\.properties$"  // Exclude development configs
}
]
}
}

Key Fields:

  • pattern: A Java regex pattern matching the resource paths.
  • includes: List of resources to include.
  • excludes: List of resources to exclude from the included patterns.

Strategy 2: Using the Native Image Agent for Automation

Manually writing these JSON files is tedious and error-prone. Fortunately, GraalVM provides a native image agent that automatically generates them for you.

How it works:

  1. Run your application on the JVM with the agent attached.
  2. Execute all code paths that load resources, classes, or use reflection.
  3. The agent intercepts these calls and records them into the configuration files.
  4. Use these generated files when building the native image.

Step-by-Step Process:

1. Build your application as usual.

mvn clean package

2. Run with the agent, specifying the output directory for config files.

java -agentlib:native-image-agent=config-output-dir=./native-config \
-jar your-application.jar

3. Exercise your application. Run tests, hit API endpoints, or use the application in a way that triggers all dynamic behavior. The agent will write files to ./native-config:

  • resource-config.json
  • reflect-config.json
  • jni-config.json
  • proxy-config.json
  • serialization-config.json

4. Place the config files in the correct classpath location.

# For a Maven project, copy them to the standard location
cp -r native-config/* src/main/resources/META-INF/native-image/

5. Build your native image. The builder will now use these configuration files.

native-image -jar your-application.jar \
-H:Name=my-native-app \
-H:ConfigurationFileDirectories=src/main/resources/META-INF/native-image

Strategy 3: Using Build Tools and Framework Integration

Modern build tools and frameworks have excellent support for automating native image configuration.

With Maven and Native Build Tools

The native-maven-plugin can simplify this process significantly.

<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.9.28</version>
<extensions>true</extensions>
<configuration>
<buildArgs>
<buildArg>-H:IncludeResources=config/.*\.properties</buildArg>
</buildArgs>
</configuration>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
<execution>
<id>test-native</id>
<goals>
<goal>test</goal>
</goals>
<phase>test</phase>
</execution>
</executions>
</plugin>

You can then run the agent-assisted process and build with:

mvn test -Pnative-agent
mvn package -Pnative

With Spring Boot 3+

Spring Boot 3 has first-class support for Native Image with its AOT (Ahead-of-Time) processing.

1. Add the Spring Native dependency:

<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-native</artifactId>
<version>0.12.1</version>
</dependency>

2. Use the Spring AOT plugin:

<plugin>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-aot-maven-plugin</artifactId>
<version>0.12.1</version>
<executions>
<execution>
<id>generate</id>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
</plugin>

3. Build with the Spring Boot plugin:

mvn spring-boot:build-image

Spring Boot's AOT processing will automatically:

  • Analyze your application configuration
  • Generate native configuration for Spring beans
  • Handle resource loading for Spring's classpath scanning
  • Process conditions like @ConditionalOnClass

Strategy 4: Runtime Resource Access Patterns

Sometimes, you need to adjust how you access resources to make them compatible with native image.

Problematic Pattern:

// This may fail if the resource wasn't included in the configuration
InputStream in = getClass().getResourceAsStream("/config/app.properties");

Better Pattern: Check for null

InputStream in = getClass().getResourceAsStream("/config/app.properties");
if (in == null) {
// Fallback strategy: use default, or load from filesystem, etc.
in = getClass().getResourceAsStream("/config/app-default.properties");
}

For File System Access:

If you need to include entire directories, you can bundle them and access them as filesystem paths.

{
"resources": {
"includes": [
{
"pattern": "templates/.*"
}
]
}
}
// Access files from the classpath
Path templateDir = Paths.get(getClass().getResource("/templates").toURI());

Best Practices and Troubleshooting

  1. Use the Agent First: Always start with the native image agent to generate the bulk of your configuration automatically.
  2. Test Thoroughly: After building your native image, run comprehensive tests to ensure all resources are accessible.
  3. Check Third-Party Dependencies: Many libraries require specific native image configuration. Check their documentation or use the agent to detect their needs.
  4. Use Build-Time Initialization: For resources that are read-only and used during startup, consider initializing them at build time. native-image --initialize-at-build-time=com.example.MyConfigLoader \ -jar your-app.jar
  5. Monitor Warnings: The native image builder outputs warnings about missing classes or resources. Address these warnings systematically.

Conclusion

Resource inclusion in GraalVM Native Images represents a paradigm shift from dynamic runtime discovery to explicit, static declaration. While this requires additional configuration, the tools and patterns available—particularly the native image agent and framework AOT support—make the process manageable and increasingly automated.

By understanding the closed-world assumption and proactively configuring your application with the strategies outlined above, you can successfully bridge the gap between Java's dynamic capabilities and the performance benefits of native compilation, creating truly optimized, production-ready native executables.


Further Reading: Explore GraalVM's --enable-url-protocols and --enable-all-security-services flags for applications that need specific URL protocols or security providers, and always refer to the latest GraalVM Native Image documentation for the most current features and best practices.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper