JEXP                       JEXP

Müllmänner und große Haufen / Keine Angst vor grossen Heaps

Wie überfordere ich eine JVM?

Wir kennen alle Moore’s Law über die Verdoppelung der Transistorzahl auf Prozessoren aller 18 Monate. Ähnliche exponentielle Entwicklungen gibt es auch bei anderen Hardwarekomponenten wie Festplatten oder RAM.

Während die JVM ziemlich gut in der Lage ist viele Prozessoren zu beschäftigen, sieht es mit der Nutzung großer RAM Mengen eher schlecht aus. Wer schon einmal grosse Anwendungen - standalone oder im AppServer mit entsprechenden Speichermengen - so ab 16 GB gefahren hat, weiss das die Garbage Collection dem kontinuierlichen Betrieb immer wieder Striche durch die Rechnung macht. Zu oft entstehen (bei entsprechender Last) längere Pausen in denen der GC die Anwendung anhält um z.b. ein Umlagerung oder Defragmentierung vorzunehmen. Daher wurden oft Architekturen in kleinere Komponenten zerlegt, die individuell weniger Heap nutzen, dafür aber über Kommunikationsmechanismen koordiniert werden müssen.

Rettung in Sicht

Das muss doch heutzutage wirklich nicht sein. Meinen ersten Aha-Effekt in dieser Richtung hatte ich, als ich vor ein paar Jahren mal auf der JAOO (heute GOTOCon) beim Clojure User Group Meeting mit Rich Hickey ein Demo einer Ameisensimulation in Clojure gesehen habe die auf einer Azul Vega Maschine 800 Prozessoren parallel auslastete und dabei mehrere zig Gigabyte Heap pro Sekunde durch deren Pauseless Garbage Collector schickte.

Spezialhardware wie die Vega Maschinen sind zwar eindrucksvoll aber die meisten von uns nutzen doch eher Standardhardware, um ihre Anwendungen zu betreiben. Netterweise, oder genau aus diesem Grund hat Azul ihre JVM Adaption auf x86 Prozessoren portiert und vertreibt das ganze jetzt unter dem wohlklingenden Namen "Zing" an ihre Kunden. Ein weiterer Grund dafür dass das erst jetzt passiert sind spezielle Eigenschaften der aktuellen Befehlssätze moderner Server-Prozessoren.

In dieser Kolumne möchte ich die Details der Zing Architektur näher beleuchten. Bei Interesse kann in einer der nächsten Kolumnen eine Gegenüberstellung der verschiedenen existierenden Kollektoren, z.b. auch des neuen G1 Kollektor von Java7 erfolgen.

Basics

Zuerst noch einmal ein paar Worte zur Garbage Collection allgemein und zu den Problemen, mit denen die Kollektoren fertig werden müssen (d.h. welche Parameter wirken sich auf deren Performance aus).

Garbage Collection gibt es schon seit den 70ern seit die automatische Speicherverwaltung in LISP Maschinen Einzug hielt. Die JVM hat seit Anfang an auf dieses Pferd (damals war es wohl eher ein Esel oder Maultier) gesetzt. Garbage Collection erspart uns den Kampf mit Pointern, manchen Speicherlecks und Buffer Overflows. Ausserdem ist es bequem, einfach Objekte zu erzeugen und sich dann nicht mehr um sie kümmern zu müssen. Der Garbage Kollektor macht das schon.

Mit fast jeder Version der JVM ist ein neuer Garbage Kollektor hinzugekommen, der schneller, besser und weniger intrusiv war. Der aktuellste von ihnen ist der in Java7 enthaltene G1 Kollektor, aber dazu später mehr. #TODO im nächsten Artikel ?

#todo image java-heap-generations

Eigenschaften von Garbage Kollektoren

Wie unterscheiden sich Garbage Kollektoren eigentlich, es gibt da einige Eigenschaften die bei bisher jedem Kollektor in irgendeiner Kombination wichtig waren:

