Block Image

With version 3 of Spring Boot, support for native image generation was officially added, suspending the experimental Spring Native project.
So I thought I would generate a native image from an existing Spring Boot application, see if it would work all out-of-box and perform load tests on both the JVM and native version of the application.
I hope my experience will be useful to you.

The Spring Boot application used is the same as in this article:
Virtual Threads vs WebFlux: who wins?.
It performs common operations: data retrieval from a database and REST call to an external service.

Compared to the linked article, the JVM and GraalVM applications will be containerized.

I will first try to create the native image and run it without changing the application code, let's see what happens.

For both the classic and GraalVM containerized applications, I will not set Heap parameters (such as -Xms and -Xmx), because I want to see the default behavior.

Two lines about native images

Native images are platform-specific executables that can be started without the aid of the JVM. They are lighter applications that consume fewer resources than applications that make use of the JVM. They also promise much better boot times. To work, native images require static code analysis. In the build phase, GraalVM must know all the classes, Spring beans, and properties that the application will use at runtime. This processing analysis of the code, which takes place in the build phase, can take a lot of time and resources, compared to those of a classic application with a JVM.

In addition, knowing in advance which beans to use leads to some limitations that native images have; for example, the @Profile and @ConditionalOnProperty annotations cannot be used since they drive at runtime the activation of beans.

We mentioned at the beginning of the paragraph that native images are platform-specific executables. This implies that if you create a native executable on Windows, the latter will not be bootable by Linux. But then don't we lose the much cherished portability of the JVM?
In theory yes, in practice no! Since container technology has become widespread, we do not need the portability of the JVM, because already containers are portable. In fact, you have two ways to create a native image:

  1. by creating a native executable with a JDK GraalVM (not portable)
  2. creating a container with the help of Cloud Native Buildpacks (portable).

If you want to learn more about GraalVM, I recommend reading this article: GraalVM Native Image Support.

Starting the application. Does everything work out-of-box?

Before using the AWS EC2 test machine, I try the native image creation and execution locally.
I create the native image with the command:

./mvnw clean spring-boot:build-image -DskipTests -Pnative

I start the native image with the command:

docker run --name reactive -p8080:8080 -p1099:1099 \
-e SPRING_R2DBC_USERNAME=myuser -eSPRING_R2DBC_PASSWORD=secret \
-e SPRING_R2DBC_URL=r2dbc:postgresql://<ip_pg>:5432/mydatabase \
-e CUSTOM_MOCKCLIENT_URL=<url_mock_client> \
-e JAVA_TOOL_OPTIONS="-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1099 -Dcom.sun.management.jmxremote.rmi.port=1099 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.local.only=false -Djava.rmi.server.hostname=<ip>" \
-d reactive:0.0.1-SNAPSHOT

Unfortunately, the application does not start, as there is the following error:

***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to configure a ConnectionFactory: 'url' attribute is not specified and no embedded database could be configured.

Reason: Failed to determine a suitable R2DBC Connection URL

Yet I have valued the environment variable SPRING_R2DBC_URL!
I came up against this issue: SpringNative#Issue#1470

It is not enough to value the environment variable, the application must know a value at build time. Although this value will eventually be overwritten by the environment variable. I therefore added the datasource props in the application.properties file, like this:

spring.r2dbc.url=
spring.r2dbc.username=
spring.r2dbc.password=

As a value they have empty string, but it is enough to have no errors.

I re-create the image and start the container again. From the logs I read:

main] c.v.reactive.ReactiveApplication         : Started ReactiveApplication in 2.047 seconds (process running for 2.063)

The application started correctly!

I try to invoke the REST service with Postman. I get a 500. Let's see the logs:

 WARN 1 --- [or-http-epoll-6] r.netty.http.client.HttpClientConnect    : [475a10ef-1, L:/172.17.0.2:53468 - R:host.docker.internal:8092] The connection observed an error

org.springframework.web.reactive.function.UnsupportedMediaTypeException: Content type '' not supported for bodyType=com.vincenzoracca.reactive.model.NameDTO

Effectively, the application read correctly from DB, but failed to invoke the downstream service.
The offending method is this:

public Flux<UserDTO> mapSurnamesInUpperCase(String name, String surname) {
    long start = Instant.now().toEpochMilli();
    log.info("mapSurnamesInUpperCase started with parameters name: {}, surname: {}", name, surname);
    return userJDBCRepository.findAllByNameAndSurname(name, surname)
            .flatMap(user -> webClient.post()
                    .uri(configProperties.getMockclientUrl() + "/upper")
                    .bodyValue(new NameDTO(user.surname()))
                    .retrieve()
                    .bodyToMono(NameDTO.class))
            .map(nameDTO -> new UserDTO(name, nameDTO.name()))
            .doOnComplete(() -> log.info("mapSurnamesInUpperCase terminated: {} with parameters name: {}, surname: {}", Instant.now().toEpochMilli() - start, name, surname));

}

Specifically, the offending piece of code is this:
.bodyToMono(NameDTO.class)).

Here GraalVM needs "help" since that method uses reflection. From the Spring documentation, I found this annotation that fits the bill: @RegisterReflectionForBinding.
The method becomes:

