Skip to main content

Introduction

Going native is a necessary step for Java apps in cloud-native era.

Traditional Java Apps

Write once, run anywhere is a famous slogan of Java programming language. Java source code is compiled to platform-neutral bytecode first, then bytecode is executed on Java Virtual Machine (JVM).

Java source code to bytecode

JVM provides runtime components to execute Java programs and encapsulates implementation details for different platforms. The same bytecode can be executed on different platforms without any changes. This is one of the important reasons why Java is so popular in enterprise applications development.

In the traditional deployment mode of Java apps, JDK/JRE is installed on top of the operating systems running on physical or virtual machines. A Java app is started by launching a JVM process using the java command. Each Java app has its own JVM process.

Java traditional deployment model

Installed JDKs can be used by many Java apps. A single installation is usually enough when all apps support the same Java version.

Cloud-Native Java Apps

Cloud-native apps are packages as OCI/Docker container images. Container images are immutable and self-contained. After the container image is built, the execution platform of the app has already been fixed. This means that cloud-native apps can always target a certain platform. Platform independence provided by JVM is actually unnecessary.

When Java apps are packaged as container images, a compatible JDK or JRE is required to be bundled with the app. When running the container image, a JVM process is launched to run the app.

Java cloud deployment model

The key issue is that Java bytecode is not executable on its own. Bytecode is a platform-neutral intermediate representation (IR), which requires a JVM to interpret in the runtime.

Even a simple Java application requires a complete JVM to run it. This will dramatically increase the size of container images.

Java Platform Module System

With the introduction of Java Platform Module System (JPMS) in Java 9, it's now possible to customise the JDK with jlink to only include required modules. This can reduce the size of JDK, but requires extra efforts to implement.

Comparing to other programming languages, Java is less promising due to its limitations on container image size, app startup time, and memory consumption.

Container Image Size

Let's use the simplest "Hello World" program as the example to demonstrate container image size. Java, Go, and Rust are used to implement this program. The programs are all very simple to write.

public class Main {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}

When creating container images for this app, Docker multi-stage builds are used to create container images for different programming languages. For Java, both JVM and native versions are provided.

The container image build process has two stages:

  • The first stage builds the app. For Java, the output is class files; for Go and Rust, the output is a native executable binary file.
  • The second stage combines the output from the first stage with a runtime environment. For Java, the runtime environment includes the JRE; for Go and Rust, the runtime environment is just the operating system.
FROM eclipse-temurin:17-jdk AS builder

RUN mkdir /build && mkdir /build/source && mkdir /build/target

COPY Main.java ./build/source

RUN javac /build/source/Main.java -d /build/target

#############

FROM eclipse-temurin:17-jre-alpine

RUN mkdir /app

COPY --from=builder /build/target/*.class /app

ENTRYPOINT [ "java", "-cp", "/app", "Main" ]

The table below shows the size of container images for different languages. Container images with native apps can save a lot of space.

LanguageBinary size (MB)Container image size (MB)
Java (JVM)N/A146
Java (Native)1635.3
Go1.821
Rust3.772.9

App Startup Time

Java programs are executed by launching the JVM with a specified entry point, which is the class with public static void main(String[]) method. During the execution of the program, more classes will be loaded dynamically. Classing loading is a powerful feature in Java. However, it may impact the runtime performance, especially the startup time.

Startup time is an important metrics for cloud-native apps. In a typical microservice architecture, these services use horizontal scaling to serve more requests. When the system is under heavy load, it's crucial that service replicas can start and serve requests quickly.

The extensive usage of frameworks slows down the startup of Java apps. During application startup, many frameworks will scan the classpath and perform initialization tasks, which will generate new classes or enhance existing classes. For example, Hibernate will enhance entity classes annotated with @Entity.

Quarkus provides some metrics about the startup time of native Java apps and traditional Java apps.

App typeQuarkus + NativeQuarkus + JITTraditional
REST0.016s0.943s4.3s
REST + CRUD0.042s2.033s9.5s

Memory Consumption

Cloud native apps have many replicas in the runtime. If we can reduce the memory consumption of an app, the memory savings could be huge. This can cut down the cost of the whole infrastructure.

In JVM mode, Java apps may waste memory in certain cases. A typical scenario is classes metadata. For example, an app uses YAML as the format of configuration files. It requires YAML libraries to parse configuration files into Java objects in the runtime. This usually happens at the initialization phase. After the parsing is done, only the result Java objects will be used. YAML libraries won't be used any more, but their classes metadata is still in the memory.

Quarkus provides some metrics about the memory of native Java apps and traditional Java apps.

App typeQuarkus + NativeQuarkus + JITTraditional
REST12MB73MB136MB
REST + CRUD28MB145MB209MB
Class unloading

JVM may unload classes to reduce memory use. However, the actual behavior is implementation specific and transparent to the program.