Garbage Collection kann in mehreren Phasen ablaufen. Zum einen (Mark-Sweep-Phase) muss festgestellt werden welche Objekte noch am Leben (meist bedeutet das noch vom Root-Set erreichbar) sind. Danach wird der Rest aufgeräumt (Compact-Phase) also defragmentiert.

Das kann z.b. geschehen dem nur noch lebendige Objekte in einen neuen Speicherbereich kopiert werden (wie z.b. vom Young-Generation Survivor-Space 1 zu 2) oder indem die Speicherbereiche der toten Objekte als frei markiert werden. Beim letzteren entstehen zwangsläufig nicht mehr füllbare Lücken die irgendwann dazu führen, dass keine Objekte mehr angelegt werden können und der gesamte Speicherbereich defragmentiert werden muss.

Kollektoren können ihre Arbeit mit mit nur einem Thread (simpler) oder parallel ausführen (komplexer). Multi-threaded Kollektoren nutzen natürlich die vorhandenen Prozessoren besser aus. Kollektoren, die die Anwendung pausieren (Stop-the-World) können ohne Rücksicht auf die Anwendung arbeiten, wohingegen "concurrent" Kollektoren aufräumen, während die Anwendung weiterläuft. Es gibt Versionen von Kollektoren, die teilweise parallel arbeiten (z.b. in der Mark-Phase) und dann während der Defragmentierung die Anwendung(en) pausieren.

Kollektoren - Grafik aus Azul Whitepaper

Einflussfaktoren

Wie lange ein Objekt lebt beeinflusst, wo es gespeichert wird. Kurzlebige Objekte wie lokale Variablen von Methoden oder Blöcken werden zum Teil nur auf dem Stack alloziert, zumindest aber in einem speziellen Bereich für kurzlebige Objekte (Eden Space) der extrem schnell aufgeräumt wird. Lebt ein Objekt etwas länger ist es ein Kandidat für die Young-Generation die durch die wechselseitige Nutzung von zwei Speicherbereichen auch schnell beräumt werden kann. Lebt das Objekt länger (d.h. der GC kommt mehrmals vorbei) bzw. ist es zu gross dann kommt es in den Bereich für ältere Objekte (Tenured Space), der der größte der Speicherbereiche ist. Diesen Teil des Heaps aufzuräumen bzw. zu defragmentieren ist eine kostspieligere Angelegenheit, sie erfolgt bei existierenden Kollektoren nur während die Anwendung angehalten wird.

Bestimmte langlebige Objekte (wie z.b. Klassendefinitionen oder internalisierte Strings) werden in manchen JVMs sofort in einen speziellen Speicherbereich untergebracht (PermGen), dessen Auslastung kann besonders mit dynamischen Sprachen wie z.B. Groovy deutlich anwachsen.

Wenn eine Anwendung extrem schnell Millionen von Objekten erzeugt und wieder entsorgt (object churn), dann kann es passieren dass der Garbage Kollektor nicht mithalten kann und sich der vorhandene Heap vorschnell füllt. Dann wird als letzte Möglichkeit der Anwendung die Handbremse angelegt und in einer (längeren) Pause erst einmal wieder Platz geschaffen. Wenn durch die schnelle Objekt-Allokation nur noch wenig Platz auf dem Heap ist, und auch nur wenig bereinigt werden kann, kann es vorkommen, das ein Lauf des GCs nicht genügend Freiraum geschaffen hat und sofort wieder startet. Das zeigt sich dann in einem langsamen aber qualvollen Tod der Anwendung, die dann meist sowieso in einem Out-Of-Memory Error endet.

Die Anzahl der vorhandenen Objekte auf dem Heap bzw. deren Größe spielt besonders für die Feststellung der lebendigen Objekte eine Rolle, da hierbei alle Objekte traversiert werden müssen. Aber auch beim Kopieren der aktiven Objekte ist es relevant. Die Größe von Objekten bestimmt, wo sie auf dem Heap gelagert werden, welche Lücken sie nach dem Löschen hinterlassen und wie schnell sich der Heap füllt.

