So beschleunigen sie existierende Deployments mit den richtigen JVM-Features

Dmitry Chuyko

In der heutigen Tech-Landschaft sehen sich Java-Anwendungen mit einer entscheidenden Herausforderung konfrontiert. Unternehmen müssen steigenden Performance-Anforderungen gerecht werden und gleichzeitig die Kosten für die Infrastruktur unter Kontrolle halten – und das ohne kostspielige Neuentwicklungen.

Diese Herausforderung hat sich mit der Einführung von Cloud Computing noch weiter verschärft. Jede Millisekunde Latenz und jedes Megabyte an Speicher wirken sich nun direkt auf die laufenden Kosten aus. Die Zahlen sind beachtlich: Basierend auf meinen Erfahrungen können schlecht konfigurierte JVMs die Cloud-Kosten um 30–50 % erhöhen. Für große Unternehmen entspricht das Millionen an vermeidbaren Ausgaben.

Häufige Optimierungsfehler

Trotz der offensichtlichen finanziellen Konsequenzen gehen die meisten Unternehmen die JVM-Optimierung nicht effektiv an. Diese vier Fehler beobachten wir immer wieder:

  1. Verwendung der Standardeinstellungen: Viele Teams nutzen einfach die vorkonfigurierten JVM-Einstellungen. Diese Standardkonfigurationen legen den Fokus eher auf Kompatibilität als auf Performance, was zu ineffizienter Heap-Nutzung, suboptimalen Garbage-Collection-Muster und ressourcenintensive Start- und Warmup-Phasen führt.
  2. Willkürliches Kopieren von Konfigurationen: Bei Optimierungsversuchen greifen Teams oft auf Einstellungen aus Blogs oder Foren zurück. Diese Konfigurationen sind allerdings zumeist für andere Workloads oder JVM-Versionen entwickelt und verschlechtern oft eher die Performance, als sie zu optimieren.
  3. Lösung von Problemen mit Ressourcen: Statt die zugrunde liegenden Probleme zu beheben, steigern viele Unternehmen schlicht CPU, Speicher und Maschinen. So geraten sie in einen Teufelskreis wachsender Kosten, ohne das Problem an der Wurzel anzugehen.
  4. Fokus auf die falschen Optimierungen: Selbst gezielte Performance-Optimierungen fokussieren sich oft auf die falschen Bereiche. Teams können beispielsweise Wochen mit der Feinjustierung der Garbage Collection zubringen, während sie einfache Konfigurationsänderungen, die einen viel größeren Einfluss hätten, unbeachtet lassen.

Diese Muster halten sich, weil viele Teams die modernen Performance-Merkmale der JVM und die leicht zugänglichen Optimierungsmöglichkeiten nicht vollständig erfassen.

Die neue Realität verstehen

Bevor wir die uns zur Verfügung stehenden Quick Wins erläutern, müssen wir die grundlegenden Veränderungen verstehen, welche die heutige JVM-Landschaft geprägt haben:

  1. Containerisierung: Die meisten Java-Anwendungen laufen heutzutage in Containern. Dadurch ergeben sich neue Herausforderungen wie beispielsweise die Interaktion der JVM mit Ressourcengrenzen, eröffnet allerdings auch neues Optimierungspotenzial.
  2. Fortschrittliche JVM-Features: Moderne JVMs bieten leistungsstarke Performance-Features, die in den meisten Deployments nicht genutzt werden. Von ausgeklügelten Garbage Collectors bis hin zur Startbeschleunigung bieten diese Features immense Vorteile bei minimalem Implementierungsaufwand.
  3. Cloud-Ökonomie: Durch die Verlagerung von Anwendungen in die Cloud sind Performance und Betriebskosten nun eng miteinander verknüpft. Bei Optimierungen geht es nicht länger nur um die Benutzererfahrung – sie ist eine Notwendigkeit zur Kostenkontrolle.

Diese Veränderungen erfordern eine neuen Ansatz in Sachen JVM-Performance – und zwar einen, der die modernen Möglichkeiten nutzt, um die spezifischen Herausforderungen containerisierter, Cloud-nativer Deployments zu bewältigen.

In diesem Artikel werden wir schrittweise immer fortschrittlichere Optimierungstechniken erläutern, mit denen sich die Performance bei nur minimalem Aufwand drastisch steigern lässt.

***

Beginnen wir unsere Reise in die Welt der JMV-Performance-Optimierung mit den Grundlagen: die richtige Container-Konfiguration. Hier sind keine Änderungen der Anwendung erforderlich – nur eine einsichtige Dockerfile-Konstruktion und JVM-Einstellungen, die sofortige Performance-Steigerungen ermöglichen. Diese Quick Wins sind bei Java-Anwendungen Ihr erster Schritt, um Performance-Einbußen mit minimalem Aufwand und Risiko einzudämmen.

Einfache Performancesteigerung mit Container-Optimierung

Mit Containern hat sich die Art und Weise revolutioniert, wie wir Java-Anwendungen verpacken und bereitstellen. Dennoch erkennen viele Unternehmen nicht die entscheidenden Möglichkeiten der Performance-Optimierung, welche die Konfiguration von Containern bieten. Bevor fortschrittliche JVM-Justierungen angegangen werden, können bereits die Art und Weise, wie wir unsere Container-Images erstellen und ihre Laufzeitumgebungen konfigurieren, erheblich zur Performance-Steigerung beitragen.

