Home Converting Java CLI Client to a Native Executable with GraalVM
Post
Cancel

Converting Java CLI Client to a Native Executable with GraalVM

GraalVM is a high-performance JDK distribution designed to accelerate the execution of applications written in Java and other JVM languages along with support for JavaScript, Ruby, Python, and a number of other popular languages. GrallVM allows you to compile your Java code into native executables which allows you to run them without a JRE. So in this post, I’ll explain the steps I followed to get my CLI client to work with GraalVM.

Some background, my CLI client was kind of a legacy application that wrapped a couple of SOAP services. Hence its dependencies required components like Axis2, Axiom, log4j etc. Initially, I used args4j for interactive CLI inputs and the Maven shade plugin to build an executable uber Jar.

There are a couple of ways to set up and use GraalVMs native-image. You can use the GraalVM Docker images, or you can simply install GraalVM on your machine. If you are super lazy you can also use Github Actions workflow with GraalVM setup step. But I highly recommend using the Docker image or setting it up locally which will save a lot of time when debugging issues. For this, you can refer the official Installation Guide

The first try, I thought it was super easy. So I had my Uber Jar created and I simply executed the below, thinking everything would work.

1
native-image -jar capp-manager.jar

At least some success, with no errors when converting to a native image.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Top 10 packages in code area:                               Top 10 object types in image heap:
 663.86KB java.util                                          947.79KB byte[] for code metadata
 351.87KB java.lang                                          897.25KB java.lang.String
 273.76KB java.text                                          837.11KB byte[] for general heap data
 234.86KB java.util.regex                                    628.55KB java.lang.Class
 198.40KB com.oracle.svm.jni                                 544.92KB byte[] for java.lang.String
 193.86KB java.util.concurrent                               434.86KB java.util.HashMap$Node
 146.93KB java.math                                          224.30KB com.oracle.svm.core.hub.DynamicHubCompanion
 120.62KB java.lang.invoke                                   209.58KB java.util.HashMap$Node[]
 105.90KB com.oracle.svm.core.genscavenge                    164.34KB java.lang.String[]
  98.16KB java.util.logging                                  155.81KB java.util.concurrent.ConcurrentHashMap$Node
   1.98MB for 119 more packages                                1.54MB for 786 more object types
------------------------------------------------------------------------------------------------------------------------
                        0.3s (2.0% of total time) in 17 GCs | Peak RSS: 3.30GB | CPU load: 8.23
------------------------------------------------------------------------------------------------------------------------
Produced artifacts:
 /home/yasassri/workspace/projects/capp-manager/wso2-capp-manager/target/capp-manager-0.1.3 (executable)
 /home/yasassri/workspace/projects/capp-manager/wso2-capp-manager/target/capp-manager-0.1.3.build_artifacts.txt (txt)
========================================================================================================================
Finished generating 'capp-manager-0.1.3' in 14.0s.
Warning: Image 'capp-manager-0.1.3' is a fallback image that requires a JDK for execution (use --no-fallback to suppress fallback image generation and to print more detailed information why a fallback image was necessary).

Although there were no errors, there was a fishy warning at the end, but let’s move the executable to a different location and run it. Bummer, the first error.

1
2
Error: Could not find or load main class org.wso2.capp.client.Main
Caused by: java.lang.ClassNotFoundException: org.wso2.capp.client.Main

The reason is the last Warning, for some weird reason the default behavior of native-image command is to create a fallback image, which means if the executable fails it will execute the Jar as a fallback measure. So this Jar needs a JVM to run. This doesn’t make any sense as to why they have this behavior by default, given the whole purpose of converting to a native image is to run the native image without a JVM. But I found there are legal reasons for Oracle to have this built this way. If interested you can read more from here

So let’s add the --no-fallback flag and create the image, which will stop creating the fallback image and pack everything into the native image.

1
native-image -jar capp-manager.jar --no-fallback

