Kapitel 1. Einführung

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Dies ist ein Buch über die Kunst und Wissenschaft der Java-Performance.

Der wissenschaftliche Teil dieser Aussage ist nicht überraschend, denn bei Diskussionen über Leistung geht es um viele Zahlen, Messungen und Analysen. Die meisten Leistungsingenieure haben einen naturwissenschaftlichen Hintergrund, und die Anwendung wissenschaftlicher Methoden ist ein entscheidender Faktor, um maximale Leistung zu erzielen.

Was ist mit dem künstlerischen Teil? Der Gedanke, dass Leistungsoptimierung teils Kunst und teils Wissenschaft ist, ist nicht neu, aber er wird in Leistungsdiskussionen selten ausdrücklich anerkannt. Das liegt zum Teil daran, dass die Vorstellung von "Kunst" unserer Ausbildung widerspricht. Aber was für manche Menschen wie Kunst aussieht, basiert im Grunde auf tiefem Wissen und Erfahrung. Es heißt, dass Magie nicht von hinreichend fortschrittlichen Technologien zu unterscheiden ist, und es ist sicher wahr, dass ein Handy für einen Ritter der Tafelrunde magisch aussehen würde. Genauso kann die Arbeit eines guten Ingenieurs wie Kunst aussehen, aber diese Kunst ist in Wirklichkeit eine Anwendung von tiefem Wissen, Erfahrung und Intuition.

Dieses Buch kann dir nicht bei der Erfahrung und Intuition helfen, aber es kann dir fundiertes Wissen vermitteln - mit der Aussicht, dass die Anwendung des Wissens dir im Laufe der Zeit helfen wird, die Fähigkeiten zu entwickeln, die du brauchst, um ein guter Java Performance Engineer zu sein. Ziel ist es, dir ein tiefgreifendes Verständnis der Leistungsaspekte der Java-Plattform zu vermitteln.

Dieses Wissen lässt sich in zwei große Kategorien einteilen.Erstens die Leistung der Java Virtual Machine (JVM) selbst: Die Art und Weise, wie die JVM konfiguriert ist, wirkt sich auf viele Aspekte der Leistung eines Programms aus. Entwickler/innen, die Erfahrung mit anderen Sprachen haben, werden das Tuning vielleicht als lästig empfinden, aber in Wirklichkeit ist das Tuning der JVM für C++-Programmierer/innen ganz ähnlich wie das Testen und Auswählen von Compiler-Flags während der Kompilierung oder das Setzen geeigneter Variablen in einer php.ini-Datei für PHP-Programmierer/innen usw.

Der zweite Aspekt ist, zu verstehen, wie die Funktionen der Java-Plattform die Leistung beeinflussen. Beachte hier die Verwendung des Wortes Plattform: Einige Funktionen (z. B. Threading und Synchronisierung) sind Teil der Sprache, andere (z. B. die String-Verarbeitung) sind Teil der Standard-Java-API. Obwohl es wichtige Unterschiede zwischen der Java-Sprache und der Java-API gibt, werden sie in diesem Fall ähnlich behandelt. Dieses Buch behandelt beide Facetten der Plattform.

Die Leistung der JVM basiert größtenteils auf Tuning-Flags, während die Leistung der Plattform eher durch bewährte Methoden innerhalb deines Anwendungscodes bestimmt wird. Lange Zeit galten diese beiden Bereiche als getrennte Fachgebiete: Die Entwickler programmieren, und die Performance-Gruppe testet und empfiehlt Korrekturen für Leistungsprobleme. Diese Unterscheidung war nie besonders sinnvoll - jeder, der mit Java arbeitet, sollte ebenso gut verstehen, wie sich der Code in der JVM verhält und welche Arten von Tuning die Leistung verbessern. Mit der Umstellung von Projekten auf ein Devops-Modell wird diese Unterscheidung immer weniger strikt. Das Wissen über den gesamten Bereich ist es, was deiner Arbeit die Patina der Kunst verleiht.

Ein kurzer Abriss

Aber eins nach dem anderen: In Kapitel 2 werden allgemeine Methoden zum Testen von Java-Anwendungen besprochen, einschließlich der Fallstricke des Java-Benchmarking. Da die Leistungsanalyse einen Einblick in das Verhalten der Anwendung erfordert, gibt Kapitel 3 einen Überblick über einige der verfügbaren Tools zur Überwachung von Java-Anwendungen.

Danach wird es Zeit, sich mit der Leistung zu befassen, wobei der Schwerpunkt zunächst auf allgemeinen Tuning-Aspekten liegt: Just-in-Time-Kompilierung(Kapitel 4) und Speicherbereinigung(Kapitel 5 und Kapitel 6). Die übrigen Kapitel befassen sich mit bewährten Methoden für verschiedene Teile der Java-Plattform: Speichernutzung mit dem Java-Heap(Kapitel 7), native Speichernutzung(Kapitel 8), Thread-Leistung(Kapitel 9), Java-Server-Technologie(Kapitel 10), Datenbankzugriff(Kapitel 11) und allgemeine Tipps zur Java SE API(Kapitel 12).

