Master Spring Boot 3 With GraalVM Native Image
This article covers the intricacies associated with Spring Boot Native Image development.
Join the DZone community and get the full member experience.
Join For FreeSpring Boot 3 is riding the wave in the Java world: a few months have passed since the release, and the community has already started migrating to the new version. The usage of parent pom 3.0.2 is approaching 500 on Maven Central!
An exciting new feature of Spring Boot is the baked-in support from GraalVM Native Image. We have been waiting for this moment for years. The time to migrate our projects to Native Image is now!
But one cannot simply transfer the existing workloads to Native Image because the technology is incompatible with some Java features. So, this article covers the intricacies associated with Spring Boot Native Image development.
A Paradigm Shift in Java Development
For many years, dynamism was one of the essential Java features. Developer tools written in Java, such as IntelliJ IDEA and Eclipse, are built upon the presumption that “everything is a plugin,” so we can load as many new plugins as we like without restarting the development environment. Spring is also an excellent example of a dynamic environment, thanks to such features as AOP.
Years have passed. We discovered that dynamic code loading is not only convenient but also resource-expensive. Waiting 20 minutes for a web application to start is not fun. We had to think of ways to accelerate startup and reduce excessive memory consumption. As a result, developers began abstaining from excessive dynamism and statically precompiling all necessary resources.
Then, GraalVM Native Image appeared. The technology turns a JVM-based application into a compiled binary, which sometimes doesn’t require a JDK to run. The resulting native binary starts up incredibly fast.
But Native Image works under the “closed-world assumption,” i.e., all utilized classes must be known during the compilation. So, the migration to Native Image is not about changing certain lines of code. It is about shifting the development approach. Your task is to make dynamic resources known to the Native Image at the compilation stage.
Native Image Specifics
Finalization
Developing a Spring application is not the same as writing a bare Java app. We should keep that in mind when working with Native Image. To bring Spring and Native Image together, you must dig into some Java peculiarities.
For example, let’s take a simple case of class finalization. At the beginning of Java evolution, we could write some housekeeping code in finalize()
, set the System.runFinalizersOnExit(true)
flag, and wait for the program to exit.
public class ShutdownHookedApp
{
public static void main( String[] args )
{
System.runFinalizersOnExit(true);
}
protected void finalize() throws Throwable {
System.out.println( "Goodbye World!" );
}
}
You will be surprised if you expect a “Goodbye World!” output because this code won’t run with the existing Java versions due to garbage collection specifics. With Java versions 8-10, the app will do nothing, but with Java 11, it will throw an exception with a message that this feature was deprecated:
➜ shutdown_hook_jar java -jar ./shutdown-hook.jar
Exception in thread "main" java.lang.NoSuchMethodError: void java.lang.System.runFinalizersOnExit(boolean)
at ShutdownHookedApp.main(ShutdownHookedApp.java:9)
Why was this feature removed? Finalizers work in some situations and fail in others. Developers can’t rely on a feature with such unpredictable behavior.
Native Image documentation states that finalizers don’t work and must be substituted with weak references, reference queues, or something else, depending on the situation.
For the Java platform, it is a good development trend to make unpredictable behavior a thing of the past. If you want to guarantee the String output upon exit, use Runtime.getRuntime().addShutdownHook()
:
public class ShutdownHookedApp
{
public static void main( String[] args )
{
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Goodbye World!");
}));
}
}
Spring developers have additional tools. You can use the @PreDestroy
annotation and close the context manually with ConfigurableApplicationContext.close()
or write something similar to shutdown hook registration.
You can do this instead of a finalizer:
@Component
public class WorldComponent {
@PreDestroy
public void bye() {
System.out.println("Goodbye World!");
}
}
Or you can use this instead of Shutdown Hook:
@SpringBootApplication
public class PredestroyApplication {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(PredestroyApplication.class, args);
int exitCode = SpringApplication.exit(ctx, new ExitCodeGenerator() {
@Override
public int getExitCode() {
System.out.println("Goodbye World!");
return 0;
}
});
System.exit(exitCode);
}
}
Now, let’s collect all these methods in one code snippet:
@SpringBootApplication
public class PredestroyApplication {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Goodbye World! (shutdown-hook)");
}));
ConfigurableApplicationContext ctx = SpringApplication.run(PredestroyApplication.class, args);
int exitCode = SpringApplication.exit(ctx, new ExitCodeGenerator() {
@Override
public int getExitCode() {
System.out.println("Goodbye World! (context-exit)");
return 0;
}
});
System.exit(exitCode);
}
@PreDestroy
public void bye() {
System.out.println("Goodbye World! (pre-destroy)");
}
@Override
protected void finalize() throws Throwable {
System.out.println( "Goodbye World! (finalizer)" );
}
}
Let’s run the program and see the order of our “finalizers”:
Goodbye World! (context-exit)
Goodbye World! (pre-destroy)
Goodbye World! (shutdown-hook)
This code will function when compiling a Spring app into a native image.
Initialization
Let’s set finalization aside and look into initialization for a change. Spring provides several field initialization methods: you can assign a value directly with the @Value
annotation or define properties with @Autowired
or @PostConstruct
. GraalVM adds another interesting technique, which enables you to write data at the binary compilation stage. Classes that you want to initialize this way are marked with --initialize-at-build-time=my.class
when building Native Image. The option works for the whole class, not just separate fields. It is convenient and sometimes even required (if you use Netty, for example).
Let’s build a new Spring application with Spring Initializr. The only dependency we need to specify is GraalVM Native Support.
You also need a Native Image Build Tool to generate native executables. BellSoft develops Liberica Native Image Kit, a GraalVM-based utility recommended by Spring. Download Liberica NIK for your platform here. Select NIK 22 (JDK 17), Full version.
Put the compiler to $PATH
with
GRAALVM_HOME=/home/user/opt/bellsoft-liberica
export PATH=$GRAALVM_HOME/bin:$PATH
Check that Liberica NIK is installed:
java -version
openjdk version "17.0.5" 2022-10-18 LTS
OpenJDK Runtime Environment GraalVM 22.3.0 (build 17.0.5+8-LTS)
OpenJDK 64-Bit Server VM GraalVM 22.3.0 (build 17.0.5+8-LTS, mixed mode, sharing)
native-image --version
GraalVM 22.3.0 Java 17 CE (Java Version 17.0.5+8-LTS)
Back to Spring Boot. Our application will do the following logic: we initialize a PropsComponent bean and ask it for a key
:
@SpringBootApplication
public class BurningApplication {
@Autowired
PropsComponent props;
public static void main(String[] args) {
SpringApplication.run(BurningApplication.class, args);
}
@PostConstruct
public void displayProperty() {
System.out.println(props.getProps().get("key"));
}
}
Component properties will be loaded in a static class initializer.
@Component
public class PropsComponent {
private static final String NAME = "my.properties";
private static final Properties props;
public static final String CONFIG_FILE = "/tmp/my.props";
static {
Properties fallback = new Properties();
fallback.put("key", "default");
props = new Properties(fallback);
try (InputStream is = new FileInputStream(CONFIG_FILE)) {
props.load(is);
} catch (IOException ex) {
throw new UncheckedIOException("Failed to load resource", ex);
}
}
public Properties getProps() {
return props;
}
}
Create a /tmp/my.props text file and populate it with data:
key=apple
If we build the standard Java app, we get different outputs by changing the contents of the my.props
file. But we can change the rules of the game. Let’s write the following Native Image configuration in our pom.xml
:
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<buildArgs>
--initialize-at-build-time=org.graalvm.community.examples.burning.PropsComponent
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
Pay attention to the initialize-at-build-time key. Now, build the app with mvn clean package —Pnative. The resulting file is in the target directory. No matter how many times we change the /tmp/my.properties
file, the output will be the same (the one we wrote at compilation).
On the one hand, it is an excellent tool that increases application portability if you use the properties file for code organization and not for dynamic String loading. On the other hand, it may lead to misuse and incorrect understanding of the code. For example, a DevOps may glance at the code, see the my.properties file, and then spend the whole day trying to understand why the file doesn’t pick his or her settings.
This is a fundamental concept of Native Image — the separation of data between compilation and run time. If you build app configuration based on environment variables, you should evaluate which keys will be initialized at a specific moment. For convenience’s sake, it is possible to use different prefixes, like S_
for static compilation and D_
for dynamic one.
Native Image Limitations
Some functions work differently or don’t work with GraalVM at all:
- Reflection
- Proxies
- Method Handles
- Serialization
- JNI
- Resources
One approach is to accept that they are not supported and rewrite the app accordingly. Another way is to understand what we know at compile time and put this data into config files.
Below is the example for Reflection:
[
{
"name": "HelloWorld",
"allDeclaredFields": true
}
]
To avoid manual configuration, run the app with the standard JVM and the java -agentlib:native-image-agent=config-output-dir=./config
flag. While you are using the application, all resources you are utilizing will be written into the config directory.
The agent usually generates a whole bunch of files associated with the features mentioned above:
- jni-config.json
- predefined-classed-config.json
- proxy-config.json
- reflect-config.json
- resource-config.json
- serialization-config.json
After that, state these files in pom.xml
Spring configuration:
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs>
-H:ReflectionConfigurationFiles=reflect-config.json
</buildArgs>
</configuration>
If you adjust the Reflection as shown above and then try to access something you didn’t define, the program won’t exit with an error like “Aborting stand-alone image build due to reflection use without configuration.” Instead, it will continue running, and Reflection calls will provide an empty output. It means that you must consider all Reflection calls when writing the tests.
Compatibility With Legacy Libraries
The Java ecosystem has a competitive edge over C/C++. With Java, you can add a couple of lines to the pom.xml, and Maven will load a ready-to-use library. In contrast, a C++ developer must put libraries together manually for different platforms. Classical Java enables you to use third-party libraries as ready-to-go boxes without knowing how they were developed.
The situation differs with Native Image. Because Native Image uses global code analysis, it compiles the libraries with the application code. Third-party libraries are compiled every time anew on your computer. If there is a compilation error, you will have to solve the issues related to the incompatible library.
If you develop an innovative solution, these GraalVM incompatibilities are a great way to find issues in your code or discover new ways of developing your projects. If you write hardcore fintech code, determine whether the third-party library supports GraalVM Native Image.
But let’s go back to Spring: what about Native image support there? The Spring team has done outstanding work with integrating Native Image technology into the ecosystem. Just a year ago, Spring didn’t support the technology. Then the Spring Native project was born, and now, Spring Boot has baked-in support for Native Image.
The team continues building on the momentum, and many Spring libraries and modules are already compatible with Native Image. Still, we recommend running the libraries with your code using a prototype to make sure that everything works correctly.
Development and Debugging Intricacies
Native Image compilation takes time (min. 90 seconds), so it would be more practical to write and debug the code using standard JVM and turn to Native Image only when you have some coherent results. But you should always test the resulting binary separately, even if you double-checked the JAR. Why? An application compiled with Native Image can behave differently than the “classic” JVM version. For example, you have an inexplicit Reflection somewhere in the code. It fails without an error or message, and the code gives a different result.
To accelerate the testing process, set the CI server in such a way that it collects all commits through Native Image. You can also save testers' time by providing them with Native Image binaries only, without the JARs.
In addition, DevOps engineers should write a console script that can be easily started (ondemand ./project
). It builds the project, compiles it with Native Image, packs it into a Docker image, and deploys it to a new virtual machine on Amazon. Fortunately, Spring Boot performs the whole build process with a single command: mvn clean package -Pnative. But virtual machine deployment remains your task.
Performance Profile
Legends and mysteries surround Native Image advantages. They claim it will make apps smaller and faster. But what does it mean, “smaller” and “faster”?
Two decades ago, before cloud computing and microservices, developers wrote monolithic applications only (they still prevail in some industries, such as gaming). The key performance indicators for monolithic applications are increased raw peak performance and minimal latency. The Just-in-time (JIT) compiler built into the OpenJDK is quite good at dealing with these tasks. But the performance increases only after Tier4CompileThreshold invokes the C2 JIT-compiler, which takes time. It is not optimal for cloud-native microservices.
Key performance indicators are different in the cloud:
- Microservices have to restart rapidly for efficient scaling;
- Containers must consume less resources so as not to inflate cloud bills;
- The build process must be simple simple to make the DevOps processes easier;
- Packages must be small so that developers can rapidly solve issues and move apps between the Kubernetes nodes.
The JIT compiler is not suitable for these purposes because of long warm-up time and excessive overhead.
GraalVM uses the AOT (ahead-of-time) compilation, which significantly reduces startup time. As for the memory consumption, the resulting native executable is not always smaller than Uber JAR or Fat JAR. So developers should build the project or its part with Native Image and verify whether it is worth the trouble.
There is one more thing to consider when selecting the compiler, namely the load patterns. The AOT compilation is best suitable for a “flat” load profile when application parts are loaded understandably. JIT is optimal for applications with sudden load peaks because JIT can define and optimize such loads. The choice depends on your app.
Take a look at your Spring Boot app. Find out which microservices return web pages, which work with a database, and which perform complex analytics. We can safely assume that the load profile of web services will be flatter than that of business analytics, so they potentially can be migrated to Native Image.
Garbage Collection in Native Image
Native Image is a relatively new project, so it doesn’t utilize the variety of garbage collectors compared to OpenJDK. GraalVM Community Edition (and Liberica NIK) currently use only simple Serial GC with generations (generational scavenger). Oracle’s GraalVM Enterprise also has a G1 GC.
The first thing to note is that Native Image uses more memory than stated in the Xmx parameter. A standard JVM-based app does that too, but for different reasons. In the case of Native Image, the root cause resides in garbage collection specifics. GC uses additional memory when performing its tasks.
If you run your application in a container and set the precise amount of memory in Xmx, it will probably go down when loads increase. Therefore, you should allocate more memory. Use a trial-and-error approach to find out how much.
Furthermore, if you write a tiny program, it doesn’t mean it will automatically use less memory. Like with JVM, we have Xmx (maximum heap size in bytes) and Xmn (young generation size) parameters. If you don’t state them, the app may devour all available memory within the limit. You can alleviate the situation with the -R:MaxHeapSize
parameter that sets the default heap size at build time.
But thanks to these Native Image specifics, we can now conveniently write console applications with Spring Boot. Imagine you want to create a console client for your web service. The first thought that comes to mind is to develop a Spring Boot app and reuse the whole Java code, including classes for API. But such an application would take several seconds to start without a chance for acceleration with JIT because there’s too little runnable code in console apps to trigger JIT. And every application start would consume a lot of RAM.
Now you can compile the app with Native Image, set -R:MaxHeapSize
, and get a good result, no worse than with standard Linux console commands.
For illustrative purposes, I wrote a console jls utility with the same function as ls, i.e., listing the files. The algorithm is borrowed from StackOverflow.
@SpringBootApplication
public class JLSApplication {
public static void main(String[] args) {
SpringApplication.run(JLSApplication.class, args);
walkin(new File(args[0]));
}
public static void walkin(File dir) {
File listFile[] = dir.listFiles();
if (listFile != null) {
for (int i=0; i<listFile.length; i++) {
if (listFile[i].isDirectory()) {
System.out.println("|\t\t");
walkin(listFile[i]);
} else {
System.out.println("+---"+listFile[i].getName().toString());
}
}
}
}
}
Define the max. heap size in Maven settings:
<configuration>
<buildArgs>
-R:MaxHeapSize=2m
</buildArgs>
</configuration>
According to the time utility, the execution time for the /tmp
directory is about 0.02 s, which is within the time margin of error. And that time includes the start of the algorithm plus the whole Spring Boot app. The result is quite impressive compared to a JAR file.
Conclusion
Finally, we can compile our Spring Boot projects with Native Image! This powerful utility makes it possible to perform tasks previously unattainable for Java developers.
Published at DZone with permission of Dmitry Chuyko. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments