Tobias Unger, Tunahan Cizek – Opitz Consulting Deutschland GmbH
The Java universe has created a bunch of frameworks claiming to be cloud-native. These frameworks especially offer support to create container images of an application. For example, Quarkus publishes pre-built Docker files . However, the images created by these Dockerfiles are not necessarily the best for running the application: Sometimes the images must be optimized regarding special performance and resource consumption requirements. One aspect here is the JVM itself. If one aims for minimizing the image size or the JVM’s startup time, one might have to create a custom image. Minimizing resource consumption is of special interest in cloud environments, because of the usage-based payment in public clouds. Another aspect is the large number of JVM variants that have emerged recently. Some of these JVMs contain their own optimizations, e.g. Amazon Corretto  or Eclipse OpenJ9 , which affect size and resource consumption. In the further course, we use OpenJDK HotSpot and Eclipse OpenJ9 provided by AdoptOpenJDK  to show the practical impact of these differences. The versions of OpenJDK (build 11.0.6+10) and Open J9 (build openj9-0.18.1) used are listed in the appendix.
Below we will share a few of our experiences on this topic.
During our daily work, we see a lot of containers containing a complete JDK. This is not a problem if one does not care about the image size. If the size of the image is to be minimized, it is worth taking a closer look here. Instead of using JDK, one can use the JRE. The JRE needs significantly less space in the image because it does not contain all the tools necessary for developing applications such as the compiler (javac). Most applications only require a JRE, especially if they were created with a cloud-native framework such as Quarkus, Micronaut , Helidon , or SpringBoot . Hence, Dockerfiles provided by these Frameworks rely on JRE. An example for which a JDK is required are Java Server Pages (JSPs) because JSPs are compiled at runtime.
Another option to reduce the storage footprint is to use the headless version of the JDK/JRE. For example, Debian provides headless packages of the JVM and the JDK/JRE. These packages omit all packages required for graphical interfaces.
Jlink  was introduced in Java 9 and allows to build a custom runtime image. It allows assembling a Java application and its dependent modules into the custom image. The real purpose of Jlink is to turn module-based applications into an image. However, you can also use Jlink to create a tailored JVM image. Helidon, for example, integrates Jlink directly in its build tools . An example of how to create a JVM image for a Quarkus application is shown below.
#!/bin/bash $JAVA_HOME/bin/jlink --add-modules java.sql,java.rmi,\\ jdk.management.agent,java.transaction.xa,java.logging,\\ java.xml,java.management,jdk.unsupported,java.datatransfer,\\ jdk.internal.jvmstat,java.instrument,java.security.jgss,\\ jdk.management,java.naming,java.desktop,java.compiler,\\ java.prefs,java.security.sasl,jdk.jconsole,\\ java.management.rmi,jdk.attach,java.base \\ --output $IMAGE_NAME --strip-debug --no-header-files \\ --no-man-pages --compress 2
Figure 1 shows a size comparison between a standard JDK, a standard JRE and a generated JVM image based on Jlink for OpenJDK as well as for OpenJ9. The size refers to the uncompressed size of the JVM layer within the container image. To measure this, we packed the JVM in its own layer. The difference in layer size between JDK, JRE, and Jlink is clearly visible. Note that compression of the JVM image may impact startup time. Furthermore, the Jlink JVM image must be created specific for each application. This is the only way to ensure that all required JVM modules are included in the JVM image.
Fast application startup time plays a big role in cloud environments. On the one hand, many serverless services start application instances as soon as a request comes in. Also, the instances are only held for a defined period of time. On the other hand, an application should scale almost without delay as the number of requests increases. To achieve these requirements, the JVM must start the application as quickly as possible. The influence of the application on the start speed is not taken into account in this blog. We define startup time as the duration it takes to bring up the Docker container, the JVM, and the Quarkus example application. The application is based on the Quarkus Hibernate ORM and RESTEasy Quickstart . However, we deactivated entity caching. The application is considered started, as soon as we receive the first response.
The first two bars of Figure 2 show the start application start time for OpenJDK and OpenJ9 without any optimization. This is too slow for the requirements of modern cloud environments. Please note that about one second is required to start the container. However, OpenJ9 offers an interesting option to reduce startup time. This is presented in the next section.
Class Data Cache
OpenJ9 provides an option to reduce the memory footprint and startup time. One can define a cache for (shared) class data. The cache can contain bootstrap and application classes, metadata, and ahead-of-time (AOT) compiled code . Using this cache, OpenJ9 can decrease startup time of an application significantly.
We tested two scenarios: the cache is part of the imaged (1) and the cache is stored on a volume (2). Figure 2 shows that using a cache might not reduce startup time in any case. Storing a populated cache (warm) with the image showed only a negligible reduction in our setup. Apparently, Docker needs it’s time to load the image or data access to the layers is slow. Also, squashing the image to a single layer doesn’t help. The startup time decreased as we outsourced the cache to a volume (locally attached SSD).
However, it is important to create the cache during the build (warm). If the cache is activated and not populated (cold) startup time increases.
CPU & Memory Consumption
Figure 3 and Figure 4 show CPU & memory consumption of our Quarkus application during startup and under load. Interestingly, OpenJ9 has a higher CPU utilization but doesn’t claim as much memory as OpenJDK. It seems that the lower memory consumption is at the expense of CPU usage.
One might argue that the emergence of GraalVM  solves a lot of problems. Especially startup time is decreased significantly by compiling a native image of the application. In principle, that is correct. So far, not every application can be compiled natively. Reflection is one prominent issue. We also tested GraalVM and leave discussing the results to a future blog post.
Containerization of Java applications requires to consider a lot of aspects. The selection of the JVM and the appropriate JVM configuration alone offers a wide range of options. With this blog, we want to encourage you to do your own tests in your setting as there is no general solution. Our measurements show directions for optimizations. All in all, we think that the Jlink approach looks promising in terms in terms of image size. Whether or not to use the J9’s class cache is a more difficult decision. On the one hand the startup time can be shortened, on the other hand the cache itself needs storage space again. In addition, the cache must be populated during the image build.
Want to know more?
If you want to know more about containerized application development, visit the Containers and Cloud-Native Roadshow Essen on March 26th, 2020 . You will gain a lot of insight in container technology and learn, how to build cloud-native applications. The Containers and Cloud-Native Roadshow Essen will be postponed due to the corona virus. Therefore we decided to start a small series of articles about containers based on this article. Stay safe and healthy!!