In Anhang Asind alle Tuning-Flags aufgelistet, die in diesem Buch besprochen werden, mit Querverweisen auf das Kapitel, in dem sie behandelt werden.

Plattformen und Konventionen

In diesem Buch geht es zwar um die Leistung von Java, aber diese Leistung wird von einigen Faktoren beeinflusst: natürlich von der Java-Version selbst, aber auch von der Hardware- und Softwareplattform, auf der sie läuft.

Java-Plattformen

Dieses Buch behandelt die Leistung der Oracle HotSpot Java Virtual Machine (JVM) und des Java Development Kit (JDK), Version 8 und 11. Dies ist auch als Java, Standard Edition (SE) bekannt. Die Java-Laufzeitumgebung (Java Runtime Environment, JRE) ist eine Untermenge des JDK, die nur die JVM enthält. Da aber die Werkzeuge im JDK für die Leistungsanalyse wichtig sind, liegt der Schwerpunkt dieses Buches auf dem JDK. In der Praxis bedeutet das, dass es auch Plattformen abdeckt, die aus dem OpenJDK-Repository dieser Technologie stammen, wozu auch die vom AdoptOpenJDK-Projekt freigegebenen JVMs gehören. Streng genommen benötigen die Oracle-Binärdateien eine Lizenz für den produktiven Einsatz, während die AdoptOpenJdK-Binärdateien mit einer Open-Source-Lizenz ausgestattet sind. Für unsere Zwecke betrachten wir die beiden Versionen als ein und dieselbe Sache, die wir als JDK oder Java-Plattform bezeichnen.1

Diese Versionen haben verschiedene Fehlerbehebungen durchlaufen. Während ich dies schreibe, ist die aktuelle Version von Java 8 jdk8u222 (Version 222) und die aktuelle Version von Java 11 ist 11.0.5. Es ist wichtig, mindestens diese Versionen zu verwenden (wenn nicht sogar noch später), vor allem im Fall von Java 8. Frühe Versionen von Java 8 (bis etwa jdk8u60) enthalten viele der wichtigen Leistungsverbesserungen und Funktionen, die in diesem Buch besprochen werden, nicht (insbesondere in Bezug auf die Speicherbereinigung und den G1 Garbage Collector).

Diese Versionen des JDK wurden ausgewählt, weil sie langfristigen Support (LTS) von Oracle erhalten. Der Java-Gemeinschaft steht es frei, ihre eigenen Support-Modelle zu entwickeln, aber bisher haben sie sich an das Oracle-Modell gehalten. Diese Versionen werden also noch eine ganze Weile unterstützt und verfügbar sein: bis mindestens 2023 für Java 8 (über AdoptOpenJDK; später über erweiterte Oracle-Supportverträge) und bis mindestens 2022 für Java 11. Das nächste langfristige Release wird voraussichtlich Ende 2021 erscheinen.

Was die Zwischenversionen angeht, so schließt die Diskussion über Java 11 natürlich auch Funktionen ein, die zuerst in Java 9 oder Java 10 verfügbar waren, obwohl diese Versionen weder von Oracle noch von der Community insgesamt unterstützt werden. Es könnte der Eindruck entstehen, dass ich sage, dass die Funktionen X und Y ursprünglich in Java 11 enthalten waren, obwohl sie vielleicht schon in Java 9 oder 10 verfügbar waren. Java 11 ist die erste LTS-Version, die diese Funktionen enthält, und das ist das Wichtigste: Da Java 9 und 10 nicht verwendet werden, spielt es keine Rolle, wann die Funktion zum ersten Mal erschien. Auch wenn Java 13 zum Zeitpunkt der Veröffentlichung dieses Buches bereits auf dem Markt ist, werden Java 12 und Java 13 kaum behandelt. Du kannst diese Versionen in der Produktion verwenden, aber nur für sechs Monate, danach musst du auf eine neue Version umsteigen (wenn du dieses Buch liest, wird Java 12 also nicht mehr unterstützt, und wenn Java 13 unterstützt wird, wird es bald durch Java 14 ersetzt). Wir werden einen Blick auf einige Funktionen dieser Zwischenversionen werfen, aber da diese Versionen in den meisten Umgebungen wahrscheinlich nicht in der Produktion eingesetzt werden, liegt der Schwerpunkt auf Java 8 und 11.

Es gibt auch andere Implementierungen der Java-Spezifikation, einschließlich Forks der Open-Source-Implementierung. AdoptOpenJDK bietet eine davon an (Eclipse OpenJ9), und andere sind von anderen Anbietern erhältlich. Obwohl alle diese Plattformen einen Kompatibilitätstest bestehen müssen, um den Namen Java verwenden zu können, erstreckt sich diese Kompatibilität nicht immer auf die in diesem Buch behandelten Themen. Das gilt insbesondere für die Tuning Flags. Alle JVM-Implementierungen haben einen oder mehrere Garbage-Collectors, aber die Flags, mit denen die GC-Implementierung des jeweiligen Anbieters eingestellt werden kann, sind produktspezifisch. Während die Konzepte dieses Buches also für jede Java-Implementierung gelten, gelten die spezifischen Flags und Empfehlungen nur für die HotSpot JVM.

