Super-fast Java with Spring Boot Native
Above the horizon of the Java ecosystem, a new star called “GraalVM” has started to shine. Are you tired of slow startups of your Java microservices, i.e. the time from starting the process to the service readiness seems to be too long? You have come to the right place. We decided to evaluate GraalVM by using it in one of our Spring applications, which has been developed for a few years. It was an experiment to determine if the migration is worth being considered and in which scenarios. In the article, we briefly present GraalVM and describe our observations about the challenges, problems and limitations related to it.
GraalVM – what is it?
On the project page, we can find a bunch of pretty definitions like:
- “GraalVM is a high-performance JDK distribution designed to accelerate the execution of applications written in Java”
- “GraalVM is a high-performance runtime that provides significant improvements in application performance and efficiency which is ideal for microservices.“
After reading above definitions it could not be clear what the GraalVM actually is.
From the perspective of Java/Spring developer, by “GraalVM” we can mean two things:
- JDK distribution – a Java VM and JDK based on HotSpot/OpenJDK which uses a GraalVM JIT compiler. GraalVM documentation states “GraalVM’s high-performance JIT compiler generates optimized native machine code that runs faster, produces less garbage, and uses less CPU thanks to a battery of advanced compiler optimizations and aggressive and sophisticated inlining techniques.”
- GraalVM native image – a technology to compile Java code ahead-of-time to a binary. It allows you to compile your Java/Spring/Hibernate etc. application into a native build, which is run as a regular executable — without JVM! This article is focused on this aspect of GraalVM.
GraalVM – native image
Previously, in the pre-GraalVM era, when we were talking about the native code in the context of the Java language, we have meant the native code generated by just-in-time compiler in the JVM during the Java application execution.
The standard scenario for many Java applications looks as follows (major simplifications ahead):
- Java source code is compiled to bytecode
- The bytecode is packaged to a .jar archive
- The .jar archive is being executed on the JVM
- JVM loads the application to the memory
- JVM part called ‘Execution Engine’ compiles the bytecode to machine code in runtime by just-in-time compiler (machine code refers to machine language instructions that are directly executable on existing CPU hardware)
- JVM executes the compiled machine code
The flow described above is a reason why Java is called platform-independent – the Java bytecode is handled by JVM supporting the given CPU architecture.
The GraalVM native image is a technology that allows compiling a Java source code ahead of time to native code (machine code) / native executable which could be directly executed on the existing hardware. It allows creating an application with faster startup, reduced memory consumption and application size in comparison to a standard Java application.
Why is it becoming more and more popular? What are the pros and cons of native image?
The first version of GraalVM and native-image tool was released around 2019. Since then, it is becoming more and more popular. Let’s look at the “popularity statistics” measured in the number of stars given to the GitHub repository.
Pros
There are a multiple answers to the question “Why is it becoming more and more popular?”:
- Improved startup time – The native executable has significantly lower startup time than the standard Java application run on the JVM. In the case of our application, the startup time has been decreased about 13 times (~13.5s -> ~1s).
- Reduced memory consumption – Due to the fact that Java code is compiled ahead-of-time to machine code, there is no JVM overhead.
- Size – The unused code is stripped from the native executable, so depending on the application characteristics the size of native executable could be reduced. In our case, the one Spring application docker image size has been reduced from 501MB to 159MB.
It seems like “must have” technology in case of Java serverless services (e.g. Lambda).
Cons
Unfortunately, this technology has at least few limitations:
- Increased building time – in case of one of our Spring applications, the building time of the “standalone app” has been increased about 12 times (28.4s => 6min 11s, building time of the jar file / native executable, the time does not include the tests and docker image building time).
- Limited debugging support – options of debugging a Java native application are limited in comparison to debugging options of standard jar applications. If we want to debug native executable “line by line” from the IDE, we have to generate a native image with additional debug informations and use the special extension for VisualCode (which at the time of writing this article is supported only on Linux, has initial support for macOS and does not seem to work on Windows at all). The other approaches and types of debugging could be found in the documentation.
- The compiled application losing platform independence – The native executable is created for a specific operating system and architecture. When we want to run our application on the x86 and ARM architecture, we have to compile our application two times.
- Limited support for dynamic code generation, reflection, dynamic proxy – GraalVM requires additional config for code which uses such mechanisms.
Full information about the limitations can be found on the GraalVM native image project page and (in case of Spring native image support) Spring Boot native image limitations.
Main challenges encountered when working with GraalVM native image
The code reachability problem
The significant number of challenges, problems and limitations of this technology relates to the “code reachability problem”. The comprehension of this problem is crucial in order to understand this technology from a developer perspective.
During compilation of Java code into a native executable GraalVM performs a point-to-point analysis, starting from application entry point. Native-image tool determines which parts of the code are reachable and used at runtime. The native code is generated only for reachable code, so when any part of the code is considered by GraalVM native-image tool as not reachable it would not be placed in the native executable.
What does it mean that “code is reachable”? Let’s consider following application:
public class TratifApplication {
static class Human {
void flyIntoSpace() {}
void drinkCoffee() {}
}
public static void main(String[] args) {
Human human = new Human();
human.drinkCoffee();
}
}
In the above simplified example:
drinkCofee()
method is reachable because it is called from the main method.flyIntoSpace()
method is not reachable, it is not referenced from any place in the application.
In the target native executable, the Human
class will contain only a drinkCoffee()
method. If in any other place in the code someone will try to invoke a flyIntoSpace()
method using reflection, the NoSuchMethodException
will be thrown.
The method invocation through the reflection mechanisms can sound as something unnatural. These mechanism is typically used by frameworks like Spring which leverages reflection for beans initialization, dependency injection and to provide AOP (aspect-oriented programming) support.
GraalVM allows defining a “reachability metadata”. The metadata in which we can configure which not-reachable code should be added to native executable. Some frameworks provide solutions which prepare such metadata config out-of-the-box, others require a manual configuration.
3rd party libraries support
Many popular Java libraries and frameworks already support compilation to native-executable out-of-the-box, but unfortunately not all of them. In case of libraries which don’t support this type of compilation, we should prepare a manual configuration (for mechanisms like: dynamic proxy, reflection, dynamic class loading).
Sample libraries which do not fully support compilation to native image:
- Jetty (on the GH there are not-resolved issues related to this topic, e.g.: #11122)
- Specification Argument Resolver (adding support is in progress: #140)
- AWS JAVA SDK v1 (GraalVM native image is supported since v2 version)
- Flyway migrations embedded in Spring application (#33458)
Oracle maintains a GitHub repository which provides a “reachability metadata” for libraries which don’t provide such metadata (e.g. reflection & dynamic proxies config), so even if a given library does not support compilation to native executable out of the box, you can check the following repo: graalvm-reachability-metadata – maybe someone already prepared and shared a reachability configuration for this library.
Double checking test code
The process of compiling the Java code to the native executable depending on the situation can introduce some changes in the application code (e.g. removal of the potentially unnecessary code).
Let’s consider the following application:
class CreateUserDto {
private String firstName;
private String lastName;
@JsonCreator
public CreateUserDto(@JsonProperty("firstName") String firstName,
@JsonProperty("lastName") String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
@Override
public String toString() {
return "CreateUserDto{firstName='" + firstName + "\', lastName='" + lastName + "\'}";
}
}
@SpringBootApplication
public class SpringnativedemoApplication {
private static final Logger log =
LoggerFactory.getLogger(SpringnativedemoApplication.class);
@RestController
static class UserController {
/*
* We are aware that below code it is not a "clean Spring Boot code",
* however, it perfectly shows the problem described in this paragraph.
*/
@PostMapping("/users")
public void handleCreateUserRequest(
HttpServletRequest httpServletRequest
) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
CreateUserDto createUserDto = objectMapper.readValue(
httpServletRequest.getInputStream(),
CreateUserDto.class
);
log.info("Received: {}", createUserDto);
}
}
public static void main(String[] args) {
SpringApplication.run(SpringnativedemoApplication.class, args);
}
}
with passing test:
@WebMvcTest(SpringnativedemoApplication.UserController.class)
class SpringnativedemoApplicationTests {
@Autowired
MockMvc mockMvc;
@Test
void shouldReturnHttp200OkStatusWhenCreateNewUser() throws Exception {
String requestBody = """
{
"firstName": "Jan",
"lastName": "Kowalski"
}
""";
mockMvc.perform(post("/users")
.content(requestBody)
.contentType("application/json")
).andExpect(status().isOk());
}
}
When the above test is performed for the “JVM application version” (the application version that is running on JVM – e.g. from the IDE), the test passes.
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.227 s
- in com.tratif.springnative3.springnativedemo.SpringnativedemoApplicationTests
However, when we compile this application to native-code and will try to send valid POST request to /users endpoint we will get the HTTP 500 Internal Server Error:
Request:
curl -X POST http://localhost:8080/users \
-d '{"firstName": "Jan", "lastName": "Kowalski"}' \
-H "Content-Type: application/json"
Response:
{
"timestamp": "2023-01-04T11:24:57.839+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/users"
}
App logs:
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `com.tratif.springnative3.springnativedemo.CreateUserDto`:
cannot deserialize from Object value (no delegate- or property-based Creator):
this appears to be a native image, in which case you may need to configure reflection
for the class that is to be deserialized
at [Source: (org.apache.catalina.connector.CoyoteInputStream); line: 1, column: 2]
The reason for this is the fact that during application compilation GraalVM determined the constructor of CreateUserDto
as not reachable. In result it wasn’t compiled to native code. To detect such problems during the development, we should run our tests in a native image (the instructions on how to do this could be found for example in Spring Boot documentation).
When we perform our test in native image we will see:
com.tratif.springnative3.springnativedemo.SpringnativedemoApplicationTests > shouldCreateNewUser() FAILED
Failures (1):
JUnit Jupiter:SpringnativedemoApplicationTests:shouldCreateNewUser()
MethodSource
[
className ='com.tratif.springnative3.springnativedemo.SpringnativedemoApplicationTests',
methodName = 'shouldCreateNewUser',
methodParameterTypes = ''
]
=>
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `com.tratif.springnative3.springnativedemo.CreateUserDto`:
cannot deserialize from Object value (no delegate- or property-based Creator):
this appears to be a native image, in which case you may need to configure reflection for the class that is to be deserialized
at [Source: (org.springframework.mock.web.DelegatingServletInputStream); line: 2, column: 7]
[...]
That’s why it is important to have a high test code coverage and high test quality – of course we all already know it, however, usage of native-image provides us with a next example why we should aim for this.
Fixed classpath
The application classpath is fixed at the native executable build time. So there is no way to add additional configuration files or libraries to classpath after application compilation.
Let’s assume that we have a Spring boot application which defines following additional source of properties in following way:
@PropertySource("classpath:integration-with-optional-service.properties")
On the server there are two directories with two alternative configurations:
/config/standard-plan/integration-with-optional-service.properties
/config/premium-plan/integration-with-optional-service.properties
and depending on the scenario we would run an application with one of above configurations. Previously, we did this via defining additional classpath files using the -Dloader.path
Spring parameter (java -jar app.jar -Dloader.path=/config/standard-plan
). After compiling the Spring boot application to native executable it is no longer possible. Instead of this, we can define an additional source of properties using a --spring.config.location
parameter using the absolute paths to properties (java -jar app.jar --spring.config.location=/config/standard-plan/integration-with-optional-service.properties
).
Spring Profiles & @ConditionalOnProperty
Both Spring profiles and conditional beans are not supported in Spring boot compiled to native executable (#1613). So in cases where there is a need to use them, the conditional logic should be implemented in another way depending on our scenario.
Final thoughts
GraalVM native-image looks like a very promising technology especially in the context of applications based on the microservice architecture. We think that in some scenarios it could be a real game-changer. We hope that we will have an opportunity to join the GraalVM community and use this technology in our future projects.