skip to content

For containerized deployment, how would you optimize a Spring Boot fat jar's startup and image caching? Discuss exploded and layered jars.

level: principalimportance: nice to knowfreq 25%

answer

  1. layers.idx: dependencies / loader / snapshot / application
  2. COPY layers least→most changing for cache
  3. jarmode=layertools extract (→ jarmode=tools in 3.3+)
  4. run exploded via JarLauncher main = faster start
  5. add CDS/AOT/buildpacks/native for more

basics

~10 s

Split the fat jar into Docker layers (dependencies vs app code) so rarely-changing layers stay cached, and optionally run the exploded jar (extracted BOOT-INF) with JarLauncher to reduce nested-jar classloading overhead and speed startup.

solid answer

~40 s

Two levers. First, image caching: a single fat-jar COPY invalidates the whole layer on every code change, re-shipping all dependencies. Spring Boot supports layered jars — the plugin writes a layers index splitting BOOT-INF/lib (dependencies, snapshot-deps) from application classes. Using `java -Djarmode=layertools -jar app.jar extract` (or `jarmode=tools extract` in newer Boot) you copy each layer in its own Docker layer, so rebuilding only the app layer keeps the big dependency layer cached. Second, startup: running the jar nested means JarLauncher reads jar-in-jar, which is slower than a flat classpath. Extracting the jar and running the exploded layout (or the `application` layer with the same JarLauncher main) avoids repeated nested reads and improves cold start. Combine with CDS/AOT (Boot 3) or buildpacks (`bootBuildImage`) for further gains. Trade single-file simplicity for cache efficiency and faster startup.

code

text · 14 lines
text
# Multi-stage Dockerfile: extract layers, COPY stable→volatile, run exploded
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY build/libs/app.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
# Run the EXPLODED layout via JarLauncher (no nested-jar reads):
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

go deeper

for a junior

Awareness only: fat jars can be run in Docker with java -jar.

for a middle

Know layered jars exist and split dependencies from app code for caching.

for a senior

Write a correct layered multi-stage Dockerfile and explain COPY ordering.

for a principal

Weigh layered/extracted vs buildpacks vs native, factor in CDS/AOT, and justify the artifact strategy for scale.

## The two distinct problems 1. **Docker image-layer caching** — how to avoid re-shipping ~50–150 MB of dependencies on every code change. 2. **JVM startup performance** — reducing the cost of the nested-jar classloading path. ### Problem 1 — Layer caching with layered jars A Dockerfile that does `COPY app.jar .` puts the *entire* fat jar in one image layer. Any code change produces a new jar → that whole layer's hash changes → on push/pull the whole thing (including unchanged dependencies) moves again. Wasteful, because dependencies change far less often than your code. **Spring Boot layered jars** fix this. The build plugin adds a **layers index** (`BOOT-INF/layers.idx`) that groups jar contents into layers, by default: - `dependencies` — released (non-SNAPSHOT) dependency jars (change rarely) - `spring-boot-loader` — the loader classes (change only on Boot upgrade) - `snapshot-dependencies` — SNAPSHOT deps (change more often) - `application` — your `BOOT-INF/classes` + resources (change most) Using the jar's **layertools jarmode** you extract each layer, then COPY them **in order from least- to most-frequently-changing** so Docker caches the stable ones: ```dockerfile FROM eclipse-temurin:21-jre AS builder WORKDIR /app COPY app.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract # (Boot 3.3+: java -Djarmode=tools -jar app.jar extract --layers ...) FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=builder /app/dependencies/ ./ COPY --from=builder /app/spring-boot-loader/ ./ COPY --from=builder /app/snapshot-dependencies/ ./ COPY --from=builder /app/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] ``` Now a code-only change rebuilds just the `application` layer; the fat `dependencies` layer stays cached. ### Problem 2 — Exploded/extracted run for faster startup Running `java -jar app.jar` uses JarLauncher to read classes **out of nested jars**, which is measurably slower to open/seek than a flat directory of jars. **Extracting** the jar (as the Dockerfile above does) and launching the exploded layout with `org.springframework.boot.loader.launch.JarLauncher` as the main class avoids repeated nested reads and improves cold start. This is Spring's officially recommended container approach. ### Complementary techniques (Boot 3.x) - **AOT processing + `bootBuildImage`** (Cloud Native Buildpacks via Paketo) — produces optimized OCI images, can enable CDS. - **Class Data Sharing (CDS)** — Boot 3.3+ integrates training runs so class metadata is memory-mapped at startup, cutting init time. Works best on the extracted layout. - **GraalVM native image** (`native-image`, Spring AOT) — eliminates the JVM/fat-jar model entirely for sub-100ms starts, at the cost of build time and reflection config. - **`jarmode=tools`** (Boot 3.3+) supersedes `layertools`, adding extraction and even a `--launcher` option. ### Trade-offs to articulate - **Single fat jar**: simplest to ship and run anywhere; worst image caching; nested-read startup overhead. - **Layered + extracted**: best caching + faster start; multi-step Dockerfile; you manage COPY order. - **Buildpacks (`bootBuildImage`)**: hands-off optimized images; less control over base image. - **Native image**: fastest start/lowest memory; longest build, reflection/AOT constraints. ### Gotchas - COPY order matters — putting `application` before `dependencies` defeats caching. - Use the **extracted** classpath entrypoint (`JarLauncher` main over the exploded dir), not `java -jar` on the reassembled jar, to get the startup benefit. - Ensure the loader package matches the Boot version (`org.springframework.boot.loader.launch.JarLauncher` in 3.2+). - Layered/extracted works precisely **because** dependencies are kept whole (see the nesting-vs-shade discussion) — a shaded jar can't be split this way.

  • Why does COPY order of the layers matter for cache efficiency?
    Docker invalidates a layer and every layer after it when content changes. Copying rarely-changing layers (dependencies, loader) first and the frequently-changing application layer last means a code change only rebuilds/ships the small app layer while the big dependency layers stay cached.
  • What advantage does running the extracted/exploded jar give over `java -jar app.jar`?
    It avoids JarLauncher having to read classes out of nested jars (jar-in-jar seeks), instead loading from a flat directory layout, which reduces classloading overhead and improves cold-start time — Spring's recommended container run mode.

saying these in an interview costs you the question

  • Copying the application layer before the dependencies layer (defeats caching).
  • Believing a single COPY of the fat jar gives good layer caching.
  • Assuming a shaded uber jar could be layered the same way (it can't — deps aren't whole).

context