Dieser Vorbehalt gilt für frühere Versionen der HotSpot JVM-Flags und ihre Standardwerte ändern sich von Version zu Version. Die hier besprochenen Flags gelten für Java 8 (genauer gesagt, Version 222) und 11 (genauer gesagt, 11.0.5). In späteren Versionen können sich einige dieser Informationen leicht ändern. Informiere dich immer in den Versionshinweisen über wichtige Änderungen.

Auf API-Ebene sind die verschiedenen JVM-Implementierungen viel kompatibler, aber selbst dann können subtile Unterschiede zwischen der Art und Weise bestehen, wie eine bestimmte Klasse in der Oracle HotSpot Java-Plattform und einer anderen Plattform implementiert ist. Die Klassen müssen funktional gleichwertig sein, aber die tatsächliche Implementierung kann sich ändern. Glücklicherweise kommt das nur selten vor und wird die Leistung wahrscheinlich nicht drastisch beeinträchtigen.

Im weiteren Verlauf dieses Buches beziehen sich die Begriffe Java und JVM speziell auf die HotSpot-Implementierung von Oracle. Streng genommen ist die Aussage "Die JVM kompiliert keinen Code bei der ersten Ausführung" falsch; einige Java-Implementierungen kompilieren den Code bei der ersten Ausführung. Aber diese Kurzform ist viel einfacher, als weiterhin zu schreiben (und zu lesen): "Die Oracle HotSpot JVM...".

JVM Tuning-Flags

Bis auf wenige Ausnahmen akzeptiert die JVM zwei Arten von Flags: boolesche Flags und Flags, die einen Parameter erfordern.

Boolesche Flaggen verwenden diese Syntax:-XX:+FlagName aktiviert das Flag, und-XX:-FlagName deaktiviert das Flag.

Flaggen, die einen Parameter benötigen, verwenden diese Syntax:-XX:FlagName =something, was bedeutet, dass der Wert vonFlagName aufsomethingIm Text wird der Wert des Flags normalerweise mit einem beliebigen Wert wiedergegeben. Zum Beispiel,-XX:NewRatio=N bedeutet, dass dasNewRatio Flagge auf einen beliebigen Wert gesetzt werden kann N gesetzt werden kann (wobei die Auswirkungen von N sind der Schwerpunkt der Diskussion).

Der Standardwert jedes Flags wird bei der Einführung des Flags besprochen. Dieser Standardwert basiert oft auf einer Kombination von Faktoren: der Plattform, auf der die JVM läuft, und anderen Kommandozeilenargumenten für die JVM. Im Zweifelsfall zeigt "Grundlegende VM-Informationen", wie man mit dem-XX:+PrintFlagsFinal Flag (standardmäßig false) verwendet wird, um den Standardwert für ein bestimmtes Flag in einer bestimmten Umgebung und einer bestimmten Befehlszeile zu ermitteln. Der Prozess der automatischen Anpassung von Flags an die Umgebung wird als Ergonomie bezeichnet.

Die JVM, die von den Oracle- und AdoptOpenJDK-Seiten heruntergeladen wird, wird alsProdukt-Build der JVM bezeichnet. Wenn die JVM aus dem Quellcode erstellt wird, können viele Builds erzeugt werden: Debug-Builds, Entwickler-Builds usw. Diese Builds haben oft zusätzliche Funktionen. Insbesondere die Entwickler-Builds enthalten eine noch größere Anzahl von Tuning-Flags, damit die Entwickler mit den kleinsten Operationen verschiedener Algorithmen experimentieren können, die von der JVM verwendet werden. Diese Flags werden in diesem Buch nicht behandelt.

Hardware-Plattformen

Als die erste Ausgabe dieses Buches veröffentlicht wurde, sah die Hardware-Landschaft anders aus als heute. Multicore-Maschinen waren beliebt, aber 32-Bit-Plattformen und Single-CPU-Plattformen waren immer noch sehr verbreitet. Andere Plattformen, die heute verwendet werden - virtuelle Maschinen und Softwarecontainer -, waren im Kommen. Hier ist ein Überblick darüber, wie diese Plattformen die Themen dieses Buches beeinflussen.

Multicore-Hardware

Heutzutage haben fast alle Maschinen mehrere Ausführungskerne, die für die JVM (und jedes andere Programm) wie mehrere CPUs erscheinen. In der Regel ist jeder Kern für Hyper-Threading aktiviert. Hyper-Threading ist der Begriff, den Intel bevorzugt, obwohl AMD (und andere) den Begriff simultanes Multithreading verwenden und einige Chip-Hersteller von Hardware-Strängen innerhalb eines Kerns sprechen. Das ist alles das Gleiche und wir bezeichnen diese Technologie als Hyper-Threading.

