Working with Native Image Efficiently

Olga Gupalo
graalvm
Published in
13 min readMar 9, 2021

GraalVM Native Image compiles a Java application to a binary executable. Compiled this way the application starts instantly and typically uses less memory at runtime, which makes it a powerful deployment option for cloud services where the performance characteristics are essential for efficient scaling. This blog post shares some useful tricks to make the configuration process and your transition from just-in-time (JIT) to ahead-of time (AOT) execution mode as smooth as possible.

Prepare Your Environment
Configuration Required
Build-time Errors
Run-time Errors
Tooling for Native Image

If we briefly describe the build process, it would look something like statically analyzing the bytecode, figuring out which classes and methods to be actually used in the application, initializing the classes if necessary, compiling the code, linking the results, and storing them with the preinitialized objects in the binary. Now, while a lot of applications would work as a native image in a very straightforward manner, some dynamic features, like serialization, might require explicit configuration to help the static analysis. Because of the intrinsic complexity of the build process, it sometimes requires a good understanding of what is happening to effectively configure or tweak the build, and get the best results out of a native image.

Besides sharing some tips and tricks, we will also review a few commonly reoccurring build errors, their causes and how you can troubleshoot them. Finally, you will find a quick summary of Native Image tools that can be useful in preventing possible issues or help investigating the performance.

Prepare Your Environment

Native Image is supported by all GraalVM distributions: Community or Enterprise Edition; based on JDK 8 or JDK 11; for Linux, macOS, Windows platforms. However, the builds on JDK 11 tend to be more effective, so prefer them if possible.

You will need the header files for C libraries, like zliband gcc, because the native image builder depends on the local toolchain. Check if you meet prerequisites for using Native Image for your platform.

To check the building environment, native-toolchain information, and settings applied while building an image, use the --native-image-info option, for example:

native-image --native-image-info -jar App.jar

To determine what GraalVM was used for building a native image, you can run the following command: strings imagename | grep com.oracle.svm.core.VM.

Configuration Required

One of the most frequent reasons why a native image fails, is incomplete or completely missing configuration for the dynamic features which static analysis cannot handle by itself. At the image build time, the builder performs static analysis to find all methods that are reachable from the main entry point of your application. Only these methods are then ahead-of-time compiled into a native executable. And since the result does not include the infrastructure to work with Java bytecode or class loading, no other code can be added to the native image at run time. So all application code needs to be available at the image build time, and all code that will ever be executed is compiled ahead-of-time into the executable.

This approach works brilliantly, unless some of the language features are fully dynamic. The native image builder can’t statically figure out all execution paths if your applications uses, for example, Java Reflection API.

Similarly, there are other dynamic features that generate or use code at run time which can’t be statically understood at build time: Proxies, Java Native Interface (JNI), some usages of method handles, even accessing class path resources (Class.getResource). So if your code uses these features, you must “inform” the native image builder which classes and methods to include in the final binary.

If any of these features is used without providing a necessary configuration at the image build time, the builder may not abort by default, but generate a fallback image, which will not have the performance characteristics of the native image. To prevent this, build a native image with the --no-fallback flag.

Native Image supports several options to configure a native image build process. A recommended way is embedding a native-image.properties file into a project’s JAR file. This is a regular properties file and supports Args, JavaArgs, and ImageName properties. All the arguments are evaluated left-to-right, and the builder would automatically pick up all configuration options provided anywhere below the META-INF/native-image location. To debug which configuration data gets applied for the image building, enable verbose output (--verbose) or pass the --native-image-info option . See the dedicated guide on embedding the properties file and its formatting.

How to configure dynamic features for native image?

Some uses of dynamic features, for example, trivial reflection calls, are automatically handled, other targets of reflective access should be provided in the JSON format with a configuration file. You should pass this file as an argument to native-image in the native-image.properties configuration file. Here is an example:

What’s the most convenient way to create the configuration files?

You can write the configuration files for reflection, proxies, and other features needing it manually, but you are probably better with generating the configuration using the Tracing Agent, provided with GraalVM.

The agent tracks all usages of the dynamic features while you execute your application, and writes them out to the configuration files. Using the agent is the recommended way. To enable the agent, pass -agentlib:native-image-agent=config-output-dir=<path> before the -jar option or a class name or any application parameters on the command line. Create the META-INF/native-image/ directory if it does not exist yet, and run:

$JAVA_HOME/bin/java -agentlib:native-image-agent=config-output-dir=META-INF/native-image -jar App.jar

The agent interfaces with the JVM to intercept all classes, methods, fields, resources, or request proxy accesses. Once the JVM process terminates, the agent generates like jni-config.json, reflect-config.json, proxy-config.json and resource-config.json and other configuration files to the specified output directory. Just recently the serialization support was added to Native Image, and Tracing Agent can also provide a list of classes used in the deserialization activity, and output them into a serialization-config.json file.

As already mentioned above, native-image picks up all configuration files from a META-INF/native-image directory on the class path by default.

It maybe be necessary to run the target application more than once with different inputs to produce the configuration for different execution paths. It is because the agent traces the execution and does not analyse your application. You can then run the agent with the config-merge-dir option to merge the traced configuration to an existing set of configuration files:

$JAVA_HOME/bin/java -agentlib:native-image-agent=config-merge-dir=/path/to/config-directory/ -jar App.jar

One of the possible ways to generate configuration is to run the tests with the Tracing Agent, and review or modify the produced config files. The agent has other advanced features that may be applicable for your case.

Build-time Errors

Classes Initialized at Build Time instead of Run Time

A typical build-time error is “Classes that should be initialized at run time got initialized during image building”. For example:

Error: Classes that should be initialized at run time got initialized during image building: org.example.library.Klass the class was requested to be initialized at build time (from the command line). org.example.library.Klass has been initialized without the native-image initialization instrumentation and the stack trace can't be tracked.
...
Error: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Error: Image build request failed with exit status 1

Before we talk about how to solve the problems arising from the incorrect class initialization process, let’s briefly describe why it is happening in general.

Classes in your application need to be initialized before they are used. This process, for example, initializes the static fields of the class. The native image lifecycle is divided into two parts: build time — when you build the native image, compile the code, and store data to the image heap, and run time — when you run the resulting executable. While there is an obvious temporal difference between the two, and they probably happen in different environments, some code of your application can be executed at both — so you can think of them as your application runtime. By default, application classes are initialized at run time, but you have the option to configure this default setting with --initialize-at-build-time or --initialize-at-run-time options.

The configuration for the classes initialization time can come from various places: embedded into a library JAR file, your framework might set initialization of some classes to build time, the build script you are using can set it up for some classes using the command line options.

Last but not least, the build time initialization propagates through the static fields. This can be confusing if you have not thought about it before. When you initialize a class, you initialize the static fields by, for example, assigning an object of a class there. That class needs to be initialized before it can be instantiated, then it will be initialized at build time too.

This also means that when a class is initialized at build time, there is always an explicit reason why. While the error message by default does not provide any actionable information (because to collect that data you need to enable options in advance), it does suggest what you can do to explore the situation.

Let’s look back at the error message above:

Error: Classes that should be initialized at run time got initialized during image building: org.example.library.Klass the class was requested to be initialized at build time (from the command line).

To resolve the issue we should remove the inconsistency between the configuration when to initialize the Klass class. The solution is to identify the chain of classes that initialize Klass at image build time, and make them to be initialized at run time using the --initialize-at-run-time option.

  1. First you need to track what class is culpable, unintentionally initializes the other class at build time, by adding the parameter --trace-class-initialization to the native image build command:
native-image -jar my-app.jar 
-H:IncludeResources=resources.json
--trace-class-initialization
--verbose

2. When you run it, you should get a similar error:

Error: Classes that should be initialized at run time got initialized during image building:
org.example.library.Klass was unintentionally initialized at build time. some.other.klass.AnotherKlass caused initialization of this class with the following trace:
at org.example.library.Klass.<clinit>(Klass.java)
at org.example.library.KlassFactory.<init>(KlassFactory.java:90)
at org.example.library.SomeOtherKlass.<clinit>(SomeOtherKlass.java:86)

3. The first class in the stacktrace is the culprit. Build the native image again by initializing it at run time:

native-image -jar my-app.jar
--initialize-at-run-time=org.example.library.SomeOtherKlass
-H:IncludeResources=resources.json
--trace-class-initialization
--verbose

You may need to repeat the above steps if more errors show up. If you need to add more classes for initialization, --initialize-at-run-time(and the build time option too) takes a comma-separated list of classes and packages names, or package prefixes.

The general preference should be to intialize as many classes as possible at run time. Intializing classes at build time can have unintended side effects, and it is really the developer’s duty to check whether the class is safe for initialization.

Missing Type Error

The next error, often reported, is a Missing type during build time:

com.oracle.graal.pointsto.constraints.UnresolvedElementException: Discovered unresolved type during parsing: NNN

This error is caused by missing classes at the image build time. Since the native image runtime does not include the facilities to load new classes, all code needs to be available and compiled at build time. So any class that is referenced but missing is a potential problem at run time. The best advice is to provide all dependencies to the build process. If you are absolutely sure that the class is 100% optional and will not be used at run time, then you can override the default behaviour of failing the build process by finding a missing class with the --allow-incomplete-classpath option to native-image.

Out-of-memory

Another problem when generating native images could be out-of-memory errors or deadlocks:

Image generator watchdog detected no activity. This can be a sign of a deadlock during image building and watchdog is aborting image generation.

The native image build is a computationally intensive process and consumes a some RAM: the representation of a whole program is created to figure out which classes and methods will be used at run time. The native image builder is a Java program, runs on the Java HotSpot VM and uses the memory management of the underlying JDK. To prevent out-of-memory errors, explicitly set the maximum heap size used during the image build by passing -J + <jvm option for memory> to the native image builder. For example, native-image -J-Xmx14g.

Run-time Errors

