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.
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).
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).
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).
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.
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).
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.
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.
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.