Aus Sicht der Leistung ist die Anzahl der Kerne das Wichtigste an einer Maschine. Nehmen wir eine einfache Maschine mit vier Kernen: Jeder Kern kann (größtenteils) unabhängig von den anderen arbeiten, so dass eine Maschine mit vier Kernen den vierfachen Durchsatz einer Maschine mit einem einzigen Kern erreichen kann. (Das hängt natürlich von anderen Faktoren der Software ab.)

In den meisten Fällen enthält jeder Kern zwei Hardware- oder Hyper-Threads. Diese Threads sind nicht unabhängig voneinander: Der Kern kann immer nur einen von ihnen ausführen. Oft kommt der Thread ins Stocken: Er muss zum Beispiel einen Wert aus dem Hauptspeicher laden, und das kann einige Zyklen dauern. Bei einem Kern mit einem einzigen Thread bleibt der Thread an diesem Punkt stehen und diese CPU-Zyklen sind verschwendet. Bei einem Kern mit zwei Threads kann der Kern umschalten und Anweisungen des anderen Threads ausführen.

Unser Vier-Kern-Rechner mit aktiviertem Hyper-Threading sieht also so aus, als ob er Befehle von acht Threads gleichzeitig ausführen kann (obwohl er technisch gesehen nur vier Befehle pro CPU-Zyklus ausführen kann). Für das Betriebssystem - und damit auch für Java und andere Anwendungen - sieht es so aus, als hätte der Rechner acht CPUs. Aber aus Sicht der Leistung sind nicht alle diese CPUs gleich. Wenn wir eine CPU-gebundene Aufgabe ausführen, verwendet sie einen Kern; eine zweite CPU-gebundene Aufgabe verwendet einen zweiten Kern und so weiter bis zu vier: Wir können vier unabhängige CPU-gebundene Aufgaben ausführen und erhalten unsere vierfache Durchsatzsteigerung.

Wenn wir eine fünfte Aufgabe hinzufügen, kann diese nur ausgeführt werden, wenn eine der anderen Aufgaben stehen bleibt, was im Durchschnitt zwischen 20 und 40 % der Zeit der Fall ist. Jede zusätzliche Aufgabe steht vor der gleichen Herausforderung. Wenn wir also eine fünfte Aufgabe hinzufügen, erhöht sich die Leistung nur um etwa 30 %. Am Ende werden wir mit den acht CPUs etwa die fünf- bis sechsfache Leistung eines einzelnen Kerns (ohne Hyper-Threading) erzielen.

Du wirst dieses Beispiel in ein paar Abschnitten sehen. Die Speicherbereinigung ist eine sehr CPU-gebundene Aufgabe, daher wird in Kapitel 5 gezeigt, wie Hyper-Threading die Parallelisierung von Speicherbereinigungsalgorithmen beeinflusst. In Kapitel 9 geht es darum, wie man die Threading-Funktionen von Java am besten nutzt; dort findest du auch ein Beispiel für die Skalierung von Hyperthreading-Kernen.

Software Container

Die größte Veränderung bei der Java-Bereitstellung in den letzten Jahren ist, dass sie jetzt häufig in einem Software-Container eingesetzt wird. Diese Veränderung ist natürlich nicht auf Java beschränkt, sondern ein Branchentrend, der durch den Wechsel zum Cloud Computing beschleunigt wurde.

Zwei Container sind hier wichtig. Der erste ist die virtuelle Maschine, die eine vollständig isolierte Kopie des Betriebssystems auf einer Teilmenge der Hardware einrichtet, auf der die virtuelle Maschine läuft. Dies ist die Grundlage des Cloud Computing: Dein Cloud Computing-Anbieter hat ein Rechenzentrum mit sehr großen Maschinen. Diese Maschinen können bis zu 128 Kerne haben, obwohl sie aus Kostengründen wahrscheinlich kleiner sind. Aus der Perspektive der virtuellen Maschine spielt das keine Rolle: Die virtuelle Maschine erhält Zugang zu einer Teilmenge dieser Hardware. So kann eine virtuelle Maschine zwei Kerne (und vier CPUs, da sie in der Regel hyper-threaded sind) und 16 GB Speicher haben .

Aus der Sicht von Java (und aus der Sicht anderer Anwendungen) ist diese virtuelle Maschine nicht von einer normalen Maschine mit zwei Kernen und 16 GB Speicher zu unterscheiden. Für Tuning- und Leistungszwecke brauchst du sie nur auf dieselbe Weise zu betrachten.

Der zweite erwähnenswerte Container ist der Docker-Container. Ein Java-Prozess, der in einem Docker-Container läuft, weiß nicht unbedingt, dass er sich in einem solchen Container befindet (obwohl er es durch Inspektion herausfinden könnte), aber der Docker-Container ist nur ein Prozess (möglicherweise mit Ressourcenbeschränkungen) innerhalb eines laufenden Betriebssystems. Als solcher ist er von der CPU- und Speichernutzung anderer Prozesse etwas anders isoliert. Wie du sehen wirst, unterscheidet sich die Art und Weise, wie Java dies handhabt, zwischen frühen Versionen von Java 8 (bis zum Update 192) und späteren Versionen von Java 8 (und allen Versionen von Java 11).

Standardmäßig kann ein Docker-Container alle Ressourcen des Rechners nutzen: Er kann alle verfügbaren CPUs und den gesamten Arbeitsspeicher des Rechners verwenden. Das ist in Ordnung, wenn wir Docker nur verwenden wollen, um die Bereitstellung unserer einzelnen Anwendung auf dem Rechner zu rationalisieren (und der Rechner daher nur diesen Docker-Container ausführt). Häufig möchten wir jedoch mehrere Docker-Container auf einem Rechner bereitstellen und die Ressourcen jedes Containers beschränken. Wenn wir einen Rechner mit vier Kernen und 16 GB Arbeitsspeicher haben, möchten wir vielleicht zwei Docker-Container einsetzen, von denen jeder nur Zugriff auf zwei Kerne und 8 GB Speicher hat.

Docker dafür zu konfigurieren ist einfach genug, aber auf der Java-Ebene kann es zu Komplikationen kommen. Zahlreiche Java-Ressourcen werden automatisch (oder ergonomisch) auf der Grundlage der Größe des Rechners konfiguriert, auf dem die JVM läuft. Dazu gehören die standardmäßige Heap-Größe und die Anzahl der vom Garbage Collector verwendeten Threads, die in Kapitel 5 ausführlich erklärt werden, sowie einige Thread-Pool-Einstellungen, die in Kapitel 9 erwähnt werden.

Wenn du eine aktuelle Version von Java 8 (Update Version 192 oder höher) oder Java 11 verwendest, handhabt die JVM dies so, wie du es dir erhoffst: Wenn du den Docker-Container auf die Verwendung von nur zwei Kernen beschränkst, basieren die ergonomisch eingestellten Werte auf der CPU-Anzahl der Maschine auf dem Limit des Docker-Containers.2 Ähnlich verhält es sich mit Heap- und anderen Einstellungen, die standardmäßig auf dem Arbeitsspeicher des Rechners basieren, und die sich auf die Speicherbegrenzung des Docker-Containers beziehen.

In früheren Versionen von Java 8 hat die JVM keine Kenntnis von den Grenzen, die der Container erzwingt: Wenn sie die Umgebung untersucht, um herauszufinden, wie viel Speicher verfügbar ist, damit sie ihre Standard-Heap-Größe berechnen kann, sieht sie den gesamten Speicher des Rechners (anstatt, wie wir es vorziehen würden, die Menge an Speicher, die der Docker-Container verwenden darf). Wenn die JVM prüft, wie viele CPUs für die Einstellung des Garbage Collectors zur Verfügung stehen, sieht sie alle CPUs des Rechners und nicht nur die Anzahl der CPUs, die dem Docker-Container zugewiesen sind. Infolgedessen wird die JVM suboptimal laufen: Sie wird zu viele Threads starten und einen zu großen Heap aufbauen. Zu viele Threads führen zu Leistungseinbußen, aber das eigentliche Problem ist der Speicher: Die maximale Größe des Heaps ist möglicherweise größer als der dem Docker-Container zugewiesene Speicher. Wenn der Heap so groß wird, wird der Docker-Container (und damit die JVM) beendet.

In frühen Java 8-Versionen kannst du die entsprechenden Werte für die Speicher- und CPU-Auslastung von Hand einstellen. Wenn wir auf diese Einstellungen stoßen, werde ich darauf hinweisen, welche für diese Situation angepasst werden müssen, aber es ist besser, einfach auf eine spätere Java 8-Version (oder Java 11) zu aktualisieren.

Docker-Container stellen eine zusätzliche Herausforderung für Java dar: Java verfügt über eine Reihe von Werkzeugen zur Diagnose von Leistungsproblemen. Diese sind in einem Docker-Container oft nicht verfügbar. Wir werden dieses Problem in Kapitel 3 genauer betrachten.

Die komplette Leistungsgeschichte

In diesem Buch geht es darum, wie man die JVM und die APIs der Java-Plattform am besten nutzt, damit Programme schneller laufen, aber viele äußere Einflüsse wirken sich auf die Leistung aus. Diese Einflüsse tauchen von Zeit zu Zeit in der Diskussion auf, aber da sie nicht spezifisch für Java sind, werden sie nicht unbedingt im Detail behandelt. Die Leistung der JVM und der Java-Plattform ist nur ein kleiner Teil des Weges zu schneller Leistung.