@RegisterReflectionForBinding(NameDTO.class)
public Flux<UserDTO> mapSurnamesInUpperCase(String name, String surname) {
    long start = Instant.now().toEpochMilli();
    log.info("mapSurnamesInUpperCase started with parameters name: {}, surname: {}", name, surname);
    return userJDBCRepository.findAllByNameAndSurname(name, surname)
            .flatMap(user -> webClient.post()
                    .uri(configProperties.getMockclientUrl() + "/upper")
                    .bodyValue(new NameDTO(user.surname()))
                    .retrieve()
                    .bodyToMono(NameDTO.class))
            .map(nameDTO -> new UserDTO(name, nameDTO.name()))
            .doOnComplete(() -> log.info("mapSurnamesInUpperCase terminated: {} with parameters name: {}, surname: {}", Instant.now().toEpochMilli() - start, name, surname));

}

I recreate the image and start the container. This time I get a 200! It works!

Before I start load tests on the AWS EC2 machine, I check that the JMX connection works. And no, it does not work.
This is because the JAVA_TOOL_OPTIONS environment variable is not available in a native image.
From this issue I note with pleasure that with JDK 22 the variable NATIVE_IMAGE_OPTIONS will be available.

However, no problem, I can insert Java properties as arguments to the docker run command, like this:

docker run --name reactive -p8080:8080 -p1099:1099 \
-e SPRING_R2DBC_USERNAME=myuser -eSPRING_R2DBC_PASSWORD=secret \
-e SPRING_R2DBC_URL=r2dbc:postgresql://<ip_pg>:5432/mydatabase \
-e CUSTOM_MOCKCLIENT_URL=<url_mock_client> \
-d reactive:0.0.1-SNAPSHOT -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1099 -Dcom.sun.management.jmxremote.rmi.port=1099 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.local.only=false -Djava.rmi.server.hostname=<ip>

Now the JMX connection works too, I can finally start testing!

Test preparation data

  • Machine used: AWS t3.micro (2 vCPU, 1 GiB di RAM) with Amazon Linux 2023 operating system.
  • Database: Postgres table with about 1800 records.
  • Network: the server application (the JVM one and the GraalVM one), mock-client application and Postgres database on the same VPC.
  • HTTP Client used: WebClient.
  • Database connection driver:: R2DBC (with Spring Data R2DBC).
  • JDK version: 21
  • Spring Boot version: 3.2.0
  • Applications Code: spring-virtual-thread-test
  • Tool used for load testing: jMeter

For test results, the Throughput parameter will be taken into account (you can find the parameter definition here) and the average response time, expressed in milliseconds.
The higher the Throughput value, the better.
The lower the value of Average Response Time, the better.

The test uses random values of name and surname (values, however, present in the Database) for each HTTP call made.

In each test, for each type of parameter, the winner will be colored green, the loser red. They will both be colored gray if there is no winner.

CPU and Heap utilization graphs will be shown, using VisualVM. At the end of the article will make considerations on them.

Data on image generation and application startup times

Startup Time: JVM 8.049 seconds - GraalVM 2.047 seconds (- 6.002 for GraalVM)

Image size: JVM 361MB - GraalVM 136MB (- 225 for GraalVM)

Build Time: JVM 45 seconds - GraalVM NA

With the t3.micro machine, after an hour, the command to generate the native image, ./mvnw clean spring-boot:build-image -DskipTests -Pnative still had to finish its execution.
I then decided to generate the image from my Linux x86 machine with Intel i5-4300M CPU (2 cores), 16 GB RAM.
The build time data were as follows:

Build Time with my personal machine: JVM 25 seconds - GraalVM 5 minutes (- 4.75 minuti for JVM)

First load test: 100 concurrent users, repeated 20 times

  • Number of Threads (users): 100.
  • Loop Count: 20.
  • Total requests: 2000.

Average Response Time: JVM 361 - GraalVM 354 (No winner)

Block Image
JVM
Block Image
GraalVM

Throughput: JVM 249.5 - GraalVM 246.1 (No winner)

Block Image
JVM
Block Image
GraalVM

CPU/Heap:

Block Image
JVM
Block Image
GraalVM

Second load test: 200 concurrent users, repeated 20 times

  • Number of Threads (users): 200.
  • Loop Count: 20.
  • Total requests: 4000.

Average Response Time: JVM 387 - GraalVM 370 (- 17 for GraalVM)

Block Image
JVM
Block Image
GraalVM

Throughput: JVM 359.7/sec - GraalVM 396.0/sec (+ 36.3 for GraalVM)

Block Image
JVM
Block Image
GraalVM

CPU/Heap:

Block Image
JVM
Block Image
GraalVM

Third load test: 300 concurrent users, repeated 20 times

  • Number of Threads (users): 300.
  • Loop Count: 20.
  • Total requests: 6000.

Average Response Time: JVM 433 - GraalVM 409 (- 24 for GraalVM)

Block Image
JVM
Block Image
GraalVM

Throughput: JVM 433.4/sec - GraalVM 449.8/sec (+ 16.4 for GraalVM)

Block Image
JVM
Block Image
GraalVM

CPU and heap usage:

Block Image
JVM
Block Image
GraalVM

Conclusions

From these tests, we find that the native image wins in both throughput and average response time.
In addition, the native image uses less CPU. On heap usage, the JVM app wins. I honestly did not expect that.

A very important data point to note is that of startup time. The native application started up faster than the JVM one, almost 4 times faster. This results in much faster horizontal scaling!

However, we must not overlook the build time of the native image. The t3.micro machine failed to generate it. If you consider using GraalVM in production, make sure you have a machine that is performant to create the image.

I recommend using GraalVM to take advantage of Function-as-a-Service in Java (for example, if you use AWS, write functions with AWS Lambdas). I do not recommend using it for an enterprise application.
Also remember that the GraalVM project is young and will get better and better as it goes along.

More articles on Spring: Spring.
Articles about Docker: Docker.

Recommended books on Spring, Docker, and Kubernetes: