Spring Boot ist vor Kurzem zehn Jahre alt geworden. Ich selbst kann mich dabei noch gut daran erinnern, wie ich vor etwa neun Jahren das erste Mal eine Spring-Boot-Anwendung erstellt habe. Vor allem, dass am Ende des Build-Prozesses eine einzelne JAR-Datei herauskam, die ich dann mittels java -jar starten konnte, hat mich nachhaltig fasziniert.

In diesem Artikel wollen wir uns deshalb einmal anschauen, wie das Ganze funktioniert und wie sich dieses Feature über die letzten zehn Jahre weiterentwickelt hat.

Ausführbare JAR-Dateien

JAR-Dateien gibt es bereits seit der ersten Version von Java. Der Zweck dieser Java-Archive besteht darin, mehrere Class-Dateien und Ressourcen, wie Bilder- oder Textdateien, zusammen in einer Datei ausliefern zu können. Dies vereinfacht vor allem die Verteilung.

Eine so gebaute JAR-Datei können wir nun sowohl beim Kompilieren mit javac als auch zur Ausführung mit java über den Parameter -classpath auf den Klassenpfad legen (s. Listing 1). Hierdurch können wir die enthaltenen Class-Dateien nutzen und auch auf die anderen Ressourcen zugreifen.

$ jar tf lib/target/lib.jar
META-INF/
META-INF/MANIFEST.MF
lib/Greeter.java

$ cat app/Main.java
package app;

import lib.Greeter;

public class Main {
    public static void main(String[] args) {
        String greeting =
            new Greeter().greet("world");
        System.out.println(greeting);
    }
}

$ javac \

    -d target/classes \

    -classpath lib/target/lib.jar \

    app/Main.java

$ jar \

    --create \

    --file target/app.jar \

    -C target/classes \

    app/Main.class

$ java \

    -classpath target/app.jar:lib/target/lib.jar \

    app.Main
Hello, world!
Listing 1: Start einer Java-Anwendung mit Klassenpfad

In Listing 1 sehen wir auch, dass wir die Klasse mit der main-Methode als Argument angeben müssen. Möchten wir hierauf verzichten, können wir beim Erzeugen der JAR-Datei das Argument --main-class=app.Main hinzufügen. Innerhalb der JAR-Datei target/app.jar gibt es nun in der Datei META-INF/MANIFEST.MF einen Eintrag Main-Class: app.Main. Dieser Eintrag wird ausgewertet, sobald wir die Anwendung mit java -jar target/app.jar starten (s. Listing 2).

$ jar \

    --create \

    --file target/app.jar \

    --main-class=app.Main \

    -C target/classes \

    app/Main.class

$ java -jar target/app.jar
Exception in thread "main" java.lang.NoClassDefFoundError: lib/Greeter
    at app.Main.main(Main.java:8)
Caused by: java.lang.ClassNotFoundException: lib.Greeter
    at ...
    at ...
    at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
    ... 1 more
Listing 2: Ausführbare JAR-Datei erzeugen

Leider läuft die Anwendung nun nicht mehr, da die Klasse lib.Greeter zur Laufzeit nicht mehr gefunden werden konnte. Auch der Versuch, diese mit -classpath lib/target/lib.jar auf den Klassenpfad zu legen, funktioniert nicht, da die Option -jar dazu führt, dass -classpath ignoriert wird. Wir haben nun, standardmäßig, zwei Möglichkeiten, um dieses Problem zu lösen.

Zum einen können wir die JAR-Datei lib/target/lib.jar entpacken und alle dort vorhandenen Dateien mit in das Archiv target/app.jar packen. Dies funktioniert zwar, hat aber zwei Nachteile. Der Aufwand hierfür ist hoch, vor allem, wenn die Anzahl der eingebundenen Bibliotheken deutlich höher als in unserem Beispiel ist. Außerdem verstößt dieses Aus- und Neuverpacken unter Umständen gegen die Lizenz der eingebundenen Bibliothek.

Somit kommen wir zur zweiten Möglichkeit. Diese besteht darin, bei der Erzeugung von target/app.jar zusätzlich über die Option –-manifest=MANIFEST.MF die Erzeugung der Datei META-INF/MANFIEST.MF innerhalb der JAR-Datei zu beeinflussen. In diesem Beispiel enthält diese MANIFEST.MF-Datei einen Eintrag, nämlich Class-Path: ../lib/target/lib.jar. Durch diesen wird, wenn die JAR-Datei mittels java -jar gestartet wird, der Klassenpfad erweitert und die JAR-Datei lib/target/lib.jar ist nun auch zur Laufzeit verfügbar (s. Listing 3).

$ cat MANIFEST.MF
Class-Path: ../lib/target/lib.jar

$ jar \

    --create \

    --file target/app.jar \

    --main-class=app.Main \

    --manifest=MANIFEST.MF \

    -C target/classes \

    app/Main.class

$ java -jar target/app.jar
Hello, world!
Listing 3: Ausführbare JAR-Datei mit Class-Path erzeugen

Wir haben somit eine ausführbare JAR-Datei, bei der wir weder die main-Klasse noch selbst einen Klassenpfad angeben müssen. Allerdings funktioniert diese nur, wenn bei der Ausführung die im Class-Path-Eintrag vorhandenen JAR-Dateien exakt am richtigen Ort liegen. In diesem Fall relativ zur app.jar unter ../lib/target/lib.jar.

Spring Boot 1

Jetzt wissen wir, wie ausführbare JAR-Dateien funktionieren, und können uns anschauen, für welche der beiden Möglichkeiten sich Spring Boot entschieden hat. Hierzu bauen wir eine Spring-Boot-Anwendung, hier mit der Version 1.0.0.RELEASE, und schauen uns die erzeugte JAR-Datei an (s. Listing 4).

$ tree .
.
├── pom.xml
├── src
│   └── main
│       └── java
│           └── bootapp
│               └── Application.java
└── target
    ...
    └── boot-app.jar

$ jar --list --file target/boot-app.jar
META-INF/
META-INF/MANIFEST.MF
...
bootapp/Application$GreetingRunner.class
bootapp/Application.class
...
lib/spring-boot-starter-1.0.0.RELEASE.jar
lib/spring-boot-1.0.0.RELEASE.jar
...
org/springframework/boot/loader/JarLauncher.class
...

$ jar --extract --file target/boot-app.jar META-INF/MANIFEST.MF \

    && cat META-INF/MANIFEST.MF
...
Start-Class: bootapp.Application
...
Main-Class: org.springframework.boot.loader.JarLauncher
Listing 4: Inhalt der ausführbaren JAR-Datei vor Spring Boot 1.4

Hierbei fallen uns gleich mehrere Dinge auf. Zwar enthält die Datei META-INF/MANIFEST.MF einen Main-Class-Eintrag, dieser zeigt jedoch nicht auf unsere Klasse, sondern auf eine von Spring Boot. Unsere eigene Klasse ist hingegen als Start-Class angegeben. Zudem gibt es keinen Class-Path-Eintrag. Auch der restliche Inhalt der JAR-Datei passt nicht in unser bisheriges Bild, dort befinden sich nämlich im lib-Verzeichnis weitere JAR-Dateien. Es sieht also so aus, als würde Spring Boot weder die verwendeten Bibliotheken neu verpacken noch einen eigenen Klassenpfad angeben. Aber wie funktioniert dann eine solche ausführbare JAR-Datei?

Das Geheimnis liegt in der Klasse org.springframework.boot.loader.JarLauncher. Da diese als Main-Class eingetragen ist, übernimmt sie den Start der Anwendung. Hierzu ist sie für zwei Dinge verantwortlich. Zum einen erzeugt sie einen für Spring Boot spezifischen java.lang.ClassLoader. Dieser weiß, dass innerhalb der JAR-Datei im lib-Verzeichnis weitere JAR-Dateien liegen, und er kann auch aus diesen Klassen oder Ressourcen zur Laufzeit laden. Zum anderen lädt der Launcher über Reflektion die in Start-Class angegebene Klasse und ruft dort die statische main-Methode auf.

Dieser Mechanismus wurde mit Spring Boot 1.4 das erste Mal verändert. Das Grundgerüst blieb dabei jedoch identisch, lediglich die Orte innerhalb der JAR-Datei wurden verändert. Von nun an liegen die Bibliotheken nicht mehr unter lib, sondern unter BOOT-INF/lib, und die eigenen Klassen werden unter BOOT-INF/classes anstatt direkt im Root der JAR-Datei abgelegt.

Spring Boot 2

Auch der Sprung auf Spring Boot 2 änderte zuerst nichts. Erst mit Spring Boot 2.3 wurde der Mechanismus erneut verändert. Und, im Vergleich zur letzten Änderung, dieses Mal deutlich.

Auslöser für diese Änderung war vor allem die nun weite Verbreitung von Docker beziehungsweise von Containern im Allgemeinen. Hierbei stellte sich heraus, dass der Ansatz einer einzelnen, relativ großen, ausführbaren JAR-Datei mit dem Konzept von cachebaren Schichten kollidierte. Dies führte dazu, dass beim Einsatz eines einfachen Dockerfile (s. Listing 5) durch jede Änderung die gesamte erzeugte JAR-Datei den gesamten Layer veränderte.

FROM bellsoft/liberica-runtime-container:jre-11-cds-slim-musl


CMD [ "java", "-jar", "boot-app.jar"]


RUN mkdir -p /app && \

    chown -R daemon /app

USER daemon

WORKDIR /app


COPY ./target/boot-app.jar boot-app.jar
Listing 5: Dockerfile für Spring Boot vor Version 2.3

Um genau dieses Problem anzugehen, wurde die Möglichkeit geschaffen, ein sogenanntes Layered JAR zu bauen. Um ein solches zu erzeugen, musste dies im Buildtool, hier Maven, angeschaltet werden (s. Listing 6). Anschließend war es nun möglich, diese JAR-Datei zu entpacken. Dabei entstanden, standardmäßig, mehrere Verzeichnisse, die anschließend der Reihe nach in den Container kopiert werden konnten, beispielsweise über einen Multi-stage Build (s. Listing 7).

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.0.RELEASE</version>
    <relativePath/>
  </parent>

  <groupId>com.innoq</groupId>
  <artifactId>boot-app</artifactId>
  <version>0.1.0-SNAPSHOT</version>

  <properties>
    <java.version>11</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
    </dependency>
  </dependencies>

  <build>
    <finalName>${project.artifactId}</finalName>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <configuration>
          <layers>
            <enabled>true</enabled>
          </layers>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
Listing 6: Maven-Konfiguration zum Anschalten von Layered JARs
FROM bellsoft/liberica-runtime-container:jre-11-cds-slim-musl AS builder


RUN mkdir -p /app
WORKDIR /app


COPY ./target/boot-app.jar boot-app.jar


RUN java -Djarmode=layertools -jar boot-app.jar extract


FROM bellsoft/liberica-runtime-container:jre-11-cds-slim-musl


CMD [ "java", "org.springframework.boot.loader.JarLauncher"]


RUN mkdir -p /app && \

    chown -R daemon /app

USER daemon

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/ ./
Listing 7: Dockerfile für Spring Boot ab Version 2.3

Durch die Reihenfolge der vier Kopierbefehle wird zuerst der Layer angelegt, welcher die eigentlichen Abhängigkeiten enthält, dann folgt der von Spring Boot beigesteuerte Loader und danach Snapshot-Abhängigkeiten sowie der Code der Anwendung selbst. Dadurch, dass sich dieser in der Regel am häufigsten ändert und die Abhängigkeiten in der Regel stabiler bleiben, wird häufig nur noch der letzte Layer übertragen, welcher nur sehr klein ist.

Bei Bedarf können, durch die Angabe einer Datei layers.xml, auch eigene Layer erzeugt werden. Dies kann beispielsweise dazu genutzt werden, verschiedene Layer für externe und interne Bibliotheken zu erzeugen. Diese Änderung war so populär, dass bereits mit dem nächsten Release, Spring Boot 2.4, das Erzeugen des Layered JAR zum Default wurde und das explizite Anstellen im Buildtool nicht mehr notwendig war.

Spring Boot 3

Die nächste, kleine Änderung kam mit Spring Boot 3.2. Da bereits seit Spring Boot 3.0 mindestens Java 17 benötigt wird, konnte der bestehende org.springframework.boot.loader.JarLauncher neu geschrieben werden. Um trotzdem abwärtskompatibel zu bleiben, wurde diese neue Implementierung in org.springframework.boot.loader.launch.JarLauncher gemacht. Dementsprechend war es beim Update auf Version 3.2 notwendig, die Angabe der main-Klasse, beispielsweise im Dockerfile oder Startskript, zu ändern.

Das im Mai 2024 erschienene und derzeit aktuelle Release, Spring Boot 3.3, veränderte den Mechanismus jedoch noch einmal komplett. Der Treiber dieses Mal war eine ideale Unterstützung von Application Class-Data Sharing (AppCDS). AppCDS erlaubt es, beim Stoppen einer Anwendung die Informationen der geladenen Klassen in eine Archivdatei zu schreiben. Wird diese bei einem Start der Anwendung wieder angegeben, wird der Inhalt quasi direkt in den Speicher geladen und die Klassen müssen nicht erneut komplett analysiert und geladen werden. Dies spart vor allem Zeit beim Start ein.

Da dieses Feature jedoch sehr stark durch Reihenfolgen beim Laden der Klassen und auch eigene ClassLoader beeinflusst werden kann, entschied sich das Team von Spring Boot dazu, den gesamten bisherigen Weg zu überdenken und einen komplett neuen zu gehen.

Listing 8 zeigt den Einsatz des neuen jarmode tools und basierend darauf das Erzeugen und die Nutzung des AppCDS-Archivs. Um das Archiv zu erzeugen, starten wir unsere Anwendung einmal und versehen diese mit den beiden Optionen -Dspring.context.exit=onRefresh und -XX:ArchiveClassesAtExit=./application.jsa. Dies führt dazu, dass die Anwendung sich selbst stoppt, sobald der Anwendungskontext erzeugt wurde und dabei die Datei application.jsa geschrieben wird. Beim eigentlichen Start können wir diese Datei nun mittels -XX:SharedArchiveFile=./application.jsa wieder angeben.

FROM bellsoft/liberica-runtime-container:jre-21-cds-slim-musl AS builder


RUN mkdir -p /app
WORKDIR /app


COPY ./target/boot-app.jar boot-app.jar


RUN java -Djarmode=tools -jar boot-app.jar extract --layers


FROM bellsoft/liberica-runtime-container:jre-21-cds-slim-musl


CMD [ "java", \

      "-XX:SharedArchiveFile=./application.jsa", \
      "-jar", "/app/boot-app.jar" ]

RUN mkdir -p /app && \

    chown -R daemon /app

USER daemon

WORKDIR /app


COPY --from=builder /app/boot-app/dependencies/ ./

COPY --from=builder /app/boot-app/spring-boot-loader/ ./

COPY --from=builder /app/boot-app/snapshot-dependencies/ ./

COPY--from=builder /app/boot-app/application/ ./