In diesem Abschnitt werden die äußeren Einflüsse vorgestellt, die mindestens genauso wichtig sind wie die in diesem Buch behandelten Java-Tuning-Themen. Der wissensbasierte Java-Ansatz dieses Buches ergänzt diese Einflüsse, aber viele von ihnen liegen außerhalb des Rahmens dessen, was wir besprechen werden.

Bessere Algorithmen schreiben

Viele Details von Java wirken sich auf die Leistung einer Anwendung aus, und es werden viele Tuning-Flags diskutiert. Aber es gibt keine magische-XX:+RunReallyFast Option.

Letztlich hängt die Leistung einer Anwendung davon ab, wie gut sie geschrieben ist. Wenn das Programm eine Schleife durch alle Elemente eines Arrays durchläuft, wird die JVM die Art und Weise, wie sie die Begrenzungsprüfung des Arrays durchführt, optimieren, damit die Schleife schneller läuft, und sie kann die Schleifenoperationen abrollen, um eine zusätzliche Beschleunigung zu erreichen. Wenn der Zweck der Schleife jedoch darin besteht, ein bestimmtes Element zu finden, wird keine Optimierungder Welt den Array-basierten Code so schnell machen wie eine andere Version, die eine Hash Map verwendet.

Ein guter Algorithmus ist das Wichtigste, wenn es um schnelle Leistung geht.

Weniger Code schreiben

Manche von uns schreiben Programme für Geld, manche zum Spaß, manche, um einer Gemeinschaft etwas zurückzugeben, aber wir alle schreiben Programme (oder arbeiten in Teams, die Programme schreiben). Es ist schwer, das Gefühl zu haben, dass du einen Beitrag zu einem Projekt leistest, wenn du Code beschneidest, und manche Manager bewerten Entwickler/innen immer noch nach der Menge des Codes, den sie schreiben.

Ich verstehe das, aber der Konflikt hier ist, dass ein kleines, gut geschriebenes Programm schneller läuft als ein großes, gut geschriebenes Programm. Das gilt generell für alle Computerprogramme und ganz besonders für Java-Programme. Je mehr Code kompiliert werden muss, desto länger dauert es, bis dieser Code schnell läuft. Je mehr Objekte zugewiesen und verworfen werden müssen, desto mehr Arbeit hat der Garbage Collector zu erledigen. Je mehr Objekte zugewiesen und behalten werden, desto länger dauert ein GC-Zyklus. Je mehr Klassen von der Festplatte in die JVM geladen werden müssen, desto länger dauert es, bis ein Programm startet. Je mehr Code ausgeführt wird, desto unwahrscheinlicher wird es, dass er in die Hardware-Caches der Maschine passt. Und je mehr Code ausgeführt werden muss, desto länger dauert die Ausführung.

Ich bezeichne dies als das "Tod durch 1.000 Schnitte"-Prinzip. Die Entwickler argumentieren, dass sie nur eine sehr kleine Funktion hinzufügen und es überhaupt keine Zeit kostet (vor allem, wenn die Funktion nicht genutzt wird). Dann behaupten andere Entwickler desselben Projekts das Gleiche, und plötzlich ist die Leistung um ein paar Prozent zurückgegangen. Der Zyklus wiederholt sich in der nächsten Version, und jetzt ist die Programmleistung um 10 % zurückgegangen. Ein paar Mal während des Prozesses stoßen die Leistungstests auf eine bestimmte Ressourcenschwelle - einen kritischen Punkt in der Speichernutzung, einen Überlauf des Code-Caches oder etwas Ähnliches. In diesen Fällen werden die regelmäßigen Leistungstests diesen Zustand aufspüren, und das Leistungsteam kann den scheinbar großen Rückschritt beheben. Aber mit der Zeit wird es immer schwieriger, die kleinen Rückschritte zu beheben.

Ich will damit nicht sagen, dass du niemals neue Funktionen oder neuen Code in dein Produkt einbauen sollst; die Verbesserung von Programmen bringt eindeutig Vorteile mit sich. Aber sei dir der Kompromisse bewusst, die du eingehst, und wenn du kannst, optimiere sie.

Oh, nur zu, optimiere vorschnell

Donald Knuth gilt als Erfinder des Begriffs " vorzeitige Optimierung", der von Entwicklern oft benutzt wird, um zu behaupten, dass die Leistung ihres Codes keine Rolle spielt und dass wir das erst wissen, wenn der Code ausgeführt wird. Das vollständige Zitat, falls du es noch nicht kennst, lautet: "Wir sollten kleine Effizienzgewinne, sagen wir etwa 97 % der Zeit, vergessen; vorzeitige Optimierung ist die Wurzel allen Übels."3

Diese Maxime besagt, dass du am Ende sauberen, unkomplizierten Code schreiben solltest, der einfach zu lesen und zu verstehen ist. In diesem Zusammenhang wird unter Optimierung verstanden, dass du algorithmische und Design-Änderungen vornimmst, die die Programmstruktur verkomplizieren, aber die Leistung verbessern. Diese Art von Optimierungen solltest du in der Tat so lange nicht vornehmen, bis das Profiling eines Programms zeigt, dass sie einen großen Nutzen bringen.