You applied the agent. It detected, as expected, many classes and added them to the configuration files. You built a native image successfully. Then you executed your image and received the ClassNotFound error, though the class was present in the fat JAR. What would you do? This error means the class with name printed to the console is not found in the classpath (not available in the configuration file). To fix it, add the class name to reflect-config.json:

{
{
"name": "java.package.ClassName"
}
}

Another quick fix is to rebuild the native image with the --allow-incomplete-classpath option to move any possible linkage errors from build time to run time.

The following listing shows other most typical run-time errors:

java.lang.ClassNotFoundException: xxx
java.io.FileNotFoundException: Could not locate xxx on classpath
java.lang.IllegalArgumentException: Class xxx is instantiated reflectively but was never registered
java.lang.IllegalArgumentException: java.lang.IllegalArgumentException: Class xxx cannot be instantiated reflectively
java.lang.InstantiationException: Type xxx can not be instantiated reflectively as it does not have a no-parameter constructor or the no-parameter constructor has not been added explicitly to the native image
java.lang.IllegalStateException: input must not be null

Most often those errors are cased by the lack of configuration. The native image builder is not informed about some reflective calls or a resource to be loaded at run time. Another possible cause is that a third-party library includes some ahead-of-time incompatible code.

In most cases, when there is a problem, the error message suggests you exactly what to do. For example:

Caused by: java.util.MissingResourceException: Resource bundle not found javax.servlet.http.LocalStrings. Register the resource bundle using the option -H:IncludeResourceBundles=javax.servlet.http.LocalStrings.

So you add -H:IncludeResourceBundles=javax.servlet.http.LocalStringsto either on the command line or in a native-image.properties file, and rebuild the image.

If there is no explicit suggestion printed to the console, you can either trace classes initialization manually (--trace-class-initialization) during the image build, or apply the Tracing Agent tool to create necessary configuration, which is the recommended approach.

The reason for missing resources could be also that some configuration files are not accessible from the classpath (by specifying some custom output directory for the agent -agentlib:native-image-agent=config-output-dir=/path/to/config-dir/). Making the builder process aware of the configuration files can be done in a few different ways. For example, you can:

  • move the generated files either to the META-INF/native-image/ directory which is accessible from the classpath by default, for example, under src/main/resources directory
  • move them to a classpath directory and specify it with the argument: -H:ConfigurationResourceRoots=path/to/resources/
  • place in an arbitrary directory and specify it with the argument: -H:ConfigurationFileDirectories=/path/to/config-dir/

Tooling for Native Image

GraalVM offers some tooling for Native Image to help you.

Tracing Agent

Tracing Agent has been already mentioned in this post as indispensable in supplying the native image builder with necessary configuration. It tracks all usages of dynamic features when executing your application on a JVM, and writes them down to JSON files to be later picked up by native-image:

$JAVA_HOME/bin/java -agentlib:native-image-agent=config-output-dir=/path/to/config-dir/ -jar App.jar

The tracing agent can also write a trace file in the JSON format, trace-file.json, that contains each individual access. For more information, check the Assisted Configuration of Native Image Builds guide.

GraalVM Dashboard

If you suspect that your native executable is too large, or you wonder what classes, packages, or preinitialized objects fill up the image and take most of the heap, you can investigate that using a web-based visualization tool — GraalVM Dashboard.
To start using the tool, gather the diagnostic data into a dump file while building an image, open the dashboard and upload the dump. The screenshot below demonstrates the breakdown view by packages sizes:

GraalVM Dashboard in action

Debugging

The JDK traditionally ships with connectors to establish a debugging session with a target JVM. A generated native image is a heavily optimized code with minimal symbol information which makes its debugging harder. However, GraalVM allows you to build a native image with Dwarf debug information, and open network sockets for debug clients to connect. To build an image with debugging symbols, pass -g.

You can also attach a debugger to the process building a native image, which actually is a Java application. Use the --debug-attach[=< port >] option to start the builder in a debug mode and attach your favourite IDE debugger to it, set breakpoints, and so on. For example, setting a breakpoint into a class initializer can often reveal a stacktrace of from where the class is being initialized. For more information, proceed to Debug Info Feature documentation. Check also how to enable additional checks in the generated image to help with debugging.

Tracking Memory

If you believe that you could optimize your application when running as a native image with tweaking its memory configuration, you can enable some debug output at runtime. For example the following options will print the garbage collection logs: XX:+PrintGC -XX:+VerboseGC .

Native Image provides different garbage collector (GC) implementations for managing the Java heap. Check the Memory Management at Image Run Time guide.

Conclusions

In this article we looked at some common misunderstandings about Native Image and what problems can be caused by a missing or incomplete configuration. We hope this blog post provides the tools you need if you stumble on an issue with the native image builder.

Remember, if the advice in this post does not seem to take you closer to successfully using Native Image, you can always ask for help in #native-image open slack channel or submit a GitHub issue. This one-page summary of the key options for native-image might be also helpful and remind of the most handy commands.

GraalVM Native Image is continuously improving technology both in its technical abilities and the developer experience so we value all feedback, questions, and reports!

By Olga Gupalo and Oleg Šelajev

--

--