Tipp 1: Das Basis-Image ist entscheidend

Der Ausgangspunkt jeder containerisierten Java-Anwendung ist die Wahl des richtigen Basis-Images. Diese scheinbar simple Entscheidung hat weitreichende Auswirkungen auf alle Bereiche von der Startzeit bis hin zum Speicherverbrauch.

Viele Entwickler greifen standardmäßig auf allgemeine Linux-Distributionen als Basis-Image zurück. Diese Distributionen sind in der Regel rund 300 MB groß und enthalten hunderte von Utilities, Bibliotheken und Services, die für die Ausführung von Java-Anwendungen irrelevant sind. Einige Anbieter bieten schlankere Versionen ihres Betriebssystems an, wie zum Beispiel Red Hats UBI Minimal (80–100 MB) oder Debian Slim. Ein Schritt in die richtige Richtung, der aber noch weitergehen kann. 

Die musl-basierten Linux-Distributionen sind deutlich kleiner als solche mit glibc. Zu dieser Erkenntnis gelangte man, als BellSoft 2019 den Alpine Linux Port (JEP 386) zum OpenJDK beitrug und damit die richtige Unterstützung für musl libc einführte. Diese Innovation führte zu dramatischen Verringerungen der Image-Größe bei Java-Anwendungen.

Noch weiter geht die Optimierung mit der speziell für Java-Workloads optimierten Linux-Distribution Alpaquita. Sie bietet eine geringe Image-Größe, lässt Nutzern die Wahl zwischen musl- und glibc-Varianten und bietet darüber hinaus Laufzeitoptimierungen, die speziell auf JVM-Workloads abgestimmt sind, auf die wir in diesem Artikel noch näher eingehen werden.

Alternativ können Sie auf einsatzbereite Java-Images wie den Liberica Runtime Container zurückgreifen. Er ist in Alpaquita Linux und Liberica JDK Lite enthalten, einer minimierten Version des OpenJDK-Builds. Durch diese Kombination können Sie bis zu 30 % RAM und Festplattenspeicher bei Cloud-Deployments einsparen – und das ohne manuelle Feinjustierung. 

Ein weiterer einfacher Optimierungsansatz besteht in der Verwendung von JRE anstelle des vollständigen JDK bei Produktion-Containern. Diese Änderung macht sich besonders bei Microservice-Architekturen mit Dutzenden oder Hunderten von Instanzen besonders bemerkbar, da der Speicherbedarf ohne Performance-Einbußen um 15–25 % verringert wird.

Diese Optimierungen dienen allerdings nicht nur der Verringerung der Image-Größe – obwohl allein dadurch schon eine Verringerung der Deployment-Zeit und des Netzwerk-Traffics erzielt wird. Viel wichtiger ist noch, dass speziell für Java entwickelte Basis-Images häufig Performance-orientierte Konfigurationen und Unterstützung für Performance-Features bieten, die bei Universal-Images fehlen.

Tipp 2: Trennung von Anwendungsschichten

Die Schichtung von Container-Images mutet auf den ersten Blick wie ein technisches Detail an, stellt aber eine weitere Möglichkeit der Performance-Optimierung dar.

Statt eine einzige Schicht zu verwenden, sollten Sie die Komponenten basierend auf ihrer Änderungsfrequenz trennen:

# Ineffiziente Vorgehensweise – eine Schicht für alle Abhängigkeiten
COPY ./target/application.jar app.jar

# Optimierte Vorgehensweise – Trennung von Schichten basierend auf Änderungsfrequenz
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app

Diese Vorgehensweise nutzt Dockers Caching-Mechanismen, sodass es nur bei Änderungen ein Rebuild und Transfer durchgeführt werden. Bei der Aktualisierung eines Microservices könnten so beispielsweise lediglich 10 KB des geänderten Codes statt des gesamten Images mit 200 MB oder mehr übertragen werden. Bei Hunderten von Containern und häufigen Deployments kann Ihr Netzwerk-Traffic so um ein Vielfaches reduziert werden.

Tipp 3: JVM-Speicherkonfiguration

Die wichtigste Optimierungsmaßnahme für Container ist die richtige JVM-Speicherkonfiguration. Ältere JVM-Versionen erkennen standardmäßig keine Container-Speichergrenzen, was zu Out-of-Memory-Fehlern oder einer nicht optimal genutzten Ressourcen führen kann. 

Eine einfache Lösung bietet die Speicherkonfiguration: 

MaxRAMPercentage=80: Verwendet 80 % des Container-Speichers (je nach Anwendungstyp anzupassen)

# Häufiger Fehler – keine Speichereinstellungen
ENTRYPOINT ["java", "-jar", "app.jar"] 


# Optimale Vorgehensweise – prozentuale Einstellungen 
ENTRYPOINT ["java", \ 
"-XX:MaxRAMPercentage=80", \ 
"-jar", "app.jar"]

Diese einfachen Änderungen passen die JVM-Ressourcennutzung optimal an die Container-Grenzen an und verbessern den Durchsatz oftmals um 20–40 %, ohne dass Codeänderungen erforderlich sind.

***

Diese Container-Optimierungen stellen die erste Reihe an Quick Wins zur Performance-Steigerung von Java-Anwendungen dar. Von der Auswahl des Basis-Images bis hin zur Speicherkonfiguration verspricht jeder Schritt signifikante Verbesserungen bei minimalem Aufwand, was die Optimierungen für Teams aller Erfahrungsstufen anwendbar macht.

