Native applications with GraalVM
published: ⏰ 8A quick note on building native apps using GraalVM. The main goal was to speed up app startup and reduce memory consumption, but it turns out that this process is not without its problems.
GraalVm is a multi-language virtual machine that supports Java and other programming languages. One of its key features is the ability to compile Java applications into native code, which significantly speeds up their launch and reduces memory consumption.
Installing GraalVM
First, you need to install the GraalVM JDK. You can download it yourself, but I use SDKMAN! to
install and switch between different JDK versions. Here is an example of downloading via curl:
curl -s "https://get.sdkman.io" | bashAfter installing SDKMAN!, install GraalVM. Testing and verification were performed on version 21.0.6-graal:

When the installer asks whether to make the installed JDK the main one, answer - Yes. That’s all.
Building a native application
To build a native application, you will need a plugin for Maven or Gradle. I almost always use Maven, so the example will be with it. I think the usage with Gradle is similar.
There is no point in describing all possible parameters, they are configured for the project and can be found in the
documentation for the plugin and for GraalVM on the official website. The example below shows the minimum parameters
required for assembly. The following configuration must be added to the pom.xml file, in the <build> section (or
better yet, create a separate profile):
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>
com.test.Main <!--(( 1 ))-->
</mainClass>
<addClasspath>true</addClasspath>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.6</version>
<extensions>true</extensions>
<configuration>
<buildArgs>
<buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
<buildArg>--gc=G1</buildArg>
<buildArg>-H:+StrictImageHeap</buildArg>
<buildArg>-march=native</buildArg>
<buildArg>--enable-sbom</buildArg>
<buildArg>-O3</buildArg> <!--(( 2 ))-->
</buildArgs>
</configuration>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>- The name of the main class.
- The compiler optimization level, where
-O3means the maximum optimization level. It is better to use-Obfor checking the assembly, so as not to waste time.
When you run the package task, the native application will be compiled in the target directory (the name of the
compiled application will match the name of the artifact). After that, you can run it.
Actually, this is where the problems start. For example, if reflection or Java SPI is used, then various errors will appear during startup, such as:
does not have a no-arg default constructor.java.base@21.0.6/java.util.ServiceLoader.fail(ServiceLoader.java:586)- and other things that are at first glance unclear.
To correct these errors, you will have to additionally describe the metadata:
Tracing agent
Describing metadata manually is quite tedious. Fortunately, GraalVM has a tracing agent (Native Image Tracing Agent) to automatically collect this data.
It can be used separately, separately, by specifying the following line in the application launch parameters:
-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-imageAs a result, the following files will appear in the META-INF/native-image directory:
jni-config.jsonpredefined-classes-config.jsonproxy-config.jsonreflect-config.jsonresource-config.jsonserialization-config.json
The second way is to add a separate task to <build>:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>java-agent</id>
<goals>
<goal>exec</goal>
</goals>
<phase>package</phase> <!--(( 1 ))-->
<configuration>
<executable>java</executable>
<arguments>
<argument>-classpath</argument>
<classpath/>
<argument>com.test.Main</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>- This specifies the build phase at which the application will be launched.
It is important that all parts of the code are executed when the application is launched so that the agent can catch them.
After the agent has completed its work, we rebuild the project and the main errors should disappear.
Performance comparison
And finally about performance. For tests I used tekBlueprint.infra with calculation of metrics for a year, with
different duration of tests: seconds, minutes, hours.
The results are actually expected, the native application starts up many times faster. Therefore, for cases when you need to quickly and frequently start applications – it is a good fit. The usual JVM loses time on startup and initial " warm-up" (I think it doesn’t even have time to reach JIT).
JIT – Just-In-Time compilation. A compiler that optimizes code during program execution. In Java, this is HotSpot.
But if the calculations take a long time (for example, a calculation per year in seconds), then the difference will be in the opposite direction. The “hot spots” of the executed code are optimized by JIT, and the native application remains at the same level.
| Simulation parameters | JIT | Native | Native vs JIT |
|---|---|---|---|
| 8760 probes, each HOUR during 1 YEAR | 534.2 ms ± 12.5 ms | 36.5 ms ± 1.1 ms | 14.63 ± 0.54 |
| 525600 probes, each MINUTE during 1 YEAR | 1.560 s ± 0.067 s | 1.109 s ± 0.028 s | 1.41 ± 0.07 |
| 31536000 probes, each SECOND during 1 YEAR | 55.400 s ± 0.228 s | 64.362 s … 64.388 s | -1.16 ± 0.00 |
Simulation by hours for a year
Benchmark 1: ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
Time (mean ± σ): 534.2 ms ± 12.5 ms [User: 1537.5 ms, System: 174.8 ms]
Range (min … max): 519.1 ms … 556.7 ms 10 runs
Benchmark 2: ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
Time (mean ± σ): 36.5 ms ± 1.1 ms [User: 24.8 ms, System: 11.8 ms]
Range (min … max): 34.2 ms … 39.9 ms 78 runs
Summary
./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml ran
14.63 ± 0.54 times faster than ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xmlSimulation by minutes per year
Benchmark 1: ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
Time (mean ± σ): 1.560 s ± 0.067 s [User: 4.469 s, System: 0.313 s]
Range (min … max): 1.495 s … 1.701 s 10 runs
Benchmark 2: ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
Time (mean ± σ): 1.109 s ± 0.028 s [User: 1.080 s, System: 0.288 s]
Range (min … max): 1.079 s … 1.162 s 10 runs
Summary
./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml ran
1.41 ± 0.07 times faster than ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xmlSimulation by seconds per year
Benchmark 1: ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
Time (mean ± σ): 55.400 s ± 0.228 s [User: 57.790 s, System: 0.416 s]
Range (min … max): 55.238 s … 55.561 s 2 runs
Benchmark 2: ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
Time (mean ± σ): 64.375 s ± 0.018 s [User: 64.609 s, System: 0.498 s]
Range (min … max): 64.362 s … 64.388 s 2 runs
Summary
./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml ran
1.16 ± 0.00 times faster than ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xmlBelow you can see that the native application’s prepare (drawing loading, simulation context creation),
merge (results collector) stages take less than a millisecond, while a regular application spends from 2ms to 36ms on
this. When calculating, the situation changes in the opposite direction, the native application is slower.
Example of simulation by seconds per year (JIT):
--- SOLVING SOFTWARE ---
31536000 probes, each SECOND during 1 YEAR
prepare: 36ms
progress: 3.76% (1186367 / 31536000), 237273.40 per second
progress: 13.60% (4287894 / 31536000), 620305.40 per second
progress: 22.98% (7246069 / 31536000), 591635.00 per second
progress: 32.03% (10101239 / 31536000), 571034.00 per second
progress: 41.02% (12935823 / 31536000), 566916.80 per second
progress: 50.05% (15782622 / 31536000), 569359.80 per second
progress: 58.94% (18586421 / 31536000), 560759.80 per second
progress: 68.03% (21453728 / 31536000), 573461.40 per second
progress: 77.30% (24377240 / 31536000), 584702.40 per second
progress: 86.62% (27315928 / 31536000), 587737.60 per second
progress: 95.82% (30216793 / 31536000), 580173.00 per second
progress: 100.00% 31535999 / 31536000
simulation: 54299ms
merge: 2msExample of simulation by seconds per year (Native):
--- SOLVING SOFTWARE ---
31536000 probes, each SECOND during 1 YEAR
prepare: 0ms
progress: 3.38% (1064963 / 31536000), 212992.60 per second
progress: 11.31% (3565270 / 31536000), 500061.40 per second
progress: 19.17% (6043884 / 31536000), 495722.80 per second
progress: 27.00% (8515355 / 31536000), 494294.20 per second
progress: 34.88% (11000622 / 31536000), 497053.40 per second
progress: 42.44% (13385213 / 31536000), 476918.20 per second
progress: 50.04% (15781071 / 31536000), 479171.60 per second
progress: 57.91% (18261901 / 31536000), 496166.00 per second
progress: 65.81% (20752741 / 31536000), 498168.00 per second
progress: 73.59% (23208860 / 31536000), 491223.80 per second
progress: 81.25% (25621794 / 31536000), 482586.80 per second
progress: 88.84% (28015952 / 31536000), 478831.60 per second
progress: 96.52% (30439208 / 31536000), 484651.20 per second
progress: 100.00% 31535999 / 31536000
simulation: 64255ms
merge: 0msMemory usage
The results with memory are a bit unexpected. In theory, a native app should take up less RAM. But in my case, the native app took up 26% more memory than the regular one.
PID△USER PRI NI VIRT RES SHR S CPU% MEM% TIME+ Command
1077262 sergey 20 0 7472 3676 3420 S 0.0 0.0 0:00.00 │ │ │ └─ bash ./tekBlueprint.infra
1077265 sergey 20 0 11.6G 510M 29644 S 0.0 1.6 0:00.00 │ │ │ └─ ./tekblueprint/infra/i PID△USER PRI NI VIRT RES SHR S CPU% MEM% TIME+ Command
1075289 sergey 20 0 9528M 697M 42984 S 0.0 2.2 0:01.01 │ │ │ └─ ./infra-cli -w ../infra-r
1075290 sergey 20 0 9528M 697M 0 S 0.0 2.2 0:00.02 │ │ │ ├─ ./infra-cli -w ../infrPerhaps this is some overhead for the native image itself. Plus, the application actively uses the Object Pool pattern, so new objects are rarely created so as not to overload the GC.
Judging by the Quarkus (Quarkus offers unequaled performance) website, everything should be the other way around in terms of memory usage.
Generating classes at runtime
There is another problem with applications that generate dynamic classes at runtime. For example, these are scripting languages or some template engines. In such cases, the compiler knows nothing about these files, and cannot know, since they are created by the user.
I did not bother to figure this out. tekBlueprint.infra is easier to use in change tracking mode, without constant
restart. Here, the native one turns out to be worse in speed.
Conclusion
It is possible to increase the execution speed of native versions of the application using Profile-Guided Optimization. Judging by the description, it just performs a similar analysis that JIT does and can use these statistics for AOT. But it is not available in the GraalVM Community Edition, so I could not check it.
AOT – Ahead-of-Time compilation. Pre-compilation of a program into machine code before the program is run.
In summary, it looks like native compilation is great for function apps where you need to do some work quickly and then stop the app. (think AWS Lambda, OpenStack Lambda). But if it’s a long-running app that runs all the time, JIT compilation might be more efficient.