Die daraus entstehende Fragmentierung hat einen grossen Einfluss auf die Häufigkeit und Dauer der Kollektionen. Je höher sie ist und je schneller der Heap wieder fragmentiert ist, desto häufiger muss der Garbage Kollektor aufwändig defragmentieren.

Die Anzahl der vorhandenen Prozessoren bzw. aktiven Threads bestimmt wie schnell Objekte erzeugt werden können und wieviele zusätzliche Threads ein paralleler Garbage Kollektor zusätzlich starten kann. Für Kollektoren oder Phasen die nicht thread-safe sind, muss die Anwendung angehalten werden damit die parallele Objekterzeugung nicht interferiert.

Die Nutzung von Soft- und Weak-Referenzen hat auf bestimmte Kollektoren auch Auswirkungen, da diese oft komplett in einer Phase bereinigt werden.

Wenn ein Garbage Kollektor die Anwendung pausiert (und sei es nur im zugesicherten Millisekunden-Bereich), hat er das Problem dass bei aufwändigen Bereinigungsaufgaben die Deadline zu schnell näher rückt. Viele Kollektoren beenden daher vorzeitig Aufgaben, da die Phase innerhalb der Deadline erfolgreich abgeschlossen werden muss. Das kann zur ungenügenden Bereinigung des Heaps führen, die sich schnell zu einem Problem aufschaukeln kann.

Der Zing C4 Kollektor

Zing enthält einen parallelen, compacting Garbage Kollektor (Continuously Concurrent Compacting Collector oder C4), der massiv parallel arbeitet, keine "stop-the-world" Phasen kennt und die Ausführung der Anwendung nicht einschränkt. Beim Design des Kollektors wurde auf Robustheit und auf Unempfindlichkeit gegenüber den genannten Faktoren Wert gelegt.

Interessanterweise ist die Funktionsweise des Garbage Kollektors wirklich simpel. Er basiert auf einem zentralen Mechanismus, der diese Vereinfachung ermöglicht.

Der wichtigste Aspekt ist die Nutzung einer Indirektion beim Zugriff auf Objekt-Referenzen. Der "Load Value Barrier" genannte Mechanismus enthält zum einen ein Flag, das speichert ob das Objekt schon von der Markierungsphase erfasst wurde. Zum anderen wird beim Zugriff auf eine Referenz über diese Barriere ein Konsistenzcheck ausgeführt, der z.b. bei verlagerten, aber noch nicht aktualisierten Referenzen oder bei nicht gesetzten Mark-Flags triggert.

Diese Überprüfung löst bei Fehlschlag sofort eine Benachrichtigung an den Garbage Kollektor aus, der die notwendigen Korrekturen vornimmt, bevor die Referenz an das Programm weitergegeben wird. Damit ist sichergestellt, dass dem ausgeführten Programm nur valide Referenzen zu Verfügung gestellt werden.

Eine interessante Folge dessen ist, dass der Garbage Kollektor nicht mit vorauseilendem Gehorsam immer alle Objekte erfassen oder korrigieren muss, da keine inkorrekten Referenzen entwischen können.

Wie läuft die Garbage Collection bei Zing jetzt konkret ab? Wie viele andere Kollektoren arbeitet Zing auch in Phasen, diese laufen aber parallel ab und halten nie die Anwendung an. Der Heap ist bei Zing in Seiten unterteilt.

Ablauf der Kollektion

In der Mark Phase werden alle aktiven Referenzen aus den CPU-Registern und der aktiven Threads ermittelt (für blockierte oder wartende Threads macht das der GC). Die Threads können dann unbehelligt vom GC weiterlaufen. Dieses Root Set wird markiert und ausgehend davon, über eine Erreichbarkeits-Traversierung alle lebendigen Objekte erreicht und ebenfalls mit Marken versehen. Für Objekte die währenddessen vorzeitig benutzt werden, kommt die Load Value Barrier zum Einsatz die dann das Setzen der Markierung auslöst. Damit ist erst einmal geklärt, welche Objekte noch aktiv sind. Soft-, Weak- und Phantom-Referenzen werden auch parallel in dieser Phase bearbeitet.

