The FROM line of a Spring Boot Dockerfile is usually copied from somewhere and never changed. eclipse-temurin is the common choice, but Amazon, Azul, IBM and Oracle’s GraalVM team all publish Java 25 images that run the same jar. I built one notes API, put it in five images that differ only in that line, and ran the same k6 tests against each one on the same machine.
Short version: the gap between the best and worst of Temurin, Corretto and Zulu was under 5% in each of the three tests. Semeru started first and used about a third less memory, but it had the lowest throughput in all three tests. GraalVM Community, used as a normal JIT JVM, finished fourth in all three tests.
Results
Requests per second are k6’s http_reqs rate over each 10-minute test. Latency is k6’s http_req_duration. Every one of the 23,651,594 requests across the 15 runs returned the expected status.
| JVM | Read req/s | Read p99 | Write req/s | Write p99 | Mixed req/s | Mixed p99 |
|---|---|---|---|---|---|---|
| Zulu | 2,265 | 239 ms | 2,861 | 194 ms | 3,762 | 137 ms |
| Corretto | 2,220 | 238 ms | 2,775 | 200 ms | 3,811 | 135 ms |
| Temurin | 2,189 | 243 ms | 2,745 | 205 ms | 3,821 | 133 ms |
| GraalVM CE | 1,857 | 268 ms | 2,040 | 255 ms | 3,446 | 150 ms |
| Semeru | 1,090 | 444 ms | 1,958 | 226 ms | 2,579 | 172 ms |
Startup, memory and GC:
| JVM | Container start → first 200 | “Started NotesApplication in” | Image size | RSS, mixed test | GC pause, mixed test |
|---|---|---|---|---|---|
| Semeru | 14.65 s | 11.26 s | 549 MB | 280 MB | 27.7 ms/s |
| Corretto | 15.16 s | 11.99 s | 729 MB | 416 MB | 9.6 ms/s |
| Zulu | 15.43 s | 12.34 s | 629 MB | 413 MB | 8.7 ms/s |
| Temurin | 15.73 s | 12.42 s | 541 MB | 421 MB | 8.6 ms/s |
| GraalVM CE | 16.24 s | 12.89 s | 1.16 GB | 417 MB | 7.7 ms/s |
These hold for this app on a 2 vCPU machine that the load kept at close to full CPU, with no JVM flags. A bigger machine, a tuned heap or a different workload can reorder the list.
The five runtimes
| Name in the post | Image | java -version | VM | JIT |
|---|---|---|---|---|
| Temurin | eclipse-temurin:25.0.5_8-jre-noble | Temurin-25.0.5+8 | HotSpot | C1 + C2 |
| Corretto | amazoncorretto:25.0.5-al2023-headless | Corretto-25.0.5.0.10 | HotSpot | C1 + C2 |
| Zulu | azul/zulu-openjdk:25.0.5.0.3-jre-headless | Zulu25.38+109-CA | HotSpot | C1 + C2 |
| Semeru | ibm-semeru-runtimes:open-jdk-25.0.5.0-jre-noble | Semeru Runtime Open Edition 25.0.5.0 | Eclipse OpenJ9 | OpenJ9 JIT |
| GraalVM CE | ghcr.io/graalvm/jdk-community:25i5-25.0.5.0.3-ol10 | GraalVM CE 25.5.5.0.3 (jvmci-25.5) | HotSpot | C1 + Graal |
Four of them are builds of the same OpenJDK source with HotSpot inside. They differ in who builds and tests them, how long updates come, the base OS of the image, and the support you can buy. Semeru keeps the OpenJDK class library but swaps HotSpot for OpenJ9, which has its own JIT and garbage collectors and was designed for a smaller footprint. GraalVM Community runs on HotSpot with C1 as the first tier; its top tier is Graal, a JIT compiler written in Java, in place of C2. Native image is a separate way to run Spring Boot and is not part of this test.
The GraalVM image is a JDK image (jdk-community) on Oracle Linux, and at 1.16 GB it is twice the size of the Temurin JRE image.
The app
A small Spring Boot 4.1.2 notes API: Spring Web, Spring Data JPA, Flyway, Hikari, Actuator with the Prometheus registry. The table and its one index:
create table notes (
id bigserial primary key,
title text not null,
body text not null,
tag text not null,
created_at timestamptz not null
);
create index notes_tag_id on notes (tag, id desc);
The endpoints the tests call:
@GetMapping("/{id}")
ResponseEntity<Note> get(@PathVariable long id) {
return ResponseEntity.of(notes.findById(id));
}
@GetMapping
List<Note> byTag(@RequestParam String tag) {
return notes.findByTagOrderByIdDesc(tag, PageRequest.of(0, 20));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
Note create(@Valid @RequestBody NoteRequest r) {
return notes.save(new Note(r.title(), r.body(), r.tag()));
}
@PutMapping("/{id}")
@Transactional
ResponseEntity<Note> update(@PathVariable long id, @Valid @RequestBody NoteRequest r) {
return notes.findById(id).map(n -> {
n.setTitle(r.title());
n.setBody(r.body());
n.setTag(r.tag());
return ResponseEntity.ok(n);
}).orElse(ResponseEntity.notFound().build());
}
application.yaml sets a pool of 20 and puts the JVM name on every metric, so one Grafana query can split by runtime:
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/notes}
hikari:
maximum-pool-size: 20
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
management:
server:
port: 9091
endpoints:
web:
exposure:
include: health,prometheus
metrics:
tags:
jvm: ${JVM_NAME:unknown}
One Dockerfile, five images
The jar is built once. The base image is a build argument, so the five images contain the same notes.jar byte for byte:
ARG BASE
FROM ${BASE}
ARG JVM
ENV JVM_NAME=${JVM}
WORKDIR /app
COPY target/notes.jar notes.jar
EXPOSE 8080 9091
ENTRYPOINT ["java", "-jar", "notes.jar"]
./mvnw -q package -DskipTests
docker build --build-arg BASE=eclipse-temurin:25.0.5_8-jre-noble --build-arg JVM=temurin -t notes-temurin .
docker build --build-arg BASE=amazoncorretto:25.0.5-al2023-headless --build-arg JVM=corretto -t notes-corretto .
# … same for semeru, zulu, graalvm
No -Xmx, no GC choice, no JAVA_TOOL_OPTIONS. Each JVM picks its own heap size and collector from what it sees: 2 CPUs and 4 GB, the whole app machine.
The setup
Three DigitalOcean CPU-Optimized droplets in London, on one private network:
| Machine | Size | Runs |
|---|---|---|
| app | c-2 (2 vCPU, 4 GB) | the notes container, cAdvisor, node exporter |
| db | c-4 | PostgreSQL 17.11 in Docker |
| load | c-8 | k6 2.3.0, Prometheus, Grafana |
The load generator and the monitoring stack sit on their own machine, so they take no CPU from the app.
For each JVM, one script on the load machine does the same steps:
- Remove the previous container, drop the page cache on the app machine, drop the
notestable, checkpoint Postgres. - Start the container and poll
GET /notes?tag=javaevery 50 ms until it returns 200. That time is the startup number. - Insert 200,000 notes with
generate_series, thenvacuum analyze. - Wait 60 seconds.
- Run three k6 tests, 10 minutes each, 100 virtual users, with 15 seconds between them: read, then write, then mixed.
The order matters. The mixed test always starts after 20 minutes of load, so every JVM has had time to compile its hot code by then.
The k6 script
constant-vus with 100 users and no sleep, so each user sends its next request as soon as the last one returns. The three modes:
| Mode | Requests |
|---|---|
| read | 70% GET /notes/{id} (random id), 30% GET /notes?tag= (random of 8 tags, newest 20) |
| write | 50% POST /notes, 50% PUT /notes/{id} |
| mixed | 80% read mix, 20% write mix |
export const options = {
scenarios: { [MODE]: { executor: 'constant-vus', vus: 100, duration: '10m' } },
tags: { jvm: __ENV.JVM, mode: MODE },
discardResponseBodies: true,
summaryTrendStats: ['avg', 'p(50)', 'p(95)', 'p(99)', 'max'],
// no `url` tag: every /notes/{id} would become its own Prometheus series
systemTags: ['status', 'method', 'name', 'scenario', 'expected_response', 'check'],
};
function read() {
if (Math.random() < 0.7) check(http.get(`${BASE}/notes/${id()}`, { tags: { name: 'GET /notes/{id}' } }), { ok: (r) => r.status === 200 });
else check(http.get(`${BASE}/notes?tag=${tag()}`, { tags: { name: 'GET /notes?tag' } }), { ok: (r) => r.status === 200 });
}
k6 streams its metrics to Prometheus with remote write (-o experimental-prometheus-rw) and also writes a summary file per test with --summary-export. The tables above come from those summary files.
The read test: a race against the JIT
The 10-minute average hides most of what happened. Here is the first minute against the last minute of the read test:
| JVM | First minute req/s | Last minute req/s |
|---|---|---|
| Zulu | 1,034 | 3,510 |
| Corretto | 1,036 | 3,455 |
| Temurin | 1,061 | 3,365 |
| GraalVM CE | 963 | 2,485 |
| Semeru | 407 | 2,008 |
Every JVM more than doubled its throughput during the test, and the code and machine stayed the same. During the read test the app container used about 1.75 of its 2 cores the whole time. Postgres read every row from its cache (blks_read stayed at 0) and its CPU stayed under 16%. The limit was the app’s own CPU.
Micrometer’s jvm_compilation_time_ms_total shows where that CPU went. As a rate it reads as milliseconds of compiling per second of wall time:
| JVM | Read 0–2 min | Read 8–10 min | Write 8–10 min | Mixed 2–5 min | Mixed 8–10 min |
|---|---|---|---|---|---|
| Temurin | 1,231 | 957 | 123 | 76 | 19 |
| Corretto | 1,281 | 999 | 119 | 56 | 17 |
| Zulu | 1,275 | 992 | 66 | 36 | 25 |
| GraalVM CE | 1,389 | 979 | 991 | 1,074 | 528 |
| Semeru | — | 931 | 978 | 169 | 40 |
Around 1,000 ms per second means a compiler thread was busy almost all the time on a machine with two cores. On the three HotSpot JVMs the JIT kept compiling through the entire read test and only settled near the end of the write test. In the mixed test they were close to idle after two minutes, and that is where the three of them reached their highest numbers.
GraalVM’s Graal compiler kept working through the write test and most of the mixed test. It reached 3,669 req/s in the last minute of the mixed test, close to the others, but its 10-minute averages stayed fourth. Semeru’s JIT followed a similar pattern through the write test, then went quiet in the mixed test, at a lower level than the HotSpot three.
The Semeru read cell is empty because Prometheus missed most actuator scrapes on that run while both cores were busy, so there aren’t enough points for that window. The k6 numbers come from the load machine and are complete.
Writes: the pool is the queue
In every test, Hikari’s hikaricp_connections_pending averaged 55 to 65 waiting requests, and 71 to 73 in the write test. 100 virtual users share 20 connections, so most requests spend part of their time waiting for a connection. That queue looks the same for every JVM, and in the write test the gap between Semeru and the HotSpot JVMs got much smaller: 1,958 req/s against 2,745–2,861, with a p99 of 226 ms against 194–205 ms.
Memory and GC
The memory column is the container’s RSS from cAdvisor, averaged over the mixed test. The four HotSpot-based JVMs sat at 413–421 MB. Semeru used 280 MB with the same jar and the same load, about a third less.
The cost showed up in GC: Semeru spent 27.7 ms of every second in GC pauses (jvm_gc_pause_seconds), against 7.7–9.6 ms for the others. OpenJ9’s default GC policy, gencon, kept the used heap smaller (71 MB average used heap against 87–98 MB) and collected more often.
What I’d take from it
- If you run on Temurin, Corretto or Zulu, this test gives you no reason to switch. Pick between them on update schedule, support, licence terms and base image.
- Semeru is worth a test where memory is the limit, for example many small containers per node. On a CPU-bound 2 vCPU box it was the slowest.
- Run as a JIT JVM, GraalVM had the highest compile time of the five, and its averages trailed Temurin, Corretto and Zulu in read, write and mixed. Its native image mode is a different trade-off and needs its own test.
- Warm-up dominated everything on a small, busy machine. If your pods restart often or scale from zero, the first minutes matter more than the 10-minute average.
What this doesn’t cover
One app, one machine size, default flags, one run per JVM. No -XX:+UseZGC, no CDS or AOT cache, no CRaC, no native image, no -Xmx tuning, no larger instances where the JIT has spare cores. Any of those can change the ranking. The numbers here say what each image does out of the box with this jar on two busy cores.