The output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
------------------------------------------------------------------------------------------------------------------------
Top 10 packages in code area:                               Top 10 object types in image heap:
 706.01KB java.util                                            1.06MB byte[] for code metadata
 370.62KB java.lang                                          980.19KB java.lang.String
 274.10KB java.text                                          887.39KB byte[] for general heap data
 234.89KB java.util.regex                                    773.05KB java.lang.Class
 204.67KB java.util.concurrent                               648.69KB byte[] for java.lang.String
 202.26KB com.oracle.svm.jni                                 435.38KB java.util.HashMap$Node
 146.93KB java.math                                          282.73KB com.oracle.svm.core.hub.DynamicHubCompanion
 119.09KB java.lang.invoke                                   210.28KB java.util.HashMap$Node[]
 105.90KB com.oracle.svm.core.genscavenge                    176.99KB java.lang.String[]
  98.30KB java.util.logging                                  155.81KB java.util.concurrent.ConcurrentHashMap$Node
   2.50MB for 152 more packages                                1.60MB for 829 more object types
------------------------------------------------------------------------------------------------------------------------
                        0.4s (2.3% of total time) in 18 GCs | Peak RSS: 3.47GB | CPU load: 7.80
------------------------------------------------------------------------------------------------------------------------
Produced artifacts:
 /home/yasassri/workspace/projects/capp-manager/wso2-capp-manager/target/capp-manager-0.1.3 (executable)
 /home/yasassri/workspace/projects/capp-manager/wso2-capp-manager/target/capp-manager-0.1.3.build_artifacts.txt (txt)
========================================================================================================================

No Warning this time, so let’s execute it. Once executed I got another error.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Exception in thread "main" java.lang.ExceptionInInitializerError
	at org.apache.logging.log4j.LogManager.<clinit>(LogManager.java:61)
	at org.wso2.capp.client.Main.<clinit>(Main.java:14)
Caused by: java.lang.IllegalStateException: java.lang.InstantiationException: org.apache.logging.log4j.message.DefaultFlowMessageFactory
	at org.apache.logging.log4j.spi.AbstractLogger.createDefaultFlowMessageFactory(AbstractLogger.java:246)
	at org.apache.logging.log4j.spi.AbstractLogger.<init>(AbstractLogger.java:144)
	at org.apache.logging.log4j.status.StatusLogger.<init>(StatusLogger.java:105)
	at org.apache.logging.log4j.status.StatusLogger.<clinit>(StatusLogger.java:85)
	... 2 more
Caused by: java.lang.InstantiationException: org.apache.logging.log4j.message.DefaultFlowMessageFactory
	at java.lang.Class.newInstance(DynamicHub.java:639)
	at org.apache.logging.log4j.spi.AbstractLogger.createDefaultFlowMessageFactory(AbstractLogger.java:244)
	... 5 more
Caused by: java.lang.NoSuchMethodException: org.apache.logging.log4j.message.DefaultFlowMessageFactory.<init>()
	at java.lang.Class.getConstructor0(DynamicHub.java:3585)
	at java.lang.Class.newInstance(DynamicHub.java:626)
	... 6 more

If we look at the above error, it’s complaining about some methods not being found. Inorder to understand the reason for the above type of errors we need to understand how native images are created. When creating the native image, the native-image tool performs static code analysis of the code to determine the classes and methods that should be reachable in the application runtime. At the same time, GraalVM employs different strategies to improve performance. For example, given Class initialization degrades the performance in the runtime, GraalVM does something called Class Initialization In Build time You can read more about these on the official GrallVM website.

Looking at the above log4j error specifically, GrallVM will have trouble with file descriptors to be referenced in static fields because the files might not be present at run time. Similar issues can occur if Java Reflection is used to load classes in the runtime, native-image will not be able to predict which classes will be needed in the runtime. At this point, you can try to force native-image tool to pack the classes that are needed when building the image.

Let’s see how to do this. For this, you can use the Tracing Agent that comes with GraalVM which allows you to trace the classes being loaded in the application runtime. This agent will allow you to run your application and generate a configuration file that will tell native-image tool which resources/classes need to be included. The Tracing Agent is capable of detecting usages of the Java Native Interface (JNI), Java Reflection, Dynamic Proxy objects (java.lang.reflect.Proxy), or class path resources (Class.getResource).

