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).
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.
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.
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.
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.
- Java
- Go
- Rust
public class Main {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
package main
import "fmt"
func main() {
fmt.Println("Hello, world!")
}
fn main() {
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.
- Java (JVM)
- Java (Native)
- Go
- Rust
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 /build/target/*.class /app
ENTRYPOINT [ "java", "-cp", "/app", "Main" ]
FROM ghcr.io/graalvm/native-image:java17-21.3 AS builder
RUN mkdir /build && mkdir /build/source && mkdir /build/target
WORKDIR /build
COPY Main.java ./source
RUN javac ./source/Main.java -d ./target
RUN native-image -cp ./target --install-exit-handlers --static -H:Name=helloworld -H:Class=Main
#############
FROM gcr.io/distroless/base-debian10
WORKDIR /
COPY /build/helloworld /helloworld
USER nonroot:nonroot
ENTRYPOINT ["/helloworld"]
FROM golang:1.17-buster AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY *.go ./
RUN go build -o /helloWorld
#############
FROM gcr.io/distroless/base-debian10
WORKDIR /
COPY /helloWorld /helloWorld
USER nonroot:nonroot
ENTRYPOINT ["/helloWorld"]
FROM rust AS builder
WORKDIR /usr/app
COPY . .
RUN cargo build --release
#############
FROM debian:buster-slim
WORKDIR /
COPY /usr/app/target/release/helloworld /helloworld
ENTRYPOINT ["/helloworld"]
The table below shows the size of container images for different languages. Container images with native apps can save a lot of space.
Language | Binary size (MB) | Container image size (MB) |
---|---|---|
Java (JVM) | N/A | 146 |
Java (Native) | 16 | 35.3 |
Go | 1.8 | 21 |
Rust | 3.7 | 72.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 type | Quarkus + Native | Quarkus + JIT | Traditional |
---|---|---|---|
REST | 0.016s | 0.943s | 4.3s |
REST + CRUD | 0.042s | 2.033s | 9.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 type | Quarkus + Native | Quarkus + JIT | Traditional |
---|---|---|---|
REST | 12MB | 73MB | 136MB |
REST + CRUD | 28MB | 145MB | 209MB |
JVM may unload classes to reduce memory use. However, the actual behavior is implementation specific and transparent to the program.