Intro

In diesem Blogpost möchte ich kurz anreissen, wie man die Java Runtime Environment (JRE) überwacht.
Es folgt eine Beschreibung, wie man die Leistung einer Java-Anwendung bewerten kann, in dem man den Speicherverbrauch, die Garbage-Collector-Metriken, die Überwachung von Java-Daemon- und User-Threads und andere grundlegende JRE-Metriken analysiert.

Wenn man an einer großen Java-Anwendung arbeitet, ist es wahrscheinlich, dass etwas irgend wann fehlschlägt, sich schlecht benimmt oder dass man eine (überraschende) OutOfMemoryException bekommt.

Und wenn man Java-Anwendung auch als Containerisierten Microservice einsetzt, kann die Überwachung von Java in Docker und Kubernetes neue und unerwartete Herausforderungen mit sich bringen.

Ich werde (leider) ein einigermaßen verständliches ‚Denglisch‘ benutzen, damit die Begrifflichkeiten erhalten bleiben. ;)

Überwachung der Leistung von Java-Anwendungen durch JRE-Metriken

Die Java Runtime Environment (JRE), über die wir heute sprechen werden, enthält viele allgemeine Informationen darüber, wie sich Ihre Anwendung verhält.
Einschließlich CPU-Auslastung, Anzahl der laufenden Threads, Anzahl der geladenen Klassen, Garbage Collector-Informationen, Speichernutzung und andere relevante Kennzahlen.

Überwachung der Java-Speicherauslastung

Eine der wichtigsten Java-Ressourcen zur Überwachung und Profilierung ist der Speicherverbrauch.
Man muß den Speicherverbrauch im Laufe der Zeit analysieren, um Speicherlecks zu vermeiden und unerwartete Ausfälle zu beheben.

Der Speicher kann in zwei verschiedene Teile aufgeteilt werden: stack und heap.
Um den Java-Speicherverbrauch zu überwachen, muß man die Unterschiede zwischen diesen Speicherdatenzonen und das spezifische Verhalten der einzelnen Zonen verstehen.

Stack

Der Stack ist der Teil des Speichers, der Informationen über Funktionsaufrufe enthält.
Hier werden lokale Variablen gespeichert und zerstört. Er arbeitet – dank eines last-in, first-out Designs – entsprechend schnell.

Speicheroperationen wie das verschieben oder eine verkleinerung / vergrößerunge des Stacks sind einfach durchzuführen.
Die Kosten für die Zerstörung einer Variablen sind gering, da ihr Speicher einfach wegfällt.

Die Stackgröße kann pro Thread (jeder Thread hat seinen eigenen Stack) mit dem Parameter -Xss<size> konfiguriert werden:

java -Xss18m somejavafile

Der Stack selbst ist in verschiedene Regionen unterteilt: permanent generation und code cache.

permanent generation

Die permanent generation Region des Stacks enthält Metadaten, die von der JVM zur Beschreibung der in der Anwendung verwendeten Klassen und Methoden benötigt werden.
Wenn eine Klasse geladen wird, wird die permanent generation mit ihr und ihren Methoden gefüllt.
Diese Region ist auch in einer vollständigen Garbage Collection enthalten, da beim Tod eines Classloaders alle seine Klassen entladen werden müssen.

code cache

Code Cache ist eine Region des Stacks, in der der gesamte native Code gespeichert und vom JIT-Compiler kompiliert wird.

Heap

Der Heap ist der Rest des für die Objektzuordnung reservierten Speichers.
Normalerweise, wenn man eine Objektinstanz im Heap zuweist, muss dieser Speicher manuell freigegeben werden, wenn man das Objekt nicht mehr verwenden möchte.
Andernfalls kann es zu einem häufigen Problem – wie in Sprachen wie C oder C++ kommen – einem Speicherleck.

Ein Speicherleck tritt auf, wenn alle Zeiger auf ein zugeordnetes Objekt im Heap verloren gehen und die Instanz nicht mehr referenziert oder freigegeben werden kann.
Dieser Teil des Speichers kann erst nach dem Schließen des Programms wieder verwendet werden.
Wenn dies im Programmcode häufig vorkommt, kann man einen Moment erreichen, in dem kein Speicher mehr für eine Zuweisung zur Verfügung steht.