Als nächstes widmen wir uns einer weiteren wichtigen Performance-Herausforderung: der Startzeitoptimierung. Wer die ressourcenintensive Initialisierungsphase von Java-Anwendungen versteht und gezielt anzupassen weiß, kann weitere signifikante Verbesserungen in Sachen Deployment-Effizienz und Ressourcennutzung erzielen.

Der Weg zur Near-Zero-Startzeit

Java-Anwendungen haben ein Ressourcennutzungsmuster, das erhebliche Skalierungsherausforderungen mit sich bringt. Statt einen gleichmäßigen Ressourcenverbrauch über den gesamten Zyklus hinweg aufrechtzuerhalten, folgen sie einem charakteristischen „Peak-then-Plateau“-Muster, bei dem der Verbrauch zu Beginn der Startphase extrem hoch ist und subsequent im Normalbetrieb stark sinkt.

Dieses Ressourcennutzungsprofil bringt eine grundlegende Skalierungsineffizienz mit sich. Besonders die horizontale Skalierung gestaltet sich problematisch, da jede neue Java-Instanz eine solche intensive Initialisierungsphase erfordert. So kann das Hinzufügen weiterer Instanzen sogar die Gesamt-Performance des Systems verringern, da Ressourcen für den Startprozess statt für tatsächliche Anfragen verbraucht werden.

Werfen wir einen Blick auf echte Performance-Daten aus unserem Test mit einer typischen Spring-Boot-Anwendung:

  • CPU-Nutzung: Peak bei 400 % (4 Kerne) zu Beginn der Startphase, pendelt sich im Normalbetrieb bei ~ 100 % ein.
  • Speichernutzung: Hoch zu Beginn der Heap-Initialisierung, stabilisiert sich nach mehreren Garbage-Collection-Zyklen.
  • Antwortzeit: Die ersten Anfragen benötigen 5–10-mal länger als Anfragen im Normalbetrieb.
  • Startzeitlänge: 41 Sekunden, um (unter Testbedingungen) bei 1000 Anfragen/Sekunde voll betriebsbereit zu sein.  

In Production-Umgebungen mit komplexeren Initialisierungsanforderungen kann die Startzeit auf 15–20 Minuten steigen. Während dieses Zeitraums können die Antwortzeiten 5–10-mal langsamer als standardmäßig vorgesehen sein, wobei auch die Fehlerquote häufig ansteigt, während Verbindungspools und Caches initialisiert werden. Dies führt zu einem kritischen Skalierungsproblem: Die Ressourcen können nicht verdoppelt werden, um die Performance zu verdoppeln. Nicht optimierte Java-Anwendungen bringen unweigerlich eine Unterauslastung der Ressourcen und erhöhte Cloud-Kosten mit sich und liefern gleichzeitig eine inkonsistente Benutzererfahrung bei Skalierung.

4 Möglichkeiten zur Verringerung der Startzeit

Das Java-Ökosystem bietet mehrere starke Quick Wins, um Herausforderungen bei Startzeit-Engpässen zu meistern, wobei jeder Schritt raffinierter als der vorangegangene ist.

Level 0: Client VM – Der einfache Switch

Die schlichteste Strategie setzt auf die oft übersehene Client VM, die gezielt einen schnellen Start optimiert, statt den langfristigen Durchsatz zu maximieren:

