Spring Boot 3.0.0-M5 was released in September. It includes numerous essential improvements, including the baked-in support for GraalVM Native Image. Although Native Image technology is gaining popularity, many developers are cautious about using it with their Spring Boot applications. In this article, we look into what needs to be configured and how to avoid migration issues with native images.
Table of Contents
What’s new in Spring Boot 3.0?
Spring Boot 3.0 is the first major version in 4.5 years and the first Spring Boot GA (general availability) version that supports Spring Framework 6.0 and GraalVM. The release contains 44 new features and more than a hundred other enhancements and bug fixes. Let us highlight the most noteworthy changes:
- GraalVM Native Image support. The initial support for native images was added to Spring Boot in the form of Spring Native, released as a beta in March 2021. Starting with Spring Boot 3.0, native support has moved to General Availability, meaning that support for GraalVM Native Image supersedes the Spring Native project. You can now convert Spring Boot apps into native executables using the standard Maven or Gradle plugins without special configuration.
- Java 17 baseline. Spring Boot 3.0 requires JDK 17 to run, so you need to upgrade your JDK version if you use Java LTS 8 or 11.
- Migration to Jakarta EE 9. A bit of background information: Oracle transferred the rights to Java EE (a set of specifications extending the Java SE with enterprise features) to the Eclipse Foundation in 2017. Java EE was subsequently renamed Jakarta EE. Because of that the package namespace changed from javax.* to jakarta.*, so you need to adjust the imports for the code to work correctly.
- Observability enhancement. The observability support went GA in this Spring Framework release. It is now possible to record application metrics and implement tracing with Micrometer (also received numerous updates) and its extension, Micrometer Tracing.
- Improved auto-configuration. The Spring Data JDBC auto-configuration has become more flexible: some auto-configured beans are now conditional and can be replaced by defining a bean of the same type.
In addition, several features were removed, deprecated, or substituted with newer functionality.
As it is a major release, the migration will take some effort. First of all, you need Java 17+ to work. Secondly, if you are using older Spring Boot versions, it is recommended to migrate to Spring Boot 2.7 and then move to version 3.0 following the migration guide. Make sure that the third-party dependencies you use have Jakarta EE 9 compatible versions. Lastly, check for deprecated features — they will be removed in the next major release, so prepare your project in advance.
As for the native images, there are two ways to use the GraalVM functionality:
- Through buildpacks
- With a Native Build tool
Liberica Native Image Kit is a GraalVM-based native-image compiler used by default with Cloud Native Buildpacks. It is also recommended by Spring as a Native Build tool. For your convenience, we implemented several methods of installing Liberica NIK on your machine. You can download it directly from our website, pull it from Docker Hub and Linux repositories, or utilize package managers or REST API.
Liberica NIK is compatible with a wide variety of system configurations and is always based on the latest release of Liberica JDK (11 or 17) and GraalVM (21 or 22) with security patches, bug fixes, and other enhancements.
Benefits of native images
Native image is a platform-dependent standalone executable of a Java application. It contains classes of the application, dependencies, runtime, and statically linked code from the JDK. It doesn’t require a JVM but includes components from Substrate VM — Graal’s framework for compiling Java programs into self-contained executables.
Native images are created using Ahead-of-time (AOT) compilation. It works like this: the compiler optimizes the code and translates it to machine code before program execution, thus eliminating the issue of slow startup. The resulting image doesn’t contain unused code and dependencies, so it requires less RAM.
Consequently, native images have the following advantages:
- Almost immediate startup without warm-up helps to minimize cold starts and make use of such services as AWS Lambdas without affecting user experience
- Optimized memory consumption is optimal for dense deployments and lightweight container images
- Smaller attack surface due to the lack of unnecessary components enhances security
Building native images from Spring boot apps
Installing Liberica NIK
It would be best to utilize a powerful computer with several gigabytes of RAM to work with native images. Opt for a cloud service provided by Amazon or a workstation so as not to overload the laptop. We will be using Linux bash commands further on because bash is a perfect way of accessing the code remotely. macOS commands are similar. As for Windows, you can use any alternative, for instance, bash included in the Git package for Windows.
Download Liberica Native Image Kit for your system. Choose a Full version for our purposes.
Unpack tar.gz with
tar -xzvf ./bellsoft-liberica.tar.gz
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)
You can then use Maven commands with the JAVA_HOME=<path to NIK Home directory> prefix.
If you get the error "java: No such file or directory" on Linux, you installed the binary for Alpine Linux, not Linux. Check the binary carefully.
Creating a Spring Boot project
The easiest way to create a new Spring Boot project is to generate one with Spring Initializr. Select Java 17, Maven, JAR, and Spring SNAPSHOT-version (3.0.2 at the time of writing this article), then fill in the fields for project metadata. We don’t need any dependencies.
Build the project and verify that everything is working:
time java -jar ./target/native-image-demo-0.0.1-SNAPSHOT.jar
real 0m1.404s
user 0m4.883s
sys 0m0.169s
It takes only a second for Spring to start up, but we can do even better with GraalVM Native Image.
Let’s build the project with the following command:
JAVA_HOME=<path to NIK Home directory> ./mvnw -Pnative native:compile
The resulting native image is in the target directory.
Check how fast the native image starts up:
time /home/username/test/native-image-demo/target/native-image-demo
real 0m0.026s
user 0m0.013s
sys 0m0.013s
The startup is several times faster than with a standard Spring JAR.
Migration to Native Image: what could go wrong
The AOT compilation happens under the closed-world assumption,i.e., the bytecode and application dependencies that can be called at runtime must be known to the AOT compiler at build time. The compiler puts only reachable methods into the native executable. As a result, applications may behave unexpectedly or throw runtime errors.
The following features must be specified in the configuration file at build time, or else the native image builder produces a fallback file at runtime:
- Dynamic class loading
- Reflection
- Dynamic proxy
- JNI
- Serialization
In addition, debugging and monitoring is impossible with the JVM tools, so you need native debuggers and monitoring tools. The invokedynamic instruction and method handles are not supported by native image, except for cases when invokedynamic is generated by javac for some instances, such as lambda expressions, because there are no changes to called methods at runtime.
For more information on feature compatibility, refer to the official GraalVM Reference manual.
Practical guide to solving compatibility issues
Building a simple Spring Boot application
Let us first analyze the possible compilation issues using a standard JAR file, and after that, we will move to Spring Boot.
First, we will create a simple HelloWorld app and verify that native image functions properly.
Below is a useful trick for writing files directly in the console — you need to put the source code between <<EOL
and EOL
:
cat >./HelloWorld.java <<EOL
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
EOL
Usually, we use the following command for running a Java program in the console:
java -cp . HelloWorld.java
But there’s a traditional way of creating JAR files manually, without IDE or Maven/Gradle:
javac HelloWorld.java
jar -cfe HelloWorld.jar HelloWorld HelloWorld.class
java -jar HelloWorld.jar
native-image -jar ./HelloWorld.jar
It is better to use the full packaging command in scripts and build the project in a separate directory:
mkdir ./build || :
javac -d build HelloWorld.java
jar --create --file ./build/HelloWorld.jar --main-class HelloWorld -C build .
java -jar ./build/HelloWorld.jar
native-image -jar ./build/HelloWorld.jar
Lastly, if you want to use your own manifest, use the following command:
jar -cvfm HelloWorld.jar HelloWorldManifest.txt HelloWorld.class
Issues with Java Reflection in Native Image
Java Reflection most frequently causes troubles when switching to Native Image. Reflection was previously used in numerous applications, even when it wasn’t necessary. This trend has declined with the dawn of Native Image, but we still have to refactor the legacy code.
All we need to do to break the program compilation is to inject a simple Reflection call. Let’s get a list of class fields:
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class HelloWorld {
private String field = "field example";
public static void main(String[] args) {
Field[] fields = HelloWorld.class.getDeclaredFields();
List<String> names = getFieldNames(fields);
System.out.println(Arrays.toString(names.toArray()));
}
private static List<String> getFieldNames(Field[] fields) {
List<String> fieldNames = new ArrayList<>();
for (Field field : fields)
fieldNames.add(field.getName());
return fieldNames;
}
}
If we run the app with a standard java -jar HelloWorld.jar
, we will get the expected [field]
. But if we try using Native Image, we will get the following error:
Warning: Reflection method java.lang.Class.getDeclaredFields invoked at HelloWorld.main(HelloWorld.java:10)
We can tell Native Image, which fields we are going to read:
cat >./reflect-config.json <<EOL
[
{
"name": "HelloWorld",
"allDeclaredFields": true
}
]
EOL
native-image -H:ReflectionConfigurationFiles=reflect-config.json -jar ./build/HelloWorld.jar
It seems to have solved the problem, but what should we write in the configuration file? And does it mean we have to manually define every access point?
Gladly, we have Java Agent that can perform this task automatically. We should simply run the app with the Agent and feed the result to Native Image:
java -agentlib:native-image-agent=config-output-dir=./config -jar ./build/HelloWorld.jar
native-image -H:ConfigurationFileDirectories=./config -jar ./build/HelloWorld.jar
The program will successfully compile and give out the desired result thanks to the config directory now having all the files we otherwise would have to state manually:
- jni-config.json
- predefined-classed-config.json
- proxy-config.json
- reflect-config.json
- resource-config.json
- serialization-config.json
A more complex Reflection case
The Agent writes only those access points that were used during app execution. Suppose we have an application that utilizes Reflection differently in various cases. In the example below, there are two paths depending on the parameter value, 1 or 2.
public class HelloWorld2 {
public class A {
private String field1 = "field 1";
}
public class B {
private String field2 = "field 2";
}
public static void main(String[] args) {
Class<?> cls = null;
if (args.length < 1) {
return;
}
String selector = args[0];
switch(selector) {
case "1": {
cls = A.class;
break;
}
case "2": {
cls = B.class;
break;
}
}
if (null != cls) {
Field[] fields = cls.getDeclaredFields();
List<String> names = getFieldNames(fields);
System.out.println(Arrays.toString(names.toArray()));
}
}
private static List<String> getFieldNames(Field[] fields) {
List<String> fieldNames = new ArrayList<>();
for (Field field : fields)
fieldNames.add(field.getName());
return fieldNames;
}
}
Let’s try to compile the app with Native Image:
mkdir ./build || :
javac -d build HelloWorld2.java
jar --create --file ./build/HelloWorld2.jar --main-class HelloWorld2 -C build .
java -jar ./build/HelloWorld2.jar
native-image -jar ./build/HelloWorld2.jar
The result will be:
Warning: Reflection method java.lang.Class.getDeclaredFields invoked at HelloWorld2.main(HelloWorld2.java:34)
Warning: Aborting stand-alone image build due to reflection use without configuration.
Failed generating 'HelloWorld2' after 15.9s.
Generating fallback image...
Warning: Image 'HelloWorld2' 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).
The error message is quite explicit. We have the HelloWorld2 binary, but we can’t use it without JDK.
The Agent creates the following configuration:
java -agentlib:native-image-agent=config-output-dir=./config2 -jar ./build/HelloWorld2.jar
cat ./config2/reflect-config.json
The config file contains []
. In other words, it is empty because the Agent didn’t find any Reflection.
We need to run it several times adding different execution branches. There is a config-merge-dir option to merge multiple results of config generation:
java -agentlib:native-image-agent=config-merge-dir=./config2 -jar ./build/HelloWorld2.jar 1
java -agentlib:native-image-agent=config-merge-dir=./config2 -jar ./build/HelloWorld2.jar 2
We now have more sound data in the ./config/reflect-config.json file:
[
{
"name": "HelloWorld2$A",
"allDeclaredFields": true
},
{
"name": "HelloWorld2$B",
"allDeclaredFields": true
}
]
Let’s compile the binary:
native-image -H:ConfigurationFileDirectories=./config2 -jar ./build/HelloWorld2.jar
The result is:
Produced artifacts:
/home/username/test/ni/HelloWorld2 (executable)
/home/username/test/ni/HelloWorld2.build_artifacts.txt (txt)
====================================================================================================
Finished generating 'HelloWorld2' in 29.8s.
How did GraalVM understand that the config file contents fully cover all Reflection calls? Could there be a call that we missed?
Let’s add one more class and execution path:
public class C {
private String field3 = "field 2";
}
case "3": {
cls = C.class;
break;
}
Try to compile an image:
Produced artifacts:
/home/username/test/ni/HelloWorld2 (executable)
/home/username/test/ni/HelloWorld2.build_artifacts.txt (txt)
====================================================================================================
Finished generating 'HelloWorld2' in 30.3s.
Something went wrong! Native Image thinks it’s the right configuration, but it’s not — we made it wrong intentionally. So it must break. How?
username@server:~/test/ni$ ./HelloWorld2 1
[field1, this$0]
username@server:~/test/ni$ ./HelloWorld2 2
[field2, this$0]
username@server:~/test/ni$ ./HelloWorld2 3
[]
The code was executed without any errors or segfaults, but the result is wrong. It should be [field3, this$0]
instead of []
. The getDeclaredFields method will always give the empty list. We can solve the issue only through recompilation.
The above example shows perfectly well the issues the developers will have to face if they wish to utilize GraalVM. It seems easy in theory, but the devil is in the details.
Porting the old school code to Spring
First of all, let’s make our Spring Boot app, which we created in the “Building native images” section, run some meaningful code similar to our console HelloWorld program:
package sample.username.nativeimagedemo;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class HelloWorld {
@EventListener(ApplicationReadyEvent.class)
public void doSomethingAfterStartup() {
System.out.println("Hello World!");
}
}
If we run this application, it will successfully print out “Hello World!” both as a JVM-based version and as a binary.
The next step is to port our application printing out the class fields to Spring:
@Component
public class HelloWorld {
private String field = "field example";
public static void run() {
Field[] fields = HelloWorld.class.getDeclaredFields();
List < String > names = getFieldNames(fields);
System.out.println(Arrays.toString(names.toArray()));
}
private static List < String > getFieldNames(Field[] fields) {
List < String > fieldNames = new ArrayList < > ();
for (Field field: fields)
fieldNames.add(field.getName());
return fieldNames;
}
@EventListener(ApplicationReadyEvent.class)
public void doSomethingAfterStartup() {
run();
}
}
After running the code with JVM, we get the expected result.
NativeImageDemoApplication : No active profile set, falling back to 1 default profile: "default"
NativeImageDemoApplication : Started NativeImageDemoApplication in 0.671 seconds (process running for 1.055)
[field]
But all is not well with Native Image:
username@server:./mvnw -Pnative clean package
username@server:/home/username/test/native-image-demo/target/native-image-demo
Started NativeImageDemoApplication in 0.014 seconds (process running for 0.017)
[]
The result is similar to the one we got with the console program after creating different execution paths without adding some of them to the config file.
The output is []
, which means that Native Image has no access to the class fields through Reflection and therefore thinks that these fields do not exist. At the same time, we don’t get an explicit error message like with the console program. Spring can magically make the app compile, but without guarantees of correct execution.
Let’s create the reflect-config.json file in the root:
[
{
"name": "sample.username.nativeimagedemo.HelloWorld",
"allDeclaredFields": true
}
]
After that, include it into the native-maven-plugin parameters:
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs>
-H:ReflectionConfigurationFiles=reflect-config.json
</buildArgs>
</configuration>
Recompile the project:
username@server:./mvnw -Pnative clean package
username@server:/home/username/test/native-image-demo/target/native-image-demo
Started NativeImageDemoApplication in 0.03 seconds (process running for 0.038)
[field]
The native-image parameters now go through the plugin configuration. How do we generate config files with access points?
The easiest way is to run JAR on JVM the old school way and generate the directory with the config files.
./mvnw clean package
java -agentlib:native-image-agent=config-output-dir=./config -jar /home/username/test/native-image-demo/target/native-image-demo-0.0.1-SNAPSHOT.jar
Include the file into build parameters:
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs>
-H:ConfigurationFileDirectories=./config
</buildArgs>
</configuration>
Recompile the project:
username@server:./mvnw -Pnative clean package
username@server:/home/username/test/native-image-demo/target/native-image-demo
Started NativeImageDemoApplication in 0.029 seconds (process running for 0.038)
[field]
In theory, we can automate the process with Maven. But in reality, we don’t need to do that because later on we will have to introduce changes into those files manually.
Conclusion
Spring Boot 3 can now compile native images out of the box. But we have to understand that even if Spring can magically compile the app without errors, there are no guarantees it will function properly. Spring Boot development with Native Image is an exciting but extensive field for Spring developers to master.
In this article we evaluated only a single case of resolving the issues associated with migration to Native Image. Subscribe to our newsletter, so you don’t miss other guides on migrating Spring Boot apps to Native Image with Liberica NIK.