How to Improve JVM-Based Application Startup Time?
In this blog post, I will compare different approaches to speeding up JVM startup time. First, let's clarify what I mean by startup time. Startup time is the duration from when a JVM process is initiated to when it is ready to perform its intended tasks. The definition of "intended tasks" varies depending on the application.
The JVM is a powerful tool known for its "write once, run anywhere" capability. However, it also has its drawbacks, including the time required to initialize all the stuff related to classes, such as:
- loading,
- verifying,
- initializing,
- linking,
and more.
As a result, the JVM often faces criticism for being slower than other technologies like C++, Rust, and Go. However, the JVM community has developed several strategies to address this issue.
I am going to briefly describe and compare a few available options, which are:
- Class Data Sharing - CDS
- Application Class Data Sharing - AppCDS
- GraalVM
- Coordinated Restore at Checkpoint - CRaC
- Project Leyden
I will show you benchmarks conducted on a simple Netty server application. This benchmark measures the time from starting the JVM process to when the Netty server is fully operational and capable of handling HTTP requests.
Class Data Sharing - CDS
Class Data Sharing (CDS) was first introduced in JDK 5. When you create a JVM instance, the tool must transform the bytecode of core JDK classes into an internal representation for use. Prior to CDS, this transformation had to happen every time a JVM instance was run on the same host machine.
With CDS, this process is only completed once during the first launch of a JVM application. After that initial run, an archive file containing this data can be created, which can then be directly mapped into memory for subsequent JVM runs. Although CDS was introduced in JDK 5, it became enabled by default, starting with JDK 12. From that version onward, the archive file is included within the JDK itself, benefiting many developers who may not even be aware of its presence.
It’s important to note that CDS can be disabled on demand by adding the following option when starting the JVM: -Xshare:off
. An example command to disable CDS would look like this:
java -Xshare:off -jar myapp.jar
Pros:
- available by default from JDK 12.
Cons:
- not so big startup improvement.
Application Class Data Sharing - AppCDS
The concept is an evolution of CDS, introduced in JDK 10. If it's possible to create an archive from core JDK classes, why not do the same for application-specific classes? However, this must be done intentionally. For JDK versions before 19, the process involves two separate commands.
During the initial run of the application, you need to include the following option: -XX:ArchiveClassesAtExit=myapp.jsa
.
For subsequent runs, the JVM must be informed of the archive file to use, which can be accomplished by adding this option:-XX:SharedArchiveFile=myapp.jsa
.
Therefore, for JDK versions before 19, the first command may look like this:
java -XX:ArchiveClassesAtExit=myapp.jsa -jar myapp.jar
The following commands may be structured as follows:
java -XX:SharedArchiveFile=myapp.jsa -jar myapp.jar
From JDK 19, it is even easier as there is a possibility to run the same command for each run, with these two options included:-XX:+AutoCreateSharedArchive
, -XX:SharedArchiveFile=myapp.jsa
.
So command may look like this:
java -XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=myapp.jsa -jar myapp.jar
With such a command, the archive will be created whenever it doesn’t exist or is corrupted. Please note that the old approach can still be used even for JDK 19 and above.
Pros:
- still very easy to use,
- bigger startup time improvement than CDS.
Cons:
- still, there is a big space for improvement.
GraalVM
The JVM compiles bytecode into binary code that can be executed on specific hardware. Given this understanding, why not shift the compilation process from runtime to build time? This is precisely what GraalVM does. It employs ahead-of-time (AOT) compilation to generate binary code that can be executed without any additional steps. At first glance, this approach appears very promising. It allows for a fast startup and delivers good performance right from the beginning of the process. In some cases, this might be sufficient. However, there are also drawbacks to this method.
The first challenge with producing a native image using GraalVM is that it can be complicated, especially for more complex codebases. The AOT compiler needs to know in advance which classes will be used at runtime, including those that may be loaded dynamically via reflection. Fortunately, the GraalVM JDK distributions include a Java agent to generate configuration files for this purpose.
To use this Java agent, you must start your application with the following option: -agentlib:native-image-agent=config-output-dir=./native-config
.
You can customize the output path as needed. The initial command might look like this:
java -agentlib:native-image-agent=config-output-dir=./native-config -jar myapp.jar
Please remember to review all or at least the main use cases of your application so that the Java agent can collect all necessary information. Failing to do this may result in errors when starting an application that uses a native image. Additionally, having configuration files alone may not suffice, as you could encounter various errors during the native image build process. The specific errors will depend on your application.
The method for building a native image varies based on the technology you are using. For instance, since my example project was written in Scala, I utilized the sbt plugin called sbt-native-packager. However, finding equivalent solutions for Maven or Gradle should not pose a problem. A native image is essentially an executable file, which you can run like any other executable on your operating system.
The second issue is the loss of JVM multiplatform support. You need to create a separate native image for each environment, which means building distinct native images for Linux, Windows, and macOS.
The final problem is that your application hardly ever achieves optimal performance. When running your application on the JVM, the JIT compiler optimizes and profiles based on how the application is used. This capability is lost when switching to AOT compilation.
I want to emphasize that I'm not against using GraalVM. I find it quite interesting and suitable for certain use cases. However, it's important to understand its limitations to make the best choice for your specific needs.
Pros:
- very fast startup,
- stable, high performance from the beginning.
Cons:
- building a native image might be problematic, sometimes even impossible as for the time when I was writing this blog post and I was testing this solution (end of January - beginning of February 2025) GraalVM hasn’t supported virtual threads yet GR-59499,
- the necessity to build an executable for each environment,
- hardly ever reaches the highest possible performance.
Coordinated Restore at Checkpoint - CRaC
A JVM application operates as a process running on your host machine. Given this understanding, why not take a snapshot of the process and restore it in the future exactly from that point? This is precisely what CRaC does. It utilizes CRIU (Checkpoint/Restore in Userspace) to achieve this functionality.
As of the end of 2024, support for an additional checkpoint engine called WARP has been introduced, though I have only tested the CRIU solution. It’s important to note that CRIU is a Linux-specific project. This means you can enjoy the full benefits of CRaC solely on a Linux operating system. While there are solutions available for Windows and macOS, they are primarily for development purposes. These solutions can create checkpoints and restore applications immediately, but they cannot save those checkpoints on disk.
This is how CRaC works: it takes a snapshot of your process and creates a checkpoint, allowing you to restore your application later. Although CRaC utilizes CRIU under the hood, it requires certain adaptations for JVM use cases. To create a checkpoint, you, as a developer, must ensure that your application is isolated from the external environment. This means there should be no open sockets, database connections, or similar resources.
To facilitate this, CRaC provides a simple API. It features an interface called Resource that includes two methods: beforeCheckpoint
and afterRestore
. The method names are self-explanatory, so I won't describe them further. You can register as many resources as you need. For example, if you register two resources, A and B, the beforeCheckpoint
method will be called in this order: A first, then B. Conversely, the afterRestore
method will be called in reverse order: B first, then A. You should register any class that needs to be notified when a checkpoint is created, such as a class that opens a database connection. You can find some tips and tricks for using CRaC resources in the Azul documentation.
Although it may seem uncomplicated, making modifications to the source code can increase maintenance costs. However, Spring Boot, Micronaut, and Quarkus support CRaC, which helps mitigate this issue.
I also discovered that it's best to execute all commands related to using CRaC as a root user, which could present challenges in certain scenarios.
Here’s how to use CRaC: first, run the application with the option -XX:CRaCCheckpointTo=/crac-image/
, which instructs the JVM where to store the checkpoint files. The full command would look like this:
java -XX:CRaCCheckpointTo=/crac-image/ -jar myapp.jar
To initiate the checkpoint process, we can either do so from another terminal or programmatically within the code. Knowing the process ID is essential. With this information, we can create a checkpoint using the jcmd command.
jcmd <pid> JDK.checkpoint
Once a checkpoint is created, the application will terminate. We can then restore from the checkpoint using the following command:
java -XX:CRaCRestoreFrom=/crac-image/
It's important to note that by using CRaC, we can restore our application at the highest performance level. To achieve this, we need to conduct a training run during which the JIT compiler will perform all necessary optimizations for our application. Once this process is complete, we can create a checkpoint.
Additionally, it's worth mentioning that AWS Lambda SnapStart operates in a similar manner to CRaC. Here are some useful links:
Pros:
- very fast startup,
- application may work at the highest performance level from the start,
- support in biggest web Java frameworks - Spring Boot, Micronaut, Quarkus.
Cons:
- require code changes for more complicated applications (if not using Spring Boot, Micronaut, Quarkus),
- fully supported only on Linux,
- you may encounter specific issues depending on what you are using in your application, e.g. my test application was a simple Netty server, and for the first run, I had to add this option:
-Dio.netty.native.deleteLibAfterLoading=false
to be able to create a checkpoint, - currently only supported by Zulu and Liberica OpenJDK distributions.
Project Leyden
As I mentioned earlier, the primary purpose of CDS and AppCDS is to cache certain information needed for the JVM to startup. This information is stored in an archive file that can be mapped into memory. Project Leyden takes this concept a step further. The main goal of this project is to include additional information within an archive that can help accelerate both the JVM startup and warmup phases. There are three JEPs (JDK Enhancement Proposals) included in the scope of Project Leyden:
- JEP 483: Ahead-of-Time Class Loading & Linking,
- JEP draft: Ahead-of-Time Code Compilation,
- JEP draft: Ahead-of-Time Method Profiling.
The work is still in progress, as only the first part is completed. However, the idea appears very promising. These JEPs address issues similar to those of CRaC but approach them slightly differently. There is no need for any external tools in this process, as the JDK itself handles everything. This means that using this technique should be quite straightforward, and this assumption is indeed correct. To take advantage of the benefits that Project Leyden offers, we simply need to add one option to our command, which requires even less than what is needed for AppCDS:-XX:CacheDataStore=myapp.aot
. For the first run, as well as for subsequent runs, we can use a command that looks like this:
java -XX:CacheDataStore=myapp.aot -jar myapp.jar
This is it! One important point to keep in mind, similar to CRaC, is that a training run must be completed before the archive is created. As this is still a work in progress, I am eager to see the final results. Even at this stage, the improvements are clearly noticeable, and I will demonstrate this to you later.
Pros:
- very easy to use,
- potentially excellent performance from the beginning.
Cons:
- still work in progress; only early access builds are available.
Comparison
After exploring all the possibilities, it's time to compare them. To do this, I created a simple project using a Tapir template. I selected the following options:
- Scala 3 (more precisely, I am using 3.7.0-RC1-bin-20250207-d60a914-NIGHTLY version of Scala, because of its compatibility with Project Leyden),
- sbt as a build tool,
- Future as the stack,
- Netty as a server implementation.
The source code is available on GitHub. There are four separate branches:
- main,
- graalvm,
- CRaC,
- project_leyden (this one is just for convenience to build jar with a specific name because the source code is the same as in the main branch).
I implemented a Resource interface for CRaC to ensure the server is stopped before the checkpoint and started again after restoration. You can check the diff between the main and CRaC branches.
The build configuration for GraalVM differs, as I needed to add a plugin along with the proper configuration for building the native image. You can check the diff between the main and graalvm branches.
The Java versions I used were:
- Temurin-21.0.6+7 (build 21.0.6+7-LTS),
- Oracle GraalVM 21.0.6+8.1 (build 21.0.6+8-LTS-jvmci-23.1-b55),
- Zulu21.38+21-CRaC-CA (build 21.0.5+11-LTS),
- 24-leydenpremain 2025-03-18.
I created a simple benchmark application. The benchmark application starts a specified process and waits for a message on the console indicating that the server is ready, which means it can accept incoming HTTP requests and kill it. Each measurement consists of 50 iterations. I conducted tests on two different machines:
- local PC:
- Intel(R) Core(TM) i5-8500 CPU @ 3.00GHz
- 6 vCPU (6 cores)
- 16 GB RAM
- Ubuntu 22.04-LTS
- virtual machine hosted on Google Cloud:
- Intel(R) Xeon(R) CPU @ 2.20GHz
- 8 vCPU (4 cores)
- 8 GB RAM
- Ubuntu 24.04-LTS
These are the results of my tests:
- local PC:
- Google Cloud virtual machine
The results of the comparison are not surprising. When we omit time values, the bars on both charts look quite similar. As a baseline, we can consider the version with CDS enabled, which is the default for newer versions of the JDK starting from version 12. Tests have shown that disabling CDS leads to increased startup time. Enabling AppCDS results in more than a twofold improvement in startup time in both scenarios. Following this, CRaC and GraalVM emerged as the fastest solutions. Project Leyden is positioned between AppCDS and CRaC/GraalVM, but it is important to note that it is still a work in progress. It will be exciting to see how these charts look once Project Leyden is completed.
Conclusion
There are several approaches to speeding up the JVM, and the best choice depends on the specific application we are working on. For older versions of the JDK (10 and below), the only available option for improvement is CDS. However, with newer JDK versions, we have more options to consider: AppCDS, CRaC, and GraalVM.
In my opinion, AppCDS is the easiest to use, although it provides the least benefit compared to the other two. CRaC is a good choice for long-running applications, as it allows the JVM to perform optimizations during runtime. On the other hand, GraalVM may be more suitable for short-lived applications where fast startup is more critical than achieving the highest possible performance, especially if using CRaC is not an option.
As always, there is no one-size-fits-all solution. I hope this blog post helps you choose the best option to fit your specific needs.
Reviewed by Adam Warski, Łukasz Lenart