Was Optimierung in diesem Zusammenhang jedoch nicht bedeutet, ist die Vermeidung von Code-Konstrukten, die bekanntermaßen schlecht für die Leistung sind. Jede Codezeile ist eine Entscheidung, und wenn du die Wahl zwischen zwei einfachen, unkomplizierten Programmierweisen hast, wähle die leistungsstärkere.

Erfahrene Java-Entwicklerinnen und -Entwickler verstehen das sehr gut (es ist ein Beispiel für ihre Kunst, die sie im Laufe der Zeit gelernt haben). Betrachte diesen Code:

log.log(Level.FINE, "I am here, and the value of X is "
        + calcX() + " and Y is " + calcY());

Dieser Code führt eine String-Verkettung durch, die wahrscheinlich unnötig ist, da die Nachricht nicht protokolliert wird, es sei denn, die Protokollierungsstufe ist sehr hoch eingestellt. Wenn die Nachricht nicht gedruckt wird, werden auch unnötige Aufrufe der MethodencalcX()undcalcY()gemacht. Erfahrene Java-Entwicklerinnen und -Entwickler werden das reflexartig ablehnen; einige IDEs werden den Code sogar kennzeichnen und vorschlagen, ihn zu ändern. (Tools sind allerdings nicht perfekt: Die NetBeans IDE markiert die String-Verkettung, aber die vorgeschlagene Verbesserung behält die unnötigen Methodenaufrufe bei).

Dieser Logging-Code sollte besser so geschrieben werden:

if (log.isLoggable(Level.FINE)) {
    log.log(Level.FINE,
            "I am here, and the value of X is {} and Y is {}",
            new Object[]{calcX(), calcY()});
}

Dadurch wird die String-Verkettung ganz vermieden (das Nachrichtenformat ist nicht unbedingt effizienter, aber sauberer), und es gibt keine Methodenaufrufe oder Zuweisung des Objektarrays, es sei denn, die Protokollierung wurde aktiviert.

Wenn du den Code auf diese Weise schreibst, ist er immer noch sauber und leicht zu lesen; der Aufwand ist nicht größer als beim Schreiben des ursprünglichen Codes. Na gut, es waren ein paar mehr Tastenanschläge und eine zusätzliche Logikzeile nötig. Aber das ist keine voreilige Optimierung, die man vermeiden sollte, sondern eine Entscheidung, die gute Programmierer/innen zu treffen lernen.

Lass dich nicht von kontextlosen Dogmen von Pionierhelden davon abhalten, über den Code nachzudenken, den du schreibst. Du wirst in diesem Buch noch weitere Beispiele dafür finden, unter anderem in Kapitel 9, in dem die Leistung eines harmlos aussehenden Schleifenkonstrukts zur Verarbeitung eines Vektors von Objekten behandelt wird.

Schau woanders hin: Die Datenbank ist immer das Nadelöhr

Wenn du eigenständige Java-Anwendungen entwickelst, die keine externen Ressourcen nutzen, ist die Leistung dieser Anwendung (meistens) das Einzige, was zählt. Sobald eine externe Ressource (z. B. eine Datenbank) hinzugefügt wird, ist die Leistung beider Programme wichtig. Und in einer verteilten Umgebung - z. B. mit einem Java REST-Server, einem Load Balancer, einer Datenbank und einem Backend-Unternehmensinformationssystem - ist die Leistung des Java-Servers vielleicht das geringste Problem.

Dies ist kein Buch über die ganzheitliche Systemleistung. In einer solchen Umgebung muss ein strukturierter Ansatz für alle Aspekte des Systems gewählt werden. CPU-Auslastung, E/A-Latenzen und Durchsatz aller Teile des Systems müssen gemessen und analysiert werden; erst dann können wir feststellen, welche Komponente den Leistungsengpass verursacht. Zu diesem Thema gibt es hervorragende Ressourcen, und diese Ansätze und Tools sind nicht spezifisch für Java. Ich gehe davon aus, dass du diese Analyse durchgeführt und festgestellt hast, dass es die Java-Komponente deiner Umgebung ist, die verbessert werden muss.

Andererseits solltest du diese erste Analyse nicht übersehen. Wenn die Datenbank der Engpass ist (und hier ein Hinweis: Sie ist es), wird das Tuning der Java-Anwendung, die auf die Datenbank zugreift, die Gesamtleistung nicht verbessern. Es könnte sogar kontraproduktiv sein. Generell gilt: Wenn die Last in einem überlasteten System erhöht wird, verschlechtert sich die Leistung des Systems. Wenn etwas an der Java-Anwendung geändert wird, das sie effizienter macht - was die Belastung einer ohnehin schon überlasteten Datenbank nur noch weiter erhöht -, kann die Gesamtleistung sogar sinken. Die Gefahr besteht dann darin, dass die falsche Schlussfolgerung gezogen wird, dass die bestimmte JVM-Verbesserung nicht genutzt werden sollte.

