Vergangenheit, Gegenwart und Zukunft
In den letzten 30 Jahren hat sich Java von einer exotischen „write once, run anywhere“-Sprache zu einer der weltweit dominierenden Plattformen für die Softwareentwicklung entwickelt. In den Anfangsjahren galt Java im Vergleich zu Sprachen wie C/C++ zu Recht als langsam, was vor allem auf den anfänglichen Interpreter-Ansatz zurückzuführen war. Die letzten 30 Jahre haben bewiesen, dass das VM-Konzept mit der adaptiven Optimierung der HotSpot-Engine langfristig die effizientere Lösung ist.
Frühere JVM-Versionen führten Bytecode rein interpretiert aus, was Java-Programme 10- bis 20-mal langsamer machte als entsprechenden C-Code. Die Performance war also von Anfang an eine Herausforderung, aber kontinuierliche Optimierungen haben die Ausführungsgeschwindigkeit seitdem drastisch verbessert.
Deshalb werfen wir in diesem Artikel einen detaillierten Blick auf die Evolution der Java-Performance. Wir blicken zurück in die Vergangenheit – von den ersten JVMs der 90er Jahre bis zur Einführung des JIT-Compilers und der ersten Garbage-Collector-Strategien. Anschließend schauen wir auf die Gegenwart moderner JVMs: die HotSpot-Engine, aktuelle Tiered Compiler (C1, C2 und GraalVM), fortschrittliche GCs wie G1, Shenandoah und ZGC, Verbesserungen beim Threading (z. B. Project Loom) und Speicheroptimierungen.
Ein Blick in die Zukunft zeigt, was Entwickler mit Loom, CRaC, nativer Kompilierung und neuen Projekten erwarten können. Zum Schluss noch ein paar praktische Tipps, um sich im Java-Performance-Universum nicht zu verirren.
Vergangenheit: Die Anfänge der Java-Performance
Die allerersten Java-Versionen (JDK 1.0 und 1.1 in den Jahren 1996–1997) setzten ausschließlich auf Interpreter. Der Java-Bytecode wurde also zur Laufzeit Befehl für Befehl emuliert, anstatt in nativen Maschinencode übersetzt zu werden. Dieser Ansatz gewährleistete die Portabilität, führte aber zu einem erheblichen Overhead. Durchschnittliche Java-Anwendungen liefen anfangs 10- bis 20-mal langsamer als in C geschriebene Programme. Entwickler spotteten daher in den 90er Jahren oft über Java als „zu langsam für ernsthafte Anwendungen“.
Ein Just-in-Time-Compiler (JIT) wurde erstmals 1997 mit Java 1.1 eingeführt. Der JIT-Compiler übersetzt häufig ausgeführte Bytecode-Sequenzen dynamisch in nativen Code, um die Ausführung erheblich zu beschleunigen. Allerdings war die frühe JIT-Kompilierung selbst rechenintensiv, so dass sie zunächst nur begrenzt, genutzt werden konnte. Der eigentliche Wendepunkt kam vom HotSpot-Team: Ihre Technologie wurde 1999 zunächst als Option für Java 1.2 zur Verfügung gestellt und wurde ab Java 1.3 (2000) als HotSpot JVM zum Standard. HotSpot führte die adaptive Optimierung ein: Die JVM beobachtet den Code zur Laufzeit, identifiziert „Hot Spots“ (häufig verwendete Codepfade) und kompiliert diese selektiv mit dem JIT, während weniger kritischer Code in interpretierter Form verbleiben kann. Dieser selektive Ansatz reduzierte den Kompilierungsaufwand erheblich und führte zu einer bis zu zehnfachen Leistungssteigerung gegenüber rein interpretiertem Code, wie Benchmarks zeigen.
Ebenfalls erwähnenswert aus dieser Ära ist das Threading-Modell. Die Plattformunabhängigkeit führte zunächst zu Einschränkungen bei der Verwendung von Betriebssystem-Threads. Java 1.1 verwendete auf einigen Plattformen Green Threads – d. h. Threads, die vom JVM-Laufzeitsystem im User Space verwaltet wurden, statt native Betriebssystem-Threads. Obwohl dies Threading auch auf Systemen ohne eigene Thread-Unterstützung ermöglichte und geringe Kosten für Thread-Switches verursachte, war es auf Multiprozessorsystemen nicht skalierbar. Alle Green Threads eines Prozesses teilten sich einen Kernel-Thread, so dass eine blockierende Operation (z. B. I/O) die gesamte VM zum Stillstand bringen konnte.
Mit Java 1.3 vollzog die JVM den Wechsel zu nativen Threads, wodurch die Parallelität auf Multi-Core-Systemen erheblich verbessert wurde – allerdings auf Kosten leicht erhöhter Aufwände für die Thread-Erstellung und den Kontextwechsel. Interessanterweise ist Java kürzlich mit Project Loom zum Konzept der ultraleichten, laufzeitverwalteten Threads zurückgekehrt – mehr dazu später.
Memory Management und Garbage Collection (GC) in den 90ern
Ein weiteres wichtiges Leistungsproblem war in den ersten Tagen die Memory Management. Java befreite die Entwickler von der manuellen Speicherzuweisung und -freigabe und reduzierte so die Programmierfehler, übertrug aber die Verantwortung für die Speicherbereinigung an die JVM. Die allerersten JVMs (1.0, 1.1) verwendeten eine einfache Mark-and-Sweep-GC, die den Heap regelmäßig überprüfte, unbenutzte Objekte als Garbage markierte und ihren Speicher freigab. Dieses Verfahren führte häufig zu einer Fragmentierung des Heaps. Darüber hinaus wurde die Sammlung mit einem Stop-the-World-Szenario durchgeführt – mit anderen Worten, die gesamte Anwendung wurde angehalten, während der GC lief, was zu spürbaren Verzögerungen führte. Darüber hinaus skalierte der GC nicht mit zunehmendem Speicher.
Mit Java 1.2 (1998) wurde die generationale GC eingeführt. Die Weak Generational Hypothesis besagt, dass die meisten Objekte sehr kurzlebig sind und nur wenige Objekte sehr langlebig. Die JVM teilte den Heap entsprechend in eine junge und eine alte Generation ein und bereinigte die junge Generation (mit dem Großteil des kurzlebigen Mülls) sehr häufig und die alte Generation entsprechend seltener. Zusätzlich konnte die Fragmentierung durch das Kopieren von Kollektoransätzen reduziert werden. Dieses generationale GC-Design erwies sich als deutlich leistungsfähiger. Es sorgte zum Beispiel dafür, dass große Heaps besser verdichtet und Speicherlecks vermieden wurden. Trotz dieser Fortschritte blieben GC-Pausen in großen Java-Anwendungen der späten 1990er Jahre ein Problem – die Nebenläufigkeit in der GC selbst war noch rudimentär, was dazu führte, dass die Anwendung während der Speicherbereinigung erheblich stockte.
Weak Generational Hypothesis – Die meisten Objekte sterben jung
Die Herausforderung der Plattformunabhängigkeit
Alles in allem waren die späten 1990er Jahre eine Zeit, in der die grundlegenden Leistungstechniken für Java gerade ausgereift waren. Viele Herausforderungen ergaben sich direkt aus der Plattformunabhängigkeit: Jeder Vorteil musste zur Laufzeit und ohne tiefe Integration in das Betriebssystem erreicht werden. Die frühen Java-Versionen hatten mit einer trägen GUI-Leistung zu kämpfen, da AWT und Swing auf plattformneutralen Code setzten, anstatt native Widgets zu nutzen. File- und Network-I/O waren aufgrund von abstrakten Streams und Sicherheitsüberprüfungen deutlich langsamer als bei C-APIs. Der JNI-Overhead machte native Bibliotheken ineffizient, weshalb leistungsrelevanter Code oft in C/C++ geschrieben wurde.
Unterschiede im Thread Scheduling erschwerten eine optimale Abstimmung, da die frühen JVMs Java-Threads nicht 1:1 auf Betriebssystem-Threads abbildeten. Dennoch wurde in diesen Jahren der Grundstein für spätere Optimierungen gelegt: Mit JIT, Generational GC und nativen Threads wurde Java um das Jahr 2000 deutlich schneller und skalierbarer, ein Trend, der die Plattform bis heute prägt.
Gegenwart: Optimierte Leistung in modernen JVMs
Die heutigen Java-Versionen (Java 17 bis 23+) verfügen über hoch optimierte virtuelle Maschinen, die das Ergebnis jahrzehntelanger Forschung und Entwicklung sind.
HotSpot-Engine und adaptive Optimierung
Seit über 20 Jahren ist die HotSpot JVM die Grundlage der Java SE-Plattform. Wie ihr Name andeutet, erkennt sie Hotspots im Code und optimiert diese gezielt zur Laufzeit. Sie kombiniert einen Bytecode Interpreter mit zwei JIT-Compilern: C1, der mit begrenzten Optimierungen schnell kompiliert, und C2, der hoch optimierten Maschinencode für langlaufende Serveranwendungen erzeugt.
Bei der abgestuften Kompilierung werden beide Compiler stufenweise eingesetzt: Methoden werden zunächst interpretiert, C1 übernimmt nach einigen Aufrufen, und häufig verwendeter Code wird schließlich von C2 optimiert. Dieser Ansatz ermöglicht eine schnelle Aufwärmphase und maximale Leistung. Während der Ausführung optimiert HotSpot dynamisch mit Techniken wie Inlining-, Loop Collapse- und Peephole-Optimierungen und passt sich dabei an Laufzeitprofile an. Spekulative Optimierungen machen Annahmen über den Code, die automatisch zurückgenommen werden, wenn sie sich als ungültig erweisen.
Ein Meilenstein war die Einführung der Escape-Analyse in Java 6/7. Diese Technik identifiziert Objekte, die einem bestimmten Scope nicht entkommen, und optimiert deren Speicherverwaltung: Objekte können auf dem Stack statt auf dem Heap alloziert oder durch skalare Ersetzung komplett eliminiert werden, wodurch die GC-Last reduziert wird.
Dank dieser Optimierungen erreicht HotSpot Java in vielen Szenarien eine nahezu native Leistung. Bereits 2008 haben Benchmarks gezeigt, dass Java dank fortschrittlicher JIT-Techniken nur 10 bis 30 % hinter C++ zurückbleibt und aufgrund von Laufzeitoptimierungen, die statischen Compilern nicht zur Verfügung stehen, manchmal mit C++ gleichzieht oder es sogar übertrifft.
Moderne Garbage Collection: G1, ZGC, Shenandoah & Co.
Die Garbage Collector der JVM haben in den letzten Jahren bedeutende Fortschritte gemacht. Neben den klassischen Serial- und Parallel-GCs führte CMS erstmals nebenläufige GC-Mechanismen ein, hatte jedoch Schwächen wie Heap-Fragmentierung. Mit Java 7/8 wurde G1 GC als moderner Ersatz eingeführt und in Java 9 zum Standard, während CMS in Java 14 wieder entfernt wurde.
G1 GC arbeitet auf regionaler Basis und wählt die speicherintensivsten Regionen für die Bereinigung aus, um die konfigurierbaren Pausenzeiten (standardmäßig etwa 200 ms) in Grenzen zu halten. Durch gleichzeitige Markierung und selektive Räumung schafft G1 ein gutes Gleichgewicht zwischen Durchsatz, moderaten Pausen und minimalem Abstimmungsaufwand, wodurch es sich besonders für mittlere bis große Heaps eignet.
Für noch niedrigere Latenzen wurden Shenandoah und ZGC entwickelt. Shenandoah reduziert die Stop-the-World-Pausen, indem es eine vollständig gleichzeitige Heap-Komprimierung unter Verwendung von Brooks-Zeigern durchführt und die Pausen selbst für 200-GB-Heaps im Sub-10-ms-Bereich hält – wenn auch in den frühen Versionen ohne Generationsverhalten. ZGC verfolgt einen ähnlichen Ansatz mit farbigen Zeigern, die Zustandsinformationen direkt in Zeiger einbetten, um Objektbewegungen effizient zu verwalten. Es garantiert Pausen unter 10 Millisekunden, unabhängig von der Heap-Größe. Mit Java 21 wurde Generational ZGC eingeführt, um kurzlebige Objekte effizienter zu sammeln und den Durchsatz weiter zu steigern.
Heutzutage stellt die JVM eine vielseitige Auswahl an Garbage-Collector-Algorithmen bereit, die gezielt auf die individuellen Anforderungen einer Anwendung abgestimmt werden können. Während Oracle G1 als Standard verwendet und für die Zukunft auf generationale ZGC setzt, bevorzugt Red Hat häufig Shenandoah. Dank dieser Fortschritte kann Java selbst mit Terabyte-großen Heaps effizient arbeiten, ohne dass lange Stop-the-World-Pausen zum Problem werden.
Threading und Concurrency: Von Fork/Join zu Loom
Seit Java 5 (2004) und dem java.util.concurrent-Paket hat sich in Sachen Parallelisierung viel getan: Thread-Pools, Sperren, atomare Variablen und nebenläufige Collections ermöglichen den modernen Multicore-Einsatz. Mit Java 7 kam das fork/join-Framework (JSR 166y) zur rekursiven Parallelisierung hinzu, und ab Java 8 wurde dieser Ansatz durch parallele Streams und Lambdas weiter vereinfacht. Gleichzeitig wurden Verbesserungen wie Lock-Striping und effizientere Datenstrukturen (wie die ConcurrentHashMap mit blockweisem Locking) entwickelt. In Java 11 folgten varHandle und Spin-Wait-Hinweise über Thread.onSpinWait(), die eine High-Performance-Concurrency ermöglichen. Ein typisches Beispiel sieht wie folgt aus:
while (!lock.isAvailable()) {
Thread.onSpinWait(); // Gives the CPU a hint for optimisation
}
Darüber hinaus ist dank der Lock-Elimination über die Escape-Analyse keine Synchronisierung mehr erforderlich, wenn Objekte nur thread-local verwendet werden.
Dennoch bleibt eine Hürde: Jeder Java-Thread entspricht einem Betriebssystem-Thread, was zu hohem Speicherbedarf (meist 1 MB pro Stack/Thread) und teuren Kontextwechseln bei sehr großen Mengen (ca. über 100k) führt. Das Scheduling skaliert dann nicht mehr linear, und die Grenzen des Betriebssystems werden erreicht.
Dies war eine der treibenden Kräfte hinter Project Loom, das wohl die bedeutendste Umwälzung im Java-Concurrency-Modell seit Jahrzehnten darstellt. Mit Project Loom wurden virtuelle Threads (auch bekannt als Fibers) eingeführt, die seit Java 19 als Vorschau verfügbar waren und in Java 21 fertiggestellt wurden.
Virtuelle Threads sind ultraleichte, vom JDK verwaltete Threads, die nicht permanent an Kernel-Threads gebunden sind. Sie können als modernes Äquivalent zu Green Threads betrachtet werden – allerdings ohne deren Nachteile.
Die Idee ist, dass ein Java-Programm Hunderttausende von gleichzeitigen Threads erstellen kann, ohne das Betriebssystem zu überlasten. Virtuelle Threads werden von der JVM geplant und auf eine kleinere Anzahl von Kernel-Threads (sogenannte Carrier-Threads) abgebildet. Wenn ein virtueller Thread blockiert (z. B. während I/O oder Thread.sleep), wird nicht der gesamte Träger-Thread blockiert – die JVM kann den Träger entkoppeln und einem anderen virtuellen Thread zuweisen, während der blockierte Thread im Hintergrund wartet. Dadurch werden blockierende Aufrufe praktisch automatisch asynchron, ohne dass der Programmierer komplexe Callback- oder Future-Logik schreiben muss.
In der Praxis ermöglicht Loom eine hohe Skalierung des bekannten Thread-per-Request-Modells. Bisher musste man bei der Verarbeitung von Tausenden von gleichzeitigen Verbindungen (z. B. in einem Webserver) auf asynchrone I/O oder reaktive Programmierung (Netty, Vert.x usw.) zurückgreifen, um zu vermeiden, dass eine gleiche Anzahl von Betriebssystem-Threads blockiert wird. Mit virtuellen Threads kann nun jede Anfrage in einem eigenen (virtuellen) Thread bearbeitet werden – mit einfachem, synchronem Code – während die JVM dafür sorgt, dass die Hardware optimal ausgelastet wird. Tests zeigen, dass Millionen von schlafenden virtuellen Threads praktisch keine Probleme verursachen. Auch IO-intensive Anwendungen profitieren enorm: In einem Experiment mit 1 Million paralleler HTTP-Anfragen konnten die neuen virtuellen Threads die Last mit sehr wenig Overhead bewältigen, während 1 Million herkömmlicher Threads das System unbrauchbar gemacht hätten.
Ein Beispiel veranschaulicht, wie virtuelle Threads in Java 21 gehandhabt werden:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
try {
Thread.currentThread().sleep(1_000);
System.out.println("Thread #" + i + " done");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
})
);
}
In diesem Code wird ein ExecutorService erstellt, der für jede eingereichte Aufgabe einen neuen virtuellen Thread startet. Wir übergeben 100.000 Aufgaben, die jeweils eine Sekunde schlafen und anschließend eine Nachricht ausgeben. Mit traditionellen Threads wäre dies nicht praktikabel – allein das Starten von 100.000 Betriebssystem-Threads würde enorme Ressourcen erfordern. Mit virtuellen Threads hingegen ist dies problemlos möglich, da die JVM sie entsprechend der verfügbaren CPU-Kerne auf einige Dutzend echte Threads multiplexen kann.
Für Java-Entwickler bedeutet Loom eine erhebliche Reduzierung der Komplexität bei der parallelen Programmierung: Viele Probleme, die bisher mit asynchronen Callbacks oder reaktiven Streams gelöst wurden (um Threads zu sparen), lassen sich nun als einfache, sequenzielle Logik schreiben – und dennoch hochskalieren. Es ist jedoch wichtig zu beachten, dass virtuelle Threads keine Allzwecklösung sind: Sie beschleunigen keine CPU-intensiven Workloads – hier sind weiterhin mehr Kerne oder effizientere Parallelalgorithmen erforderlich. Für IO-lastige Anwendungen und Systeme mit hohen Nebenläufigkeitsanforderungen hingegen wird die Entwicklung deutlich einfacher und wartungsfreundlicher.
Java-Objekte und Speicherverbrauch
Ein wichtiger Aspekt der Java-Leistung ist der Speicherbedarf von Objekten und die Speicherorganisation (Heap vs. Stack).
Objekte und Heap: Java-Objekte haben einen Overhead (Klassenzugehörigkeit, Synchronisationsstatus usw.). Auf 64-Bit-JVMs beträgt der Header typischerweise 16 Byte für jedes Objekt. Es kann auch Auffüllungen geben, da Objekte aus Gründen der Ausrichtung normalerweise auf Vielfache von 8 Byte ausgerichtet sind. Mit Java 6 wurden komprimierte OOPs (Ordinary Object Pointers) eingeführt: Wenn der Heap kleiner als ~ 32 GB ist, kann die JVM 32-Bit-Zeiger anstelle von 64-Bit-Zeigern verwenden, was 4 Byte pro Objektfeld spart. Wird mehr Speicher benötigt, kann z.B. auf 16-Byte-Alignment (-XX:ObjectAlignmentInBytes) umgestellt werden, so dass auch Heaps bis zu 64 GB noch komprimierte Zeiger verwenden. In der Regel erfordert die Umstellung von 32-Bit- auf 64-Bit-Zeiger das 1,7-fache an Speicher.
String: Seit Java 9 verwendet die JVM intern kompakte Strings. Das heißt, wenn ein String nur aus reinen ASCII-Zeichen besteht, speichert sie die Zeichen als byte[] (8-Bit pro Zeichen) statt wie bisher als char[] (16-Bit pro Zeichen). Dies spart fast die Hälfte des Speicherplatzes für die vielen Strings, die typischerweise aus ASCII bestehen (z. B. JSON-Texte, Protokollnachrichten). In einzelnen Anwendungen wurde allein durch die Umstellung von Java 8 auf 9 ein Leistungsgewinn von ~40% beobachtet.
- Metaspace: Mit Java 8 wurde der “PermGen” durch Metaspaceersetzt, der im nativen Speicher (off-heap) verwaltet wird. Metaspace wächst dynamisch und beseitigt das frühere Problem eines festgelegten permanenten Heaps, das zuvor zu OutOfMemoryError führen konnte. Aus Leistungssicht brachte diese Änderung zwar keine direkte Beschleunigung, erhöhte jedoch die Stabilität und erleichterte das Tuning der JVM.
- Heap vs. Off-Heap Memory: Neben dem regulären Java-Heap (für Objekte) nutzt die JVM in verschiedenen Situationen auch nativen Speicher. Dazu gehören beispielsweise Metaspace (siehe oben), DirectByteBuffer-Allokationen für NIO, um Byte-Puffer außerhalb des Heaps zu halten und teure Kopieroperationen in den Kernel-Space zu vermeiden, sowie C-Heaps für JNI-Bibliotheken. Aus Performance-Sicht kann der gezielte Einsatz von Off-Heap-Speicher vorteilhaft sein, da er die Garbage Collection entlastet. Allerdings geht dies auf Kosten einer komplexeren Speicherverwaltung, da die automatische Speicherbereinigung der JVM hier nicht greift.
- Stack Allocation: Mithilfe der Escape-Analyse (siehe oben) versucht die JVM, Objekte gar nicht erst auf dem Heap abzulegen, wenn dies nicht erforderlich ist. Dadurch entsteht praktisch eine Art von Wertetypen für kurzlebige Objekte: Diese existieren ausschließlich auf dem Stack und werden automatisch mit dem Methoden-Frame verworfen – völlig ohne Einfluss auf die Garbage Collection.
Zusammenfassend lässt sich sagen, dass im Speicherlayout viel optimiert wurde, um den Footprint von Java-Anwendungen zu reduzieren. Dabei geht es nicht nur um die Einsparung von Speicher: Weniger belegter Speicher und weniger Fragmentierung bedeuten auch weniger Arbeit für die GC und eine effizientere Nutzung der CPU-Caches. Kompaktere Objekte können beispielsweise die Cache-Lokalität verbessern und dadurch insbesondere bei großen Datenstrukturen spürbare Leistungssteigerungen erzielen.
Entwickler sollten dennoch bewusste Entscheidungen treffen: Primitive Arrays vs. Objektlisten, sparsame Nutzung von Strings sowie ein Verständnis der Kosten von Autoboxing. Die JVM optimiert vieles automatisch, aber ein grundlegendes Wissen über Speicherverwaltung hilft, Performance-Engpässe zu vermeiden.
JVM-Implementierungen: HotSpot, OpenJ9, Azul Prime, GraalVM
HotSpot (OpenJDK/Oracle JDK) ist die am weitesten verbreitete JVM, aber es gibt leistungsfähige Alternativen mit spezifischen Vorteilen:
- Eclipse OpenJ9, IBMs Open-Source-JVM, ist auf schnellen Start und geringen Speicherverbrauch optimiert. Dank IBMs Testarossa JIT und einem eigenen Garbage Collector eignet sie sich besonders für Cloud-Umgebungen mit begrenztem RAM. Allerdings liegt OpenJ9 beim maximalen Durchsatz leicht hinter HotSpot zurück.
- Azul Platform Prime (ehemals Zing) wurde für Latenz-kritische Anwendungen entwickelt. Sein Pauseless C4-GC minimiert Stop-the-World-Pausen selbst bei großen Heaps, während der LLVM-basierte Falcon JIT oft effizienteren Code generiert als HotSpot C2. Azul setzt diese Technologien seit über einem Jahrzehnt in Produktionsumgebungen ein und ist eine bewährte Lösung für Systeme mit hohen Latenzanforderungen.
- GraalVM ist eine erweiterte JDK-Distribution von Oracle Labs mit Polyglot-Unterstützung für JavaScript, Python, R, Ruby und WebAssembly. Die Native Image-Funktion ermöglicht eine Ahead-of-Time-Kompilierung (AOT), wodurch Java-Anwendungen sofort starten und weniger RAM verbrauchen. Während dies ideal für serverlose und Function-as-a-Service (FaaS)-Workloads ist, liefert eine gut optimierte HotSpot-VM unter konstanter Last oft eine bessere Latenz.
Diese Alternativen zeigen, dass Java nicht länger ein monolithisches System ist, sondern ein Ökosystem verschiedener VM-Technologien. GraalVM Native Image hat in der Cloud-Ära an Zugkraft gewonnen und macht Java für schnell startende CLIs und FaaS attraktiver. Azul Prime bleibt für Hochfrequenz-Finanzsysteme relevant, während OpenJ9 eine starke Option für speicherempfindliche Arbeitslasten ist. Die Vielfalt der JVMs fördert die Innovation und treibt die Leistungsverbesserung kontinuierlich voran.
Meilensteine der Java-Versionen (Performance-relevant)
Abschließend in diesem Gegenwarts-Block ein kompakter Überblick einiger wichtiger Versionen und deren Performance-Neuerungen:
Version | Jahr | Wichtigste Leistungsmerkmale |
Java 5 (Tiger) | 2004 | java.util.concurrent (Thread-Pools, Futures), 64-Bit JVM, verbesserte JIT, CMS GC (GC mit niedriger Pause als Option). |
Java 6 (Mustang) | 2006 | Schnellere JIT-Kompilierung, Escape-Analyse (experimentell), komprimierte OOPs, parallele alte GC, Verbesserungen bei der Synchronisierung. |
Java 7 (Dolphin) | 2011 | Fork/Join-Framework, G1 GC (experimentell, offiziell ab 7u4), invokedynamic, NIO 2 (asynchrone Kanäle), Tiered Compilation (C1+C2). |
Java 8 (Spider) | 2014 | Lambdas & Streams (einfachere Parallelisierung), Metaspace anstelle von PermGen, Compact Strings, Tiered Compilation standardmäßig aktiviert. |
Java 9 (Jigsaw) | 2017 | G1 GC als Standard, Flight Recorder (OracleJDK kommerziell, ab OpenJDK 11 kostenlos), AOT-Kompilierung (experimentell), Spin-Wait-Hinweis, 8-Byte-aligned Heap. |
Java 11 (LTS) | 2018 | ZGC (experimentell), Epsilon GC (No-Op), Flight Recorder + Mission Control in OpenJDK, neuer HTTP-Client (bessere Leistung als HttpUrlConnection). |
Java 15 | 2020 | Shenandoah GC in OpenJDK integriert (JEP 379); ZGC jetzt produktionsreif (kein Experiment-Flag mehr, JEP 377); Textblöcke (Syntax, ohne Leistungseinfluss); versteckte Klassen (für Frameworks wie Bytecode-Generatoren, etwas effizienter). |
Java 17 (LTS) | 2021 | Stärkere Kapselung im JDK (Sicherheit vs. Leistung), neue Plattform-Intrinsics (z.B. CRC32, GHASH), Shenandoah im Oracle JDK, konsolidierte Updates von 11-16. |
Java 19 | 2022 | Virtuelle Threads (Vorschau) für hohe IO-Parallelität, strukturierte Concurrency (Inkubator), Foreign Function & Memory API (Vorschau) für schnelleren Zugriff auf Native/Off-Heap. |
Java 21 (LTS) | 2023 | Virtual Threads GA (JEP 444), Generational ZGC (Vorschau), insgesamt großer Schritt durch Loom und verbesserte GC. |
Java 22 | 2024 | Region Pinning für G1 (JEP 423): Stabilisiert/verbessert GC durch Fixierung bestimmter Heap-Regionen. Foreign Function & Memory API (JEP 454): Effizientere native Interop, geringerer Overhead. Vektor-API (JEP 460): Nutzt SIMD-Befehle für datenparallele Beschleunigungen. Stream Gatherer (JEP 461): Reduziert den I/O-Overhead durch gebündelte Datenerfassung. |
Java 23 | 2024 | Vector API (JEP 469, 8. Inkubator): Beschleunigt datenparallele Aufgaben über SIMD Stream Gatherer (JEP 473, Zweite Vorschau): Verbessert den Durchsatz durch Stapelverarbeitung mehrerer I/O-Operationen. ZGC: Standardmäßiger Generierungsmodus (JEP 474): Effizientere GC, bessere Handhabung von kurzlebigen Objekten. |
Java 24 | 2025 | Es beschleunigt mit generational Shenandoah und Compact Object Headers (beide experimentell), zusammen mit Verbesserungen an G1, die den GC-Overhead reduzieren. Es bietet außerdem schnelleres AOT-Laden, verbessertes Streaming und eine erweiterte Vektor-API für SIMD (9. Inkubator), neben anderen Verbesserungen. |
Ihr seht, dass die Leistung von Java in fast jeder Version verbessert wurde, entweder direkt (schnellere JVM) oder indirekt durch neue Sprach-/Bibliotheksfunktionen, die effizienteren Code ermöglichen. Insbesondere die LTS-Versionen (8, 11, 17, 21) enthielten oft die experimentellen Funktionen der Zwischenversionen in stabiler Form. Es lohnt sich immer, die neueste Java-Version zu verwenden, um von allen Optimierungen zu profitieren – vor allem die Leistungssteigerung wirkt sich direkt aus, ohne dass neuer Byte-Code kompiliert werden muss.
Die Zukunft: Wie geht es mit Java weiter?
Die Java-Plattform entwickelt sich weiter, wobei mehrere Schlüsselprojekte ihre zukünftige Leistung und Skalierbarkeit bestimmen:
- Project Loom & Virtual Threads: Loom ist jetzt Teil von Java 21 und es wird einige Zeit dauern, bis es vollständig übernommen wird, da Frameworks wie Tomcat, Jetty, Spring und Quarkus es integrieren. Virtuelle Threads könnten schließlich reaktive Programmiermodelle übertreffen, indem sie die Concurrency vereinfachen und eine effiziente Skalierung von Thread-per-Request-Architekturen ermöglichen. Structured Concurrency wird Lesbarkeit von nebenläufigem Code weiter verbessern. Während sie für I/O-gebundene Aufgaben ideal ist, profitieren CPU-lastige parallele Streams weniger, da sie durch das Betriebssystem begrenzt bleiben.
- CRaC (Coordinated Restore at Checkpoint): Das CRaC-Projekt von OpenJDK zielt darauf ab, die Startzeit drastisch zu reduzieren, indem ein Snapshot eines „aufgewärmten“ JVM-Zustands erstellt wird, der innerhalb von Millisekunden wiederhergestellt werden kann. Dies ist von großer Bedeutung für Cloud-Umgebungen, in denen eine schnelle Skalierung erforderlich ist. Anwendungen müssen sich anpassen und die Ressourcen vor einem Snapshot bereinigen. Derzeit ist CRaC nur für Linux verfügbar und befindet sich noch in der Entwicklung, aber es stellt eine Alternative zu GraalVM Native Image dar, indem es Kaltstarts beschleunigt, ohne JIT-Optimierungen zu verlieren.
- Native Kompilierung & Project Leyden: Inspiriert von GraalVM Native Image versucht Project Leyden, die AOT-Kompilierung (AOT = ahead of time) für Java zu standardisieren. Ziel ist es, langsame Startzeiten, insbesondere für Cloud-basierte Anwendungen, zu verringern und gleichzeitig aktuelle Einschränkungen wie eingeschränkte Reflektion und dynamisches Laden zu beheben. Künftige Java-Versionen könnten getrennte JIT- und AOT-Modi einführen, so dass Java-Binärdateien in Umgebungen, in denen ein schneller Start wichtig ist, häufiger verwendet werden.
- Project Valhalla (Value Types): Zukünftige Java-Versionen werden wahrscheinlich Wertetypen einführen, die es ermöglichen, Objekte flach in Arrays oder Containern zu speichern, was die Cache-Effizienz verbessert und den Zeiger-Overhead reduziert. Obwohl sich Valhalla noch in der Entwicklung befindet, wird es den Speicherbedarf und die Leistung von Java weiter optimieren.
Die Zukunft von Java konzentriert sich auf schnellere Starts, bessere Concurrency und geringeren Speicher-Overhead. Virtuelle Threads werden die Art und Weise, wie Java mit Skalierbarkeit umgeht, neu gestalten, CRaC befasst sich mit Startverzögerungen, und Valhalla modernisiert die Objektbehandlung. Die letzten beiden sind noch Zukunftsmusik, aber diese Innovationen würden Java flexibler machen und eine Feinabstimmung der Anwendungen auf die Einsatzanforderungen ermöglichen – sei es durch native Binärdateien oder neue Concurrency-modelle.
GC-Selection: Latency vs. Throughput
Praktische Performance-Tipps für Hitchhiker’s
Um die Leistung von Java-Anwendungen effektiv zu optimieren, solltet ihr die folgenden Schlüsselstrategien berücksichtigen:
- Auswahl des richtigen Garbage Collectors: Die Wahl des GC wirkt sich auf Latenz und Durchsatz aus. G1 ist seit Java 11 der Standard für die meisten Serveranwendungen. Für extrem niedrige Pausenzeiten (Echtzeit, UI) ist ZGC (stabil seit Java 17) oder Shenandoah (bevorzugt in Red Hat/SAP JDKs) ideal. Batch-Workloads profitieren von Parallel GC, während kleine Tools mit kurzen Laufzeiten am besten mit Serial GC arbeiten, um Parallelisierungs-Overhead zu vermeiden.
- Profiling mit den richtigen Tools: Identifiziert Leistungsengpässe, bevor Ihr optimiert. Java Flight Recorder (JFR) und Async Profiler bieten tiefe Einblicke. Eine frühzeitige Profilerstellung hilft bei der Erstellung von Baselines und zeigt die größten CPU-Verbraucher und Zuweisungs-Hotspots auf. Für tiefergehende Analysen (z. B. Lock Contention, native Engpässe) solltet ihr spezialisierte Profiler oder Tools verwenden. Ihr beginnt mit einem breiten Spektrum (hochauflösendes CPU-Profiling) und verfeinern den Fokus dann schrittweise. Vermeidet Vermutungen – Engpässe liegen oft dort, wo man sie am wenigsten erwartet (z. B. I/O statt CPU, übermäßige GC statt langsamer Schleifen). Am schnellsten lassen sich Engpässe mit JProfiler [10] finden – seit Jahrzehnten mein treuer Reisebegleiter.
- Effiziente Datenstrukturen und Speicherverwaltung: Wählt die richtigen Strukturen, um den Overhead zu reduzieren. Verwendet Primitive anstelle von Wrapper-Typen (z. B. int statt Integer), um unnötiges Boxing zu vermeiden. Zieht für große Collections spezialisierte Bibliotheken wie Trove oder FastUtil für primitiv gestützte Listen in Betracht. Vermeidet übermäßige String-Verkettung in Schleifen – verwendet stattdessen StringBuilder. Minimiert unnötige Objektzuweisungen, aber vermeidet veraltete Objektpools, da moderne JVMs kurzlebige Objekte effizient handhaben.
- Concurrency und Parallelität: Verwaltet Threads effizient. Bei CPU-gebundenen Aufgaben entspricht die optimale Thread-Anzahl in etwa der Anzahl der CPU-Kerne. Bei I/O-gebundenen Arbeitslasten ermöglichen virtuelle Threads (Java 21) eine Skalierung auf Tausende von Aufgaben ohne übermäßigen Overhead. Minimiert die Synchronisierung (Sperren) und bevorzugt sperrenfreie Alternativen wie Atomics, StampedLock oder LMAX Disruptor. Verwendet bei der Arbeit mit Sammlungen im Multithreading konkurrierende Datenstrukturen oder unveränderliche Snapshots anstelle von globalen Sperren. Messt immer die Skalierbarkeit – das Hinzufügen von Threads führt meist nicht zu einer linearen Leistungssteigerung. Verwendet JMH-Benchmarks [11], um den optimalen Grad der Parallelität zu ermitteln.
- Bleibt mit Euren JDK-Versionen auf dem neuesten Stand: Neue JDK-Versionen bieten erhebliche Leistungsverbesserungen. Ein Upgrade von Java 8 auf Java 11 kann die GC-Zeit aufgrund von G1-Optimierungen um 20 % reduzieren, während Java 17 die String-Verarbeitung und die GC-Effizienz weiter verbessert. Benchmarks zeigen, dass Java 17 den Durchsatz im Vergleich zu Java 11 um ~15 % steigert, und das, bei geringerer Latenz, selbst ohne Codeänderungen. Regelmäßige Updates sorgen für kostenlose Leistungssteigerungen ohne zusätzlichen Aufwand.
Keep It Simple – Vermeidet verfrühte Optimierungen – konzentriert euch zuerst auf Architektur und Algorithmen. Die JVM ist hochgradig optimiert, aber wenn eine Feinabstimmung erforderlich ist, solltet ihr Profiling-Tools und moderne Funktionen nutzen. Mit dem richtigen Ansatz kann Java eine außergewöhnliche Leistung erzielen.
Fazit
Java hat in den letzten 30 Jahren einen langen Weg zurückgelegt. Dank hoch entwickelter JIT-Compiler, moderner Garbage-Collectors und innovativer Concurrency-Konzepte ist die Plattform, die einst den Ruf hatte, langsam zu sein, heute eine der leistungsfähigsten Umgebungen für skalierbare Serveranwendungen. Die kontinuierlichen Verbesserungen – ob in der HotSpot-Engine, in alternativen JVMs wie GraalVM oder in zukunftsweisenden Projekten wie Loom und CRaC – zeigen, dass Performance in Java ein lebendiges und dynamisches Thema bleibt.
Entwickler sollten die vielen Tools zur Analyse und Optimierung (JFR, JMC, Async Profiler, JMH) konsequent nutzen und sich sowohl an bewährten als auch an neuen Technologien orientieren. So könnt ihr die bestmögliche Performance aus euren Anwendungen herausholen – egal, ob es sich um rechenintensive Prozesse, latenzkritische Systeme oder moderne Cloud-Umgebungen handelt.
Die Zukunft der Java-Plattform verspricht, durch native Kompilierung, optimierte Concurrency und energieeffiziente Ansätze noch leistungsfähiger zu werden. Wer heute auf die richtigen Optimierungstechniken setzt, profitiert nicht nur von höherer Geschwindigkeit, sondern auch von besserer Skalierbarkeit und Wartbarkeit der Software. Java wird also auch in Zukunft eine zentrale Technologie bleiben – bereit, die Herausforderungen moderner Anwendungen zu meistern.
Literatur
[01] https://www.academia.edu/50669704/Escape_analysis_for_Java[02] https://www.azul.com/blog/why-we-called-our-new-jit-compiler-falcon/
[03] https://shipilev.net/talks/javazone-Sep2018-shenandoah.pdf
[04] https://openjdk.org/projects/leyden/
[05] https://www.infoq.com/articles/java-virtual-threads-a-case-study/
[06] https://webtide.com/jetty-12-virtual-threads-support/
[07] https://www.infoq.com/news/2024/11/tomcat-11/
[08] https://citeseerx.ist.psu.edu/document?doi=93b48eef8acfd110755b44ff1a346b65422a2bf4#
[09] https://youtu.be/HCcq6VLuXe0
[10] https://www.ej-technologies.com/jprofiler
[11] https://github.com/openjdk/jmh
Neugierig, wie sich Java entwickelt hat?
Ingo Düppe ist Speaker auf der JCON.
In diesem Artikel geht es um die Performance-Reise von Java – und in seiner JCON-Session wirft er einen Blick auf 30 Jahre UI-Entwicklung in Java.
Falls Du die Session verpasst hast, kein Problem! Das Video zum Talk wird nach der Konferenz verfügbar sein.