ENTRYPOINT ["java", "-client", "-jar", “app.jar"]

Hinweis: Hierfür benötigen Sie ein JDK-Bundle, das eine Client VM enthält. 

Die Tests mit einer standardmäßigen Spring-Boot-Anwendung ergeben die folgenden Werte:

  • Startzeit: um 15–25 % verringert.
  • CPU-Verbrauch: Niedrigerer anfänglicher Peak, Initialisierung erfordert normalerweise 1–2 Kerne statt 3–4.
  • Speicherverbrauch: rund 10 MB weniger.
  • Trade-off: Möglicherweise niedrigerer Peak-Durchsatz bei anhaltend hoher Last.  

Diese Optimierung erweist sich insbesondere bei serverlosen Deployments, kurzlebigen Prozessen und Anwendungen mit moderatem Durchsatz als vorteilhaft. AWS Lambda empfiehlt beispielsweise die Option -XX:TieredStopAtLevel=1. Damit verhält sich die Server VM beim Kompilieren ähnlich wie die Client VM,wodurch Kaltstartzeiten erheblich verkürzt werden, während die Performance für serverlose Funktionen auf einem akzeptablen Niveau bleibt.

Level 1: Class Data Sharing – Wiederverwendung geladener Klassen

Das Class Data Sharing (CDS) optimiert einen der ressourcenintensivsten Aspekte des Java-Startvorgangs: das Laden von Klassen. Durch die Erstellung im Speicher abgelegter Archive bereits geladener Klassen verkürzt CDS die Startzeit erheblich und reduziert zugleich den Speicherverbrauch:

# Erste Phase: Erstellen eines geteilten Archivs
java -XX:ArchiveClassesAtExit=./application.jsa -jar target/app.jar

# Starten der Anwendung und Nutzung des geteilten Archivs:
java -XX:SharedArchiveFile=application.jsa -jar target/app.jar

Bei Spring-Boot-Anwendungen gestaltet sich diese Optimierung noch einfacher, da CDS bereits nativ unterstützt wird:

# Erstellen eines ausführbaren JAR
FROM bellsoft/liberica-runtime-container:jdk-23-stream-musl as builder

WORKDIR /home
ADD app /home/app
RUN cd app && ./mvnw clean package

# Erstellen eines geschichteten JAR
FROM bellsoft/liberica-runtime-container:jdk-23-cds-slim-musl as optimizer

WORKDIR /app
COPY --from=builder /home/app/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher

# Kopieren der Anwendungsschichten in das neue Basis-Image und Erstellung des Archivs
FROM bellsoft/liberica-runtime-container:jdk-23-cds-slim-musl

COPY --from=optimizer /app/app/dependencies/ ./
COPY --from=optimizer /app/app/spring-boot-loader/ ./
COPY --from=optimizer /app/app/snapshot-dependencies/ ./
COPY --from=optimizer /app/app/application/ ./

# Ausführen der Anwendung im Container zur Erstellung des Archivs. Die Anwendung wird automatisch geschlossen. Aktivierung von Spring AOT.
RUN java -Dspring.aot.enabled=true \
  -XX:ArchiveClassesAtExit=./application.jsa \
  -Dspring.context.exit=onRefresh \
  org.springframework.boot.loader.launch.JarLauncher

# Ausführen der Anwendung mit aktivierter Spring AOT und unter Verwendung des geteilten Archivs
ENTRYPOINT ["java", \
  "-Dspring.aot.enabled=true", \
  "-XX:SharedArchiveFile=application.jsa", \
  "org.springframework.boot.loader.launch.JarLauncher"]

Tests zeigen, dass CDS in der Regel folgende Vorteile bietet:

  • Startzeit: Verringerung um 30–40 %
  • Speichereinsparungen: 5–15 % weniger Speichernutzung dank geteiltem Metaspace
  • CPU-Verringerung: weniger ausgeprägter initialer CPU-Spitzenlast beim Start
  • Kompatibilität: Funktioniert mit nahezu allen Java-Anwendungen, ohne dass Code-Änderungen erforderlich sind. 

Level 2: Native Image von Grund auf kompiliert

GraalVM Native Image verfolgt einen radikaleren Ansatz, indem Java-Anwendungen im Voraus zu eigenständigen nativen Executables kompiliert:

# Build-Phase mit GraalVM CE-basiertem Liberica Native Image Kit
FROM bellsoft/liberica-native-image-kit-container:jdk-21-nik-23.1-stream-musl
WORKDIR /home/myapp
COPY Demo.java /home/myapp/
RUN javac Demo.java
RUN native-image Demo

# Run-Phase
FROM bellsoft/alpaquita-linux-base:stream-musl
WORKDIR /home/myapp
COPY --from=0 /home/myapp/demo.
CMD [“./demo”]

Für Spring-Boot-Anwendungen wird der Native-Image-Prozess durch die eingebaute Unterstützung weiter vereinfacht: ./mvnw -Pnative spring-boot:build-image.

Native Image liefert drastische Verbesserungen beim Start:

  • Startzeit: 10–100-mal schneller (Millisekunden statt Sekunden)
  • Speicherverbrauch: Häufig 50 % geringer
  • Trade-off: Einschränkungen bei der Reflexion, möglicherweise geringerer Spitzendurchsatz  

Level 3: Checkpoint/Restore (CRaC) – Einfrieren einer laufenden Anwendung

Der fortschrittlichste Ansatz nutzt das OpenJDK-Projekt Coordinated Restore at Checkpoint (CRaC). CRaC funktioniert wie das Erstellen eines Snapshots Ihrer vollständig initialisierten und laufenden Java-Anwendung, die dann „eingefroren“ wird. Sie können diesen Snapshot sofort wieder „auftauen“ und wiederherstellen, wodurch die gesamte Start- und Warmup-Phase umgangen wird:

# Erstellen des Container-Images, Starten und Beenden der Anwendung in einem Container zu Erstellung einer gespeicherten Datei der Anwendung
FROM bellsoft/liberica-runtime-container:jdk-21-crac-musl as builder
WORKDIR /home
ADD app /home/app
RUN cd app && ./mvnw clean package
 
FROM bellsoft/liberica-runtime-container:jre-21-crac-slim-musl as optimizer

WORKDIR /app
COPY --from=builder /home/app/target/app.jar /app/app.jar

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

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

# Bleiben Sie als Root im Container, um CRaC zu nutzen.
VOLUME /tmp
EXPOSE 8080

COPY --from=optimizer /app/app/dependencies/ ./
COPY --from=optimizer /app/app/spring-boot-loader/ ./
COPY --from=optimizer /app/app/snapshot-dependencies/ ./
COPY --from=optimizer /app/app/application/ ./

ENTRYPOINT ["java", "-Dspring.context.checkpoint=onRefresh", "-XX:CRaCCheckpointTo=/checkpoint", "-XX:MaxRAMPercentage=80.0", "org.springframework.boot.loader.launch.JarLauncher"]

Hier ist der vollständige Workflow zur Implementierung von CRaC in Ihrer Deployment-Pipeline vom Container-Start über das Erstellen des Checkpoints bis hin zum Starten der Anwendung aus dem gespeicherten Zustand:

# Erstellen des Container-Images
docker build . -t app-crac-checkpoint -f Dockerfile-crac

# Starten des Container-Images
docker run --cap-add CHECKPOINT_RESTORE --cap-add SYS_PTRACE --name app-crac app-crac-checkpoint

# Übertragen des Image-Inhalts in ein neues Image und Ändern des Entrypoints, um die Anwendung aus der Datei neu zu starten:
docker commit --change='ENTRYPOINT ["java", "-XX:CRaCRestoreFrom=/checkpoint"]' app-crac app-crac-restore

# Entfernen des ursprünglichen Containers:
docker rm -f app-crac

# Ausführen des zweiten Images mit der gespeicherten Anwendung:
docker run -it --rm -p 8080:8080 --cap-add CHECKPOINT_RESTORE --cap-add SYS_PTRACE --name app-crac app-crac-restore

CRaC bietet die ultimative Startzeit-Optimierung:

  • Startzeit: nahezu sofort (Millisekunden)
  • Speichereffizienz: vorinitialisierter Heap ohne Störungen durch Garbage Collection
  • CPU-Einsparungen: Fängt nahezu alle CPU-Spitzenlasten beim Start ab
  • Voraussetzungen: Erfordert eine CRaC-kompatible JVM (z. B. Liberica JDK CRaC) sowie Anwendungsänderungen für eine sichere und saubere Checkpoint/Restore-Funktion 

Die Zukunft: Project Leyden

Project Leyden stellt die natürliche Weiterentwicklung der bereits erwähnten Optimierungstechniken dar. Doch statt eine Technologie der fernen Zukunft zu sein, wird es bereits aktiv entwickelt, um die AppCDS-Funktionen innerhalb von OpenJDK noch zu übertreffen. Aufbauen auf AppCDS zielt Leyden darauf ab, einen einheitlichen Cache zu schaffen, der nicht nur geladene Klassen speichert, sondern auch kompilierten Code, Methodenprofile und verknüpfte Klassen. Leyden wird auf allen unterstützten Java-Plattformen arbeiten und nach einem initialen Trainingslauf einen zustandslosen Betrieb ermöglichen. Dieser umfassende Ansatz verspricht, die Startvorteile der AOT-Kompilierung mit der Laufzeit-Performance einer JVM im Normalbetrieb zu kombinieren, und das ohne die Trade-offs aktueller Ansätze.

Die richtige Startoptimierung wählen

Jeder Lösungsansatz zur Startzeitoptimierung erfordert andere Kompromisse. Für die meisten Anwendungen ist ein schrittweises Implementierungsmodell am besten geeignet:

  • Beginnen Sie mit Client VM und/oder CDS, um sofortige Verbesserungen bei minimalem Risiko zu erzielen.
  • Ziehen Sie für Anwendungen mit besonders hohen Startzeitanforderungen Native Image oder CRaC in Betracht.

Durch die Wahl des richtigen Optimierungsansatzes können Unternehmen ihren Ressourcenverbrauch drastisch verringern, was eine effizientere Skalierung und deutlich reduzierte Cloud-Kosten mit sich bringt.

Garbage Collection: Die Wahl des richtigen Collectors ist entscheidend

Garbage Collection (GC) ist ein automatisierter Prozess, durch den ungenutzter Speicher in Java-Anwendungen freigegeben wird. So entfällt die Notwendigkeit für ein manuelles Speichermanagement. Allerdings kann eine falsche GC-Konfiguration zu häufigen und/oder langen Pausen führen, welche die Reaktionsfähigkeit und den Durchsatz der Anwendung beeinträchtigen.

Garbage Collection galt lange als der komplexeste Aspekt beim JVM-Tuning. Teams haben Wochen damit zugebracht, GC-Parameter zu optimieren, was oft nur in marginalen Verbesserungen resultierte. Moderne JVMs bieten jedoch einen einfacheren und effektiveren Ansatz: die Wahl des richtigen Garbage Collectors auf Grundlage Ihrer spezifischen Anwendungsanforderungen.

AnwendungstypCollector-WechselTypische Verbesserung
Latenzempfindliche APIG1 → ZGC90–99 % Verringerung der max. Pausenzeiten
Batch-VerarbeitungG1 → Parallel10–15 % Durchsatzverbesserung
Begrenzter SpeicherG1 → Serial5–10 % Verringerung des Speicherverbrauchs

Die durch die richtige Wahl des Collectors erzielten Performance-Steigerungen können erheblich sein. Noch beeindruckender ist, dass diese Verbesserungen nur eine einzige Konfigurationsänderung erfordern – im Gegensatz zum traditionellen GC-Tuning, das oft Dutzende von Parametern umfasst. Daher besteht der einfachste und effektivste Quick Win bei der GC-Optimierung schlicht darin, den richtigen Garbage Collector für die eigenen Performance-Ziele auszuwählen.

  • Für Low-Pause-Anwendungen: ZGC und Shenandoah. Diese beiden GCs liefern unabhängig von der Heap-Größe (selbst bei über 100 GB) Pausen von unter einer Millisekunde bei vorhersehbarer Performance unter Speicherdruck, wenn auch auf Kosten eines geringen Durchsatzverlustes.
  • Für maximalen Durchsatz: Parallel GC. Parallel GC bietet 10–15 % höheren Durchsatz für Batch-Verarbeitungs- und CPU-intensive Anwendungen, wobei höhere Pausenzeiten in Kauf genommen werden müssen.
  • Für Ausgewogenheit: G1 GC. G1 bietet eine gute Balance für allgemeine Anwendungen mit konfigurierbaren Pausenzielen und angemessenem Durchsatz.
  • Für Umgebungen mit begrenzten Ressourcen: Serial GC. Serial GC verringert den Speicher-Overhead und die CPU-Konkurrenz in kleinen Containern, wodurch er in Umgebungen mit begrenzten Ressourcen besonders effizient ist.
  • Die nächste Generation: Generational ZGC und Generational Shendandoah. Diese beiden neuen GC-Versionen behalten die ultra-niedrigen Pausenzeiten bei, reduzieren den Speicherverbrauch um 10–25 % und verbessern die Handhabung kurzlebiger Objekte – eine Lösung ohne Kompromisse.

Die Zukunft: Project Lilliput

Projekt Lilliput stellt jenseits der Collector-Wahl einen weiteren kommenden Quick Win für die Speichereffizienz dar und verringert die Größe der Java-Objekt-Header:

# Aktivieren von Lilliput für eine verringerte Objekt-Header-Größe (JDK 24+ experimentell)

ENTRYPOINT ["java", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseCompactObjectHeaders", "-jar", "app.jar"]

Lilliput kann den Heap-Speicherbedarf in objektintensiven Anwendungen ohne Code-Änderungen um 10–20 % verringern. Das Projekt zielt darauf ab, ein seit langem bestehendes Speicherproblem von Java zu beheben, bei dem der Objekt-Header unverhältnismäßig viel Heap-Speicher verbraucht.

GC-Tuning in der Praxis

Der moderne Ansatz zur GC-Optimierung folgt einem schrittweisen Vorgehen von einfachen zu fortgeschrittenen Techniken:

  1. Beginnen Sie mit der Auswahl eines Collectors, basierend auf Ihrem primären Performance-Ziel.
  2. Fügen Sie eine geeignete Speicherkonfiguration hinzu, die auf Ihre Container-Umgebung abgestimmt ist.
  3. Ziehen Sie zur weiteren Optimierung experimentelle Features wie Lilliput in Betracht.
  4. Erwägen Sie Parameter-Feinjustierung nur, wenn es absolut notwendig ist.

Dieser Ansatz stellt eine drastische Vereinfachung gegenüber dem traditionellen CG-Tuning dar und bietet deutlich höhere Renditen. Die Zeiten, in denen ein GC-Experte notwendig war, um eine exzellente Java-Performance zu erzielen, sind praktisch vorbei.

Legacy-Anwendungen: Es gibt noch Hoffnung

Die bisher vorgestellten Optimierungstechniken entfalten ihr volles Potenzial auf modernen JVMs – also hauptsächlich ab Java 17. In der Realität der meisten Unternehmen stellt dies allerdings eine Herausforderung dar, da ihre Produktion-Workloads immer noch auf älteren Versionen laufen.

Laut verschiedenen Quellen (z. B. „Status quo des Java-Ökosystems 2024“ von New Relic oder „The State of Developer Ecosystem 2023“ von JetBrains) verwenden 29–50 % der Befragten noch Java 8, während 32–38 % auf Java 11 setzen. In der Java Developer Survey von BellSoft gaben zwei Drittel der Befragten an, ihre Anwendungen noch auf Java 11 oder früheren Versionen auszuführen.

Diese Statistiken spiegeln eine gängige Realität in Unternehmen wider: Die Migration von Legacy-Anwendungen auf neuere Java-Versionen stellt häufig erhebliche technische und geschäftliche Herausforderungen dar. Das Resultat besteht aus Performance- und Effizienzeinbußen, da Unternehmen, die ältere Java-Versionen verwenden, wichtige Optimierungsmöglichkeiten verpassen, die in neueren JDKs zur Verfügung gestellt werden.

Fused JDKs: Moderne Performance für Legacy-Anwendungen

Glücklicherweise gibt es für Java-Legacy-Anwendungen einen leistungsstarken Quick Win: Fused JDKs. Diese spezialisierten Distributionen kombinieren die API-Kompatibilität älterer Java-Versionen mit den Performance-Verbesserungen moderner oder maßgeschneiderter JVM-Implementierungen.

Liberica JDK Performance Edition stellt ein Open-Source-Beispiel für diesen Ansatz dar. Die Edition bewahrt eine klare Trennung zwischen der Java-API (mit der Ihr Anwendungsquellcode interagiert) und der JVM-Implementierung (der Laufzeitumgebung, die Ihren Code ausführt):

  • API-Schicht: Bleibt 100 % kompatibel mit der Ziel-Java-Version (8 oder 11).
  • JVM-Schicht: Integriert Optimierungen aus neueren JVM-Versionen.

Diese Architektur ermöglicht es Anwendungen, von modernen JVM-Optimierungen zu profitieren, ohne dass Code-Änderungen erforderlich sind oder Kompatibilitätsrisiken bestehen. Ihre Anwendung nutzt weiterhin die APIs von Java 8 oder Java 11, für die sie entwickelt wurde, nutzt dabei aber die modernen Performance-Verbesserungen moderner JVMs:

  1. Moderne Garbage Collection: Legacy-Anwendungen können auf ZGC und andere Low-Pause-Collector zurückgreifen, die eine Verringerung der Pausenzeiten um bis zu 90 % ermöglichen – ganz ohne Änderungen an Ihrer Anwendung.
  2. Laufzeitoptimierung: Anwendung auf Grundlage von Java 8 oder 11 profitieren von JIT-Compiler-Verbesserungen, optimierter String-Verarbeitung und erweiterten Intrinsics, was zu 10–30 % höherem Durchsatz bei vielen Workloads führt.
  3. Speichereffizienz: Speicheroptimierungen aus neuen JVMs verringern den Speicherbedarf und verbessern die Effizienz der Garbage Collection bei Legacy-Anwendungen.
  4. Container-Bewusstsein: Legacy-Anwendungen profitieren von einem container-bewussten Ressourcenmanagement, das in den Java-Versionen 8 und 11 noch nicht verfügbar war.

Performance-Tests mit Java-Standardbenchmarks zeigen klare Verbesserungen:

  • Spring Boot 2.7.x-Anwendungen auf Java 8: 15–20 % Durchsatzverbesserung
  • Java 8-Webanwendungen: bis zu 70 % Verringerung der GC-Pausenzeiten
  • Java 11 Batch-Verarbeitung: 10–15 % schnellere Ausführungszeit 

Noch wichtiger: Diese Verbesserungen erfordern keine Änderungen am Anwendungsquellcode, keine komplexen Migrationsprojekte und nur minimale Betriebsänderungen – der einfache Wechsel zu einer Fused-JDK-Distribution liefert also sofortige Vorteile.

***

Bei der Auswahl eines Anbieters für ein Fused JDK ist es wichtig, die Distribution mit Ihrer eigenen Anwendung zu testen. Verschiedene Builds können unterschiedliche Performance-Verbesserungen bei unterschiedlichen Workloads bieten. Ein weiterer wichtiger Aspekt ist der Vendor Lock-in. Viele Fused JDKs verwenden maßgeschneiderte JVMs. Dies kann zu Hindernissen führen, wenn Sie zum betreffenden JDK wechseln oder später auf neuere Versionen des JDK upgraden möchten. Berücksichtigen Sie dies bei Ihrer Entscheidung – wir empfehlen, sich an Open-Source-Lösungen zu halten, die Ihnen die Flexibilität gewähren, Ihren Stack in Zukunft anzupassen.

Alles in allem können Unternehmen durch den Einsatz von Fused JDKs die Lebensdauer von Java-Legacy-Anwendungen verlängern und gleichzeitig Performance und Ressourceneffizienz signifikant steigern – ein „legales Aufputschmittel“ für einen bedeutenden Teil der Java-Unternehmenswelt, der weiterhin auf älteren JDK-Versionen läuft.

Buildpacks: Die ultimative Alternative zu Dockerfiles

Das manuelle Optimieren von Dockerfiles und JVM-Einstellung bringt schon an und für sich erhebliche Vorteile. Buildpacks bieten allerdings noch weitaus mehr – und das bei deutlich geringerem Aufwand. Diese Technologie eliminiert die Notwendigkeit, vollständig zu schreiben, indem sie Ihren Anwendungsquellcode direkt in optimierte Container-Images umwandelt.

Buildpacks vereinfachen den Prozess des Erstellens containerisierter Anwendungen und integrieren parallel fortschrittliche JVM-Anpassungen und -Optimierungen. Sie bieten sofort einsatzbereite Unterstützung für fortgeschrittene Features wie AppCDS und Native Image, die andernfalls umfangreiche Expertise erfordern würden.

Statt komplexe Dockerfiles zu schreiben und zu warten, können Sie das Build-System Ihres Projekts (Maven oder Gradle) verwenden und mit einem einzigen Befehl ein Container-Image erstellen:

# Ein einziger Befehl ersetzt Ihr gesamtes Dockerfile

mvn spring-boot:build-image

oder

gradle bootBuildImage

Dieser einfache Befehl löst einen fortschrittlichen Workflow aus:

  1. Erkennungsphase: Das Buildpack analysiert zur Identifizierung des Anwendungstyps Ihre Codebase. Es ermittelt die Anforderungen und stellt alle Ressourcen bereit, die für den Betrieb der Anwendung erforderlich sind.
  2. Build-Phase: Das Buildpack erfüllt seine Aufgabe, indem es ein optimiertes Container-Image erstellt.

Performance-Vorteile ganz ohne Konfiguration

In den meisten Fällen funktionieren Buildpacks ganz ohne Konfiguration. Sie verwenden standardmäßig die neueste Version optimierter Basis-Images und bringen automatische Performance-Verbesserungen mit sich. Für Spring-Boot-Anwendungen erstellen die Buildpacks von Paketo automatisch geschichtete JARs mit optimierten Strukturen, die – wie bereits in diesem Artikel erläutert – einen schnelleren Start und einen geringeren Speicherverbrauch ermöglichen. 

Buildpacks bieten mehrere Quick Fixes, mit denen Sie problemlos sofortige Performance-Verbesserungen erzielen können:

  1. Automatisierte Ressourcenberechnung: Eine der größten Vorteile von Buildpacks besteht in der Fähigkeit, Ressourcen basierend auf den tatsächlichen Anwendungsanforderungen korrekt zu berechnen.
  2. Schnelles Patchen von OS-Sicherheitslücken: Buildpacks integrieren sich nahtlos in moderne CI/CD-Systeme. Somit ist ein schneller Rebuild Ihrer Anwendungen möglich, ohne die Build-Tools umfassend anzupassen. Mit Buildpacks in Ihrem CI können Sie die Betriebssystemschicht Ihrer Anwendungs-Images ohne Rebuild Ihres Quellcodes patchen.
  3. Standardisierte Optimierungsansätze: Buildpacks erzwingen eine konsistente Optimierung über Ihr gesamtes Anwendungsportfolio hinweg. Alle Anwendungen erhalten das gleiche Maß an Optimierung, sodass sich Entwickler auf den Code statt auf Container-Konfigurationen konzentrieren können. Neue JVM-Optimierungen werden von Build-Paketen automatisch integriert, sobald sie verfügbar sind.

Fortgeschrittenes Performance-Tuning leicht gemacht

Die wahre Stärke von Buildpacks zeigt sich, wenn Sie Ihre Performance auf das nächste Level heben möchten. Statt sich mit der Erlernung Dutzender JVM-Optimierungs-Flags abzumühen, können Sie die Funktionen von Buildpacks durch einfache Konfiguration nutzen. Hier sind einige Beispiele: 

1/Optimierung für einen schnellen Start:

# AppCDS für schnellere Starts aktivieren
BP_JVM_CDS_ENABLED=true

2/Umstieg auf Native Image für eine Near-Zero-Startzeit: 

# Für Spring Boot 3.x-Anwendungen

./mvnw -Pnative spring-boot:build-image

***

Während Dockerfiles nach wie vor den gängigsten Containerisierungsansatz darstellen, bietet sich mit Buildpacks eine fortschrittliche und Java-bewusste Lösung. Sie vereinen jahrelange JVM-Optimierungsexpertise in einem benutzerfreundlichen Tool, das konstant bessere Ergebnisse als manuell erstellte Container liefert.

Für die meisten Java-Anwendungen bringt der Wechsel von Dockerfiles zu Buildpacks sofortige Performance-Verbesserungen bei geringem Aufwand. Somit handelt es sich um den vielleicht einfachsten, aber effektivsten Quick Win für Teams.

Aufbau einer nachhaltigen Optimierungsstrategie

In diesem Artikel haben wir leistungsstarke Quick Wins zur Verbesserung der Java-Anwendungs-Performance ohne Code-Änderungen untersucht. Diese Optimierungen schaffen einen positiven Kreislauf: Eine verbesserte Performance führt zu Ressourceneinsparungen, die wiederum weitere Optimierungen und letztlich eine Modernisierung ermöglichen können.

Die Wahl des perfekten Optimierungsansatzes hängt von Ihrer aktuellen Java-Version ab:

JDK-VersionErste Ebene (First Tier)Zweite Ebene (Second Tier)Dritte Ebene (Third Tier)Migrationspfad
JDK 8Fused JDKs (Liberica JDK Performance Edition) oder Liberica JDK Lite Container-OptimierungTest mit Java 11
JDK 11Fused JDKs (Liberica JDK Performance Edition) oder Liberica JDK Lite Container-OptimierungAppCDSValidierung mit Java 17
JDK 17+Container-Optimierung, Generational ZGC/ShenandoahCRaC, GraalVM oder AppCDSProject Lilliput, Buildpacks, Virtuelle Threads (JDK 21+)Informieren Sie sich über Updates.

Optimierung und ihre Alternativen

Bei der Bewältigung von Performance-Problemen ziehen Unternehmen in der Regel drei Ansätze in Betracht:

  • Einfach mehr Ressourcen: Schneller Ansatz, der allerdings zu nicht nachhaltigem Kostenzuwachs führt und das Problem nicht an der Wurzel angeht.
  • Komplettes Neuschreiben: Beseitigt technische Defizite, ist jedoch mit hohen Kosten, Risiken und Zeitaufwand verbunden.
  • Strategische Optimierung: Liefert sofortige Vorteile bei geringem Aufwand und Risiko; schafft Raum für geplante Modernisierungen.

Der nachhaltige Weg nach vorn

Die effektivste und nachhaltigste Java-Optimierungsstrategie folgt den folgenden Prinzipien:

  1. Bleiben Sie soweit möglich auf dem neuesten Stand. Neue JDK-Versionen bringen standardmäßig Performance-Verbesserungen mit sich.
  2. Erst optimieren, dann skalieren. Beheben Sie Effizienzprobleme, bevor Sie mehr Ressourcen hinzufügen.
  3. Optimierung automatisieren. Nutzen Sie Buildpacks, um Performance-Expertise zu demokratisieren.
  4. Alles wird gemessen. Treffen Sie Ihre Entscheidung auf Grundlage der Datenlage, nicht auf Grundlage von Annahmen.
  5. Progressive Verbesserungen. Betrachten Sie die Optimierung als einen kontinuierlichen Prozess.

Durch die Anwendung dieser Quick Wins können selbst Java-Legacy-Anwendungen dramatische Performance-Verbesserungen erzielen – und das ganz ohne Risiko oder die Kosten einer Neuschreibung. Dieser nachhaltige Ansatz wägt die sofortigen Performance-Anforderungen gegen eine langfristig funktionale Architektur ab und transformiert ihre Anwendung durch Evolution statt Revolution.

Neugierig geworden?
Dmitry Chuyko ist Speaker der JCON!
Dieser Artikel behandelt das Thema seiner JCON-Session. Du konntest nicht live dabei sein? Kein Problem – das Video wird nach der JCON verfügbar sein. Anschauen lohnt sich!

Total
0
Shares
Previous Post

Entwicklung von KI-Anwendungen mit Spring AI

Next Post

Schluss mit ORM-Problemen: EclipseStore Hands-On mit dem Leiter des Open-Source-Projekts

Related Posts