Dieses Prinzip - dass eine Erhöhung der Last auf eine Komponente in einem System, das schlecht läuft, das gesamte System langsamer macht - gilt nicht nur für eine Datenbank. Es gilt auch, wenn ein Server, dessen CPU ausgelastet ist, zusätzlich belastet wird, oder wenn mehr Threads auf eine Sperre zugreifen, auf die bereits Threads warten, oder in vielen anderen Szenarien. Ein extremes Beispiel dafür, das nur die JVM betrifft, wird in Kapitel 9 gezeigt.

Optimieren für den Normalfall

Es ist verlockend - vor allem angesichts des "Tod durch 1.000 Schnitte"-Syndroms - alle Leistungsaspekte als gleich wichtig zu behandeln. Aber wir sollten uns auf die gängigen Anwendungsszenarien konzentrieren. Dieses Prinzip kommt auf verschiedene Weise zum Tragen:

  • Optimiere deinen Code, indem du ein Profil erstellst und dich auf die Operationen im Profil konzentrierst, die am meisten Zeit benötigen. Beachte jedoch, dass dies nicht bedeutet, dass du nur die Blattmethoden in einem Profil betrachtest (siehe Kapitel 3).

  • Wende Occams Rasiermesser bei der Diagnose von Leistungsproblemen an. Die einfachste Erklärung für ein Leistungsproblem ist die denkbarste Ursache: Ein Leistungsfehler im neuen Code ist wahrscheinlicher als ein Konfigurationsproblem auf der Maschine, das wiederum wahrscheinlicher ist als ein Fehler in der JVM oder im Betriebssystem. Obskure Betriebssystem- oder JVM-Fehler gibt es durchaus, und wenn glaubwürdigere Ursachen für ein Leistungsproblem ausgeschlossen werden, ist es durchaus möglich, dass der betreffende Testfall einen solchen latenten Fehler ausgelöst hat. Aber stürze dich nicht gleich auf den unwahrscheinlichen Fall.

  • Schreibe einfache Algorithmen für die häufigsten Operationen in einer Anwendung. Angenommen, ein Programm schätzt eine mathematische Formel und der Benutzer kann wählen, ob er eine Antwort mit einer Fehlerspanne von 10 % oder 1 % erhalten möchte. Wenn die meisten Nutzer mit der 10%igen Spanne zufrieden sind, optimiere diesen Codepfad - auch wenn das bedeutet, dass der Code, der die 1%ige Fehlerspanne liefert, langsamer wird.

Zusammenfassung

Java verfügt über Funktionen und Werkzeuge, die es ermöglichen, die beste Leistung aus einer Java-Anwendung herauszuholen. In diesem Buch erfährst du, wie du alle Funktionen der JVM am besten nutzen kannst, um schnell laufende Programme zu erstellen.

In vielen Fällen solltest du dich jedoch daran erinnern, dass die JVM nur einen kleinen Teil der Gesamtleistung ausmacht. In Java-Umgebungen, in denen die Leistung von Datenbanken und anderen Backend-Systemen mindestens genauso wichtig ist wie die Leistung der JVM, ist ein systemischer Ansatz für die Leistung erforderlich. Diese Ebene der Leistungsanalyse ist nicht der Schwerpunkt dieses Buches - es wird davon ausgegangen, dass eine sorgfältige Prüfung durchgeführt wurde, um sicherzustellen, dass die Java-Komponente der Umgebung der wichtigste Engpass im System ist.

Die Interaktion zwischen der JVM und anderen Bereichen des Systems ist jedoch ebenso wichtig - egal, ob diese Interaktion direkt (z. B. die beste Art und Weise, Datenbankaufrufe zu tätigen) oder indirekt (z. B. die Optimierung der nativen Speichernutzung einer Anwendung, die sich einen Rechner mit mehreren Komponenten eines großen Systems teilt) ist. Die Informationen in diesem Buch sollen helfen, auch solche Leistungsprobleme zu lösen.

1 Selten gibt es Unterschiede zwischen den beiden; zum Beispiel enthalten die AdoptOpenJDK-Versionen von Java neue Garbage Collectors in JDK 11. Ich werde auf diese Unterschiede hinweisen, wenn sie auftreten.

2 Du kannst in Docker gebrochene Werte für CPU-Limits angeben. Java rundet alle gebrochenen Werte auf die nächsthöhere ganze Zahl auf.

3 Es ist umstritten, wer dies ursprünglich gesagt hat, Donald Knuth oder Topy Hoare, aber es steht in einem Artikel von Knuth mit dem Titel "Structured Programming with goto Statements". Und in diesem Zusammenhang ist es ein Argument für die Optimierung von Code, auch wenn dies unelegante Lösungen wie die goto Anweisung erfordert.

Get Java Performance, 2. Auflage now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.