RUN java \

    -Dspring.context.exit=onRefresh \
    -XX:ArchiveClassesAtExit=./application.jsa \
    -jar boot-app.jar
Listing 8: Dockerfile für Spring Boot 3.3 mit AppCDS

Gleichzeitig hat sich aber unser Startkommando auch wieder auf ein java -jar geändert. Wenn wir doch jetzt wieder eine ausführbare JAR-Datei starten, wieso entpacken wir diese dann vorher so aufwendig? Hierzu schauen wir uns den Inhalt (s. Listing 9) des Verzeichnisses an, in das wir unsere JAR-Datei temporär extrahiert haben. Wir können hier sehen, dass sich einiges geändert hat und die Anwendung final nur noch aus der JAR-Datei boot-app.jar und einem lib-Verzeichnis mit allen Abhängigkeiten besteht. Betrachten wir nun die JAR-Datei boot-app.jar (s. Listing 10). Diese besteht nur noch aus den Klassen und Ressourcen unserer eigentlichen Anwendung. Auch die Datei META-INF/MANIFEST.MF enthält nun unsere main-Klasse als Eintrag unter Main-Class. Und zudem wird nun auch der am Anfang vorgestellte Class-Path-Eintrag für die Abhängigkeiten verwendet.

$ tree .
.
└── boot-app
    ├── application
    │   └── boot-app.jar
    ├── dependencies
    │   └── lib
    │       ├── jakarta.annotation-api-2.1.1.jar
    │       ├── ...
    │       └── spring-jcl-6.1.8.jar
    ├── snapshot-dependencies
    └── spring-boot-loader
Listing 9: Temporär mit tools jarmode entpackter Inhalt der JAR-Datei
$ jar \

    --list \

    --file boot-app/application/boot-app.jar
META-INF/MANIFEST.MF
...
bootapp/Application$GreetingRunner.class
bootapp/Application.class
...

$ jar --extract --file boot-app/application/boot-app.jar META-INF/MANIFEST.MF \

    && cat META-INF/MANIFEST.MF
...
Main-Class: bootapp.Application
Class-Path: lib/spring-boot-3.3.0.jar lib/spring-context-6.1.8.jar lib/s
 pring-aop-6.1.8.jar lib/spring-beans-6.1.8.jar lib/spring-expression-6.
 1.8.jar lib/micrometer-observation-1.13.0.jar lib/micrometer-commons-1.
...
Listing 10: Inhalt der entpackten JAR-Datei boot-app.jar

Letztlich ist somit wieder eine normale ausführbare JAR-Datei, ganz ohne eigenen ClassLoader oder andere Magie, entstanden und unsere Anwendung läuft jetzt genau so, wie Java-Anwendungen schon immer laufen konnten.

Conclusion

In diesem Artikel haben wir uns zuerst, wiederholend, angeschaut, wie sich Java-Anwendungen im Allgemeinen starten lassen. Anschließend sind wir in die Welt von Spring Boot gewechselt und haben kennengelernt, wie die hier erzeugten ausführbaren JAR-Dateien aufgebaut sind und wieso wir auch diese starten können, nämlich über einen speziellen Einstiegspunkt und einen eigenen ClassLoader.

Von diesem Punkt aus sind wir dem Pfad der Geschichte gefolgt und haben dabei gesehen, wie sich dieser Mechanismus über diverse Releases von Spring Boot aufgrund von äußeren Einflüssen geändert hat. Erst hat dabei die Containerisierung dafür gesorgt, dass wir die ausführbare JAR-Datei vor dem Start, in mehreren Schichten, entpacken möchten, um effizientes Caching von Schichten auszunutzen. Und zuletzt hat der Einfluss von AppCDS dazu geführt, dass wir wieder auf den speziellen Einstiegspunkt und eigenen ClassLoader verzichten und es mit einer klassischen, ausführbaren JAR-Datei mit Main-Class- und Class-Path-Einträgen im Manifest zu tun haben.