In Java wird der Speicher von Objekten, die nicht von einem Pointer referenziert werden, automatisch vom Garbage Collector (GC) freigegeben.
Allerdings hat dies seine Grenzen, ebenso wie Referenzen und Ressourcen, die offen bleiben.

Wenn man einem Objekt Speicher zuweist und eine Referenz darauf behält, obwohl man das Objekt nicht mehr verwendet, ist das ein weiteres Speicherleck-Szenario, dass man vermeiden sollte.

Auch wenn man eine Ressource – z.B. einen Stream – öffnet und diese nicht wieder schließt, bleibt sie offen und wird weiterhin Speicher im Heap verbrauchen.
Eine gute Lösung dafür wäre hier die die Verwendung der „try with resource„-Blöcke, die mit Java 7 freigegeben wurden.

Wenn man nicht mehr in der Lage ist Speicher zu reservieren, löst die JVM eine Ausnahme namens java.lang.OutOfMemoryError aus und beendet das Programm.

Der maximal zulässige Heap-Speicher pro Anwendung kann beim Start der Anwendung mit den folgenden Parametern konfiguriert werden:
-Xms setzt die anfängliche Java-Heap-Größe
-Xmx maximale Java-Heap-Größe einstellen

Der Heap ist ebenfalls in mehrere Regionen unterteilt, darunter eden space, survivor space, and tenured generation.

Eden-Space

In diesem Bereich des Heaps werden alle neuen Objekte zugeordnet.
Wenn ein Objekt eine Garbage Collection überlebt, wird es in den survivor space verschoben, der als Teil der Young generation gilt.

Survivor Space

Wenn sich die Young generation füllt, wird eine minor garbage collection durchgeführt.
Diese collection ist optimiert unter der Annahme einer hohen Objektsterblichkeitsrate.
Es führt immer ein stop-the-world Ereignis durch, ist aber ein wirklich schneller Weg, um eine Young generation zu reinigen, die voller toter Objekte ist.

Tenured Generation

Auch die old generation genannt, enthält die tenured generation alle langlebigen Objekte.

Wenn er sich füllt, wird eine major garbage collection abgefeuert.
Dies ist ein weiteres stop-the-world Ereignis, das aber viel langsamer ist, da es alle lebenden Objekte umfasst.

Überwachung des Java-Garbage-Collectors

Der Garbage Collector ist eine Schlüsselkomponente zur Überwachung von Java in Docker und Kubernetes.
Ein korrektes und vorhersehbares GC-Verhalten stellt sicher, dass Ihr Pod nicht die konfigurierten Grenzen erreicht und schließlich getötet und ersetzt wird.

Garbage Collector

Der GC zielt darauf ab, nicht mehr erreichbaren Heap-Speicher freizugeben, um diesen für neue Objektzuordnungen zur Verfügung zu stellen.

Es kann mit System.gc() aufgerufen werden; dies garantiert jedoch nicht die Ausführung.
Wenn der GC arbeitet, müssen alle Zustände aller Threads gespeichert werden.
Wenn er ausgeführt wird, während ein Objekt zugewiesen wird, kann er die Spezifikation der Java Virtual Machine brechen.
Der Garbage Collector verbraucht Ressourcen, um zu entscheiden, welcher Speicher freigegeben werden muss, was zu einem Overhead und Leistungseinbußen führen kann.
Die meisten modernen Garbage Collectoren versuchen, keine stop-the-world Collection durchzuführen.
Es gibt mehrere Implementierungen der Garbage Collection in Java, die wir uns im folgenden anschauen.

Serial garbage collector

Der serial garbage collector ist für den Einsatz in Single-Thread-Umgebungen vorgesehen.
Es ist für die Verwendung mit einfachen Befehlszeilenprogrammen konzipiert und nicht für Serverumgebungen geeignet.
Ein serieller GC verwendet einen einzelnen Thread und führt während des Betriebs ein stop-the-world Ereignis durch.
Parameter: -XX:+UseSerialGC

Parallel garbage collector

Auch als throughput collector bezeichnet, war der parallel garbage collector von der Einführung bis Java 9 der Standard in der JVM.

Im Gegensatz zu einem seriellen GC verwendet ein paralleler GC mehrere Threads zum Ausführen. Es führt auch ein Stop-the-World durch, um die gesamte Anwendung zu stoppen, bis die Garbage Collection abgeschlossen ist.
Parameter: -XX:+UseParallelGC

CMS garbage collector

Der Concurrent Mark Sweep (CMS) Garbage Collector implementiert einen mark-and-sweep Algorithmus in zwei Schritten:

  1. mark: Führen Sie eine Baumsuche durch, um Objekte zu finden, die verwendet werden, und markieren Sie sie als „in Gebrauch“.
  2. sweep: diejenigen, die nicht als „in Gebrauch“ markiert sind, gelten als unerreichbar, so dass ihr Speicher freigegeben wird und „in Gebrauch“ Markierungen weggefegt werden.

Bei einem CMS GC muss das gesamte System gestoppt werden, um eine Änderung des Speicherbaumes zu verhindern und der gesamte Speicher muss zweimal überprüft werden.
Parameter: -XX:+UseConcMarkSweepGC und -XX:ParallelCMSThreads=<num>

G1 garbage collector

Der garbage-first collector (G1) wurde in JVM 6 eingeführt und von JVM 7 unterstützt.
Es war geplant, den CMS GC damit zu ersetzen und ist seit Java 9 das Standard-GC.

Mit G1 konzentriert sich die Garbage Collection auf Speicherbereiche mit der geringsten Menge an Live-Daten, während die Global Heap Collection gleichzeitig durchgeführt wird, um lange Unterbrechungen zu vermeiden.
Es gibt zwei wesentliche Unterschiede zwischen dem CMS und den G1 GCs:

  1. G1 ist ein compacting collector, der Teile des collectors vereinfacht und Fragmentierungsprobleme eliminiert.
  2. Die Pausen der Garbage Collection sind vorhersehbarer und können vom Benutzer mit G1 konfiguriert werden.

Parameter: -XX:+UseG1GCC

Überwachung von Java-Threads

Threads

Das Erstellen von Threads zur Erledigung paralleler Aufgaben kann die Leistung einer Anwendung erheblich verbessern.
So z.B. das Lesen oder Schreiben von Daten in eine Datenbank oder in eine Datei.
Man kann aber auch auf Probleme zu stoßen, wenn man viele Threads hat, die gleichzeitig arbeiten.

Das Erstellen und Zerstören von Threads sowie das Speichern und Wiederherstellen der Zustände der Threads verursachen einen erheblichen Overhead, da Hardware-Ressourcen gemeinsam genutzt werden.
Dies führt zu einer allgemeinen Verlangsamung der Anwendung.
Die Lösung besteht darin, die Anzahl der laufenden Threads zu begrenzen.

Im Idealfall werden die computing threads von den I/O Threads getrennt, da diese sich gegenseitig blockieren.
Idealerweise sollen daher computing Threads so designed werden, das sie die nicht von externe Ereignissen blockiert werden.

Es gibt zwei Arten von Threads in Java: daemon threads und user threads.

daemon threads

Daemon threads sind Dienstanbieter für die user threads.

Sie werden von der JVM erstellt. Ihr Leben hängt von user threads ab. Sie haben eine geringe Priorität und werden für die Garbage Collection und andere, housekeeping Aufgaben verwendet.
Die JVM wird nicht darauf warten, dass Daemon threads ihre Ausführung beenden.

user threads

User threads werden vom Benutzer oder der Anwendung erstellt.
Sie haben hohe Priorität und die JVM wartet, bis sie ihre Aufgaben abgeschlossen haben.

Fazit

Die Java-Laufzeitumgebung ist die grundlegendste Informationsquelle für die Überwachung von Java, um die grundlegenden Speicherdatenstrukturen zu verstehen, und das Thread-Verhalten ist der Schlüssel zum Aufspüren nicht trivialer Ausnahmen.

Um die JRE zu überwachen bedarf es keiner kommerziellen Produkte, da diese bereits von der JVM zur Verfügung gestellt werden.

Wie man diese Endpunkte erfolgreich auslesen kann, habe ich bereits in einem früheren Blogpost beschrieben.