Let’s run the client with the agent using -agentlib:native-image-agent flag. If you have a server-type application that keeps on running. Once run with the agent you can execute all the options in your application which will load the classes in the runtime and the agent will track them. In my case I have a CLI client which runs and exits, so I had to run the CLI client multiple times with different options that are supported and generate the configurations. One thing to note is if you run the command multiple times with the agent the configurations will be overridden, hence like in my case if you have multiple commands you may want to use multiple output directories for each command and merge the config files later. You can use something like meld to check the diff between files and merge them.

1
2
3
4
5
6
7
8
# Option 1
java -agentlib:native-image-agent=config-output-dir=./gral-settings -jar capp-manager-0.1.3.jar list-apps --server https://localhost:9443 --username admin --password admin

# Option 2
java -agentlib:native-image-agent=config-output-dir=./gral-settings2 -jar capp-manager-0.1.3.jar download --server https://localhost:9443 --username admin --password admin --app-name HelloCompositeExporter --destination ./
.
.
.

After merging the files you would have multiple configuration files like below. If you see anything missing you can add them manually to each config file.

1
2
3
4
5
6
7
graalvm-settings
├── jni-config.json
├── predefined-classes-config.json
├── proxy-config.json
├── reflect-config.json
├── resource-config.json
└── serialization-config.json

Let’s build the new native image with the above config files. Since my application does http/https calls I’m passing some additional flags like --enable-url-protocols=https,http --enable-https --enable-http if your application doesn’t need them, you can omit them.

1
native-image -jar target/capp-manager-0.1.3.jar --no-fallback -H:ConfigurationFileDirectories=./graalvm-settings --enable-url-protocols=https,http --enable-https --enable-http

The output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 Top 10 packages in code area:                               Top 10 object types in image heap:
   1.49MB sun.security.ssl                                     5.82MB byte[] for code metadata
   1.04MB java.util                                            2.71MB java.lang.String
 839.46KB picocli                                              2.66MB java.lang.Class
 742.50KB com.sun.org.apache.xalan.internal.xsltc.compiler     2.57MB byte[] for general heap data
 704.42KB com.sun.crypto.provider                              2.04MB byte[] for java.lang.String
 534.36KB java.lang                                          966.80KB com.oracle.svm.core.hub.DynamicHubCompanion
 530.98KB java.lang.invoke                                   900.18KB byte[] for embedded resources
 500.51KB com.sun.org.apache.xerces.internal.impl            639.19KB java.util.HashMap$Node
 487.96KB c.s.org.apache.xerces.internal.impl.xs.traversers  561.73KB byte[] for reflection metadata
 459.58KB sun.security.x509                                  559.87KB java.lang.String[]
  19.49MB for 483 more packages                                4.69MB for 2238 more object types
------------------------------------------------------------------------------------------------------------------------
                        2.0s (4.1% of total time) in 31 GCs | Peak RSS: 6.35GB | CPU load: 8.25
------------------------------------------------------------------------------------------------------------------------
Produced artifacts:
 /home/yasassri/workspace/projects/capp-manager/wso2-capp-manager/capp-manager-0.1.3 (executable)
 /home/yasassri/workspace/projects/capp-manager/wso2-capp-manager/capp-manager-0.1.3.build_artifacts.txt (txt)
========================================================================================================================
Finished generating 'capp-manager-0.1.3' in 47.7s.

You can see the number of packages that are added to the native image is much higher than in the previous build. The more classes you add to the native image the size of the executable will increase. So you may consider optimizing it after you have successfully built the native image. After the above steps, my application started working without any further errors.

General Tips

  • Try to use dependencies that are GraalVM compatible. In my case, I migrated from Args4J to Pocoli to build the interactive CLI wchih is compatible with GraalVM.

Hope the above helps. Please drop a comment if you have any questions.

This post is licensed under CC BY 4.0 by the author.

Stop A Recurring Jenkins Job After a Fixed number of Runs

Storing TDigests In A Database And Reconstructing It Back In Javascript