In der darauffolgenden "Relocate" Phase werden die am spärlichsten besetzten Seiten gesucht und deren aktive Objekte in eine neue, leere Seite kopiert, die daraufhin eine kontinuierliche Ansammlung von Objekten enthält. Die freigewordene Page wird sofort wieder freigegeben, die Information über die Relokation wird ausserhalb der alten Page gehalten. Zumindest der physikalische Speicher ist wieder frei, die virtuelle Speicherseite kann erst wieder freigegeben werden, wenn keine Referenz mehr darauf zeigt. Damit kann aber der Kollektor die Seite auch sofort wieder benutzen.

Falls eines der umgelagerten Objekte von der Anwendung genutzt werden will, greift wieder die Load Value Barriere und repariert sowohl die Objektreferenz als auch sich selbst.

Ansonsten schliesst noch eine "Remap" Bereinigungsphase an, die noch nicht aktualisierten Objektreferenzen auf ihren neuen Bestimmungsort zeigen lässt und die Marken aktualisiert. Das passiert einfach über eine Traversierung und Auslösen Load Value Barriere. Und wo der Kollektor gerade beim Traversieren ist, erledigt er dabei gleich auch noch die Markierung der aktiven Objekten, d.h. die Mark und die Remap-Phase werden in einem Durchgang kombiniert.

Umsetzung auf x86

Azul’s Zing 4.0 läuft auf Intel 55xx, 56xx und jüngeren Xeon Prozessoren (mit EPT Extended Page Table) und auf AMD Prozessoren 6100, 8100 und jünger (mit NPT, AMD-V Nested Paging). Der interessanteste Aspekt, die Load Value Barrier wird mit wenigen Instruktionen in den von der JVM generierten Maschinencode eingewoben, da ihr Check auf einen einzelnen Branch-Jump beschränkt, können aktuelle Prozessoren diesen Seitenstrang und den Hauptstrang weit vorberechnen (Branch-Prediction & XXXX read ahead), so dass beim Nicht-Zutreffen der Bedingung (was in den meisten Taktzyklen der Fall ist) die schon weit fortgeschrittene Mikrocode-Ausführung genutzt werden kann. Daher bleibt die ganze Ausführung auch im Prozessor und seinen Caches und ist schön schnell. Die neue virtuelle Speicherseitenverwaltung in diesen Prozessoren unterstützt und beschleunigt den Umlagerungsprozess zwischen den Seiten aktiv. Diese neuen Prozessor-Features erlauben es, mehrere Gigabyte RAM pro Sekunde zu belegen und wieder freizugeben. Es sind eher die Betriebssysteme die damit noch nicht Schritt halten können.

Auf der Betriebssystem-Ebene kommt dann die Managed Runtime Initiative zum tragen. Die open-source MRI hat einen umfassenden Ansatz in Bezug auf die Ausnutzung aktueller Hardware-Features über alle Schichten von Betriebssystem-Kern bis zur JVM. Innerhalb des Projektes hat Azul ihren Garbage Kollektor, Patches für das OpenJDK und diverse Linux-Kernel-Patches und -Erweiterungen beigesteuert.

Architektur

Zing besteht aus mehreren Bestandteilen. Auf einer Virtualisierungsumgebung wie KVM oder VMWare ESX(i) läuft ein Gast-Betriebssystem-System mit der Zing-JVM. Die Zing JVM ist nur ein schmaler Proxy, der die Ausführung des Java-Codes in die eigentliche, betriebssystem-unabhängige Umgebung (Zing-Virtual-Appliance) transferiert. Die Virtual-Appliance wird auch innerhalb der Virtualisierungsumgebung installiert. Die Nutzung einer separaten Komponente ist notwendig, da aktuelle Betriebssysteme noch zu viele Einschränkungen bei der schnellen Speicher (De-)Allokation aufweisen. Die Interaktion zwischen beiden Bestandteilen erfolgt über ein internes virtuelles Netzwerk? Beim jedem Zugriff vom OS auf die Anwendung (z.b. Netzwerkzugriff von aussen) wird diese Barriere einmalig überschritten, das kostet nur minimale Performance.

Unter VMWare unterstützt Zing bis zu 512 GB RAM und 160 (virtuelle) Prozessoren, auf KVM sind es sogar 768 GB RAM und 160 Prozessoren. #TODO

Diese Infrastruktur lässt sich über den Zing Resource Controller steuern. Zing Vision ist ein Monitoring- und Statistik-Tool mit dem ohne Performance-Verlust Profiling von Anwendungen möglich ist. Informationen die der Garbage Kollektor (Speicher) und die JVM (JIT) während der Ausführung nutzen, werden dem Anwender in Real-Time bereitgestellt. Zing Vision wurde ursprünglich für Statistiken zur Weiterentwicklung der Azul Technologie entwickelt, war aber zu nützlich um es nicht auch anderweitig zu verwenden.

Performance

Für die Demonstration der Performance hat der Chef-Wissenschaftler von Azul Cliff Clicks eine e-Commerce Warenkorb Liferay Demo auf der Zing JVM laufen lassen. Mit einem SLA das 99.9% aller Requests innerhalb von 5 Sekunden ausgeführt werden können, konnten mit dem CMS Kollektor der Hotspot JVM 45 parallele Nutzer bedient werden, auf der Zing JVM waren es 800 (90 GB Heap). Dabei wurden 3,2 GB Heap pro Sekunde bereinigt.

Zing 4.1

Mit Version 4.1. gab es noch einmal einen ziemlichen Performance-Sprung (um 80 Prozent), der diesen Benchmark auf 1500 parallele Nutzer hochschnellen lies. Die Details der Verbesserungen sind wieder interessant. Zum einen geht es um die Nutzung von Aussagen über die relative Lage von Speicher-Adressen zueinander. Nur zu den bekannten Garbage Collection Zeitpunkten (Umlagerung) können sich Speicher-Adressen ändern, daher werden die Adressen zwischenzeitlich von JVM/JIT Optimierungen als Konstanten betrachtet. Ein weiterer Aspekt ist die Behandlung von Feldern. Da oft auf Felder mehrmals mittels Indexierung zugegriffen wird (jedenfalls nach JIT-Inlining), wurde zuerst immer die LVB des Feldes gecheckt und dann die LVB des Feldelements. Der Check der Feldereferenz selbst wird jetzt innerhalb bestimmter Grenzen nur noch einmal vorgenommen und damit viele Takte gespart. Beim Kopieren von Feldern werden Quell- und Zieladressen der Element nur einmal überprüft. Es werden auch Überprüfungen auf Null-Werte erkannt. Für die dort geladenen Referenzen werden Load Value Barriers erst angelegt, wenn sie den Null-Check bestanden haben.

Ein paar nette Tools, die von Azul bereitgestellt werden, sind JitterMeter, ein Programm, das misst, wie stark Anwendungsthreads durchschnittlich von Pausen die durch die JVM verursacht werden (z.b. durch GC) beeinflusst werden. Fragger hilft beim Erzeugen realerer Bedingungen für GC-Tests indem Heap-Fragmentierung schneller herbeigeführt wird, die sonst erst nach längerer Laufzeit auftreten würde.

Azul Systems, one of the five vendors to make Gartner’s 2011 “Cool Vendors in Application and Integration Platforms”

referenzen

infoq artikel

garbage collection vs. STM se-radio

azul whitepapers / trial

next article - todo gc-log

Andere existierende GC’s # todo nächster Artikel ?

http://www.azulsystems.com/resources/tools http://www.infoq.com/Azul http://java.dzone.com/category/tags/azul http://www.azulsystems.com/products/zing/whatisit http://www.azulsystems.com/trial # todo link azul liferay demo http://java.dzone.com/zing-benchmarks http://www.azulsystems.com/products/zing/performance

Last updated 2011-07-23 20:36:36 CEST
Impressum - Twitter - GitHub - StackOverflow - LinkedIn