Nützliche JVM Flags – Teil 6

1 Kommentar

Ein Algorithmus zur Garbage Collection (GC) wird für die meisten praktischen Einsatzgebiete anhand zweier Kriterien bewertet:

  1. Je höher der erzielte Durchsatz, desto besser der Algorithmus.
  2. Je geringer die resultierenden Pausenzeiten, desto besser der Algorithmus.

Klären wir zunächst mal die Begriffe „Durchsatz“ und „Pausenzeit“ im Kontext von GC. Die JVM führt eine GC stets in eigenen Threads, den sogenannten „GC-Threads“ aus. Sind diese GC-Threads aktiv, so konkurrieren sie mit den eigentlichen „Anwendungs-Threads“ um die verfügbaren Prozessoren und damit auch um CPU-Zeit. Etwas vereinfacht beschreibt nun der „Durchsatz“ denjenigen Anteil, den die Anwendungs-Threads an der Gesamtausführungszeit der Anwendung haben. Ein Durchsatz von 99/100 beispielsweise bedeutet, dass die Anwendungs-Threads im Mittel 99 Sekunden Aktivität auf 100 Sekunden Laufzeit der Anwendung erhalten, während die GC-Threads im selben Zeitraum nur eine Sekunde aktiv sind.

Als „Pausenzeiten“ bezeichnet man zusammenhängende Zeiträume, in denen die Anwendungs-Threads zugunsten der GC-Threads stillgelegt sind. Tritt beispielsweise während einer GC eine Pausenzeit von 100 Millisekunden auf, so bedeutet dies, dass in diesen 100 Millisekunden kein Anwendungs-Thread aktiv war. Spricht man für eine Anwendung nun von einer „mittleren Pausenzeit“ von 100 Millisekunden, so haben alle aufgetretenen Pausenzeiten im Schnitt 100 Millisekunden betragen. Eine „maximale Pausenzeit“ von 100 Millisekunden hingegen bedeutet, dass keine einzige der während der Anwendung aufgetretenen Pausenzeiten größer als 100 Millisekunden war.

Durchsatz vs. Pausenzeiten

Ein hoher Durchsatz ist wünschenswert, da nur die Anwendungs-Threads „produktive“ Arbeit (im Sinne der Sichtbarkeit für die Endnutzer) ausführen. Intuitiv arbeitet die Anwendung schneller, wenn der Durchsatz höher ist. Geringe Pausenzeiten sind ebenfalls wünschenswert, weil die Anwendung während der Pausenzeiten aus Sicht der Endnutzer „hängt“. Je nach Art der Anwendung können schon verhältnismäßig kleine Hänger von ca. 200 Millisekunden als störend empfunden werden. Insbesondere für interaktive Anwendungen ist es also relevant, die maximale Pausenzeit gering zu halten.

Leider ist es so, dass die beiden Ziele „hoher Durchsatz“ und „geringe Pausenzeiten“ miteinander konkurrieren. Machen wir uns das, erneut etwas vereinfacht, an einem kleinen Gedankenspiel klar: Eine GC benötigt gewisse Rahmenbedingungen, um sicher ablaufen zu können. Es muss beispielsweise gewährleistet sein, dass die Anwendungs-Threads nicht gleichzeitig auf den Objekten arbeiten, die von der GC gerade zuverlässig als lebendig oder tot eingestuft werden sollen. Deshalb müssen die Anwendungs-Threads während einer GC (bzw. je nach Algorithmus nur während bestimmter Phasen einer GC) gestoppt werden. Dadurch entstehen aber Kosten für Thread-Scheduling, und zwar einerseits direkte Kosten durch den Kontextwechsel und andererseits indirekte Kosten durch die in der Folge resultierenden Cache-Effekte. Zusammen mit den Kosten durch weitere JVM-interne Vorsichtsmaßnahmen bedeutet dies, dass schon das Starten einer GC bestimmte Fixkosten mit sich bringt, die zusätzlich zur eigentlichen GC von der Zeit für die Anwendungs-Threads abgehen. Maximalen Durchsatz erreicht man deshalb dadurch, dass man die GC so selten wie möglich ausführt und so deren Fixkosten insgesamt gering hält.

Führt man die GC nur selten aus, so kommt jedoch mehr Arbeit auf jede einzelne GC zu – denn seit der letzten GC haben sich mehr Objekte auf dem Heap angesammelt. Jede einzelne GC benötigt dann mehr Zeit zur Komplettierung, was wiederum eine höhere mittlere und maximale Pausenzeit zur Folge hat. Aus Sicht der Pausenzeiten wäre es also wünschenswert, die GC häufiger und dafür jedes Mal kürzer auszuführen. Darum leidet jedoch wiederum der Durchsatz, und so schließt sich der Kreis.

Ein GC-Algorithmus muss sich daher entscheiden, worauf er optimiert: Er kann sein Verhalten komplett auf eines der beiden Ziele (maximaler Durchsatz bzw. minimale Pausenzeiten) ausrichten oder aber versuchen, einen Kompromiss zwischen den Zielen zu finden.

Garbage Collection auf der HotSpot-JVM

Mit der Young Generation GC haben wir uns in Teil 5 bereits ausgiebig beschäftigt. Für die Old Generation stellt die HotSpot-JVM im Wesentlichen zwei Klassen von GC-Algorithmen bereit. Die eine Klasse enthält Algorithmen, bei denen eine Maximierung des Durchsatzes im Vordergrund steht, während die andere Klasse die Minimierung der Pausenzeiten zum Ziel hat. Heute befassen wir uns mit der ersten, „durchsatzorientierten“ Klasse.

Eine detaillierte Beschreibung der GC-Algorithmen ist nicht das Ziel dieses Beitrags, daher gebe ich nur einen kurzen Überblick. Bei den durchsatzorientierten Algorithmen von HotSpot wird die GC ausgelöst, wenn eine Objektallokation in der Old Generation aufgrund von Platzmangel fehlschlägt. Die GC-Algorithmen durchforsten dann (ähnlich wie bei der Young Generation GC) den Heap nach erreichbaren Objekten der Old Generation, die als „lebendig“ markiert werden. Anschließend werden die lebendigen Objekte so im Speicher verschoben, dass sie einen zusammenhängenden Bereich bilden, und der verbleibende Speicher wird als frei vermerkt. Es findet also keine Copy-Collection in einen anderen Bereich, wie das bei der Young Generation der Fall ist, sondern ein Verschieben im selben Bereich inklusive einer Defragmentierung statt. Die Kollektoren verwenden dabei einen oder mehrere Threads, um die GC durchzuführen. Im Fall mehrerer Threads werden die jeweiligen Arbeitsschritte geschickt aufgeteilt, so dass die Threads möglichst in eigenen Bereichen arbeiten und sich nur wenig in die Quere kommen. Beachtenswert ist noch, dass die Anwendungs-Threads während der GC komplett stillgelegt und erst nach Beendigung der GC wieder aktiviert werden. Schauen wir uns nun die wichtigsten Flags hierzu an.

-XX:+UseSerialGC

Mit diesem Flag aktivieren wir die vollständig serielle (d.h. single-threaded) Variante des durchsatzorientierten Garbage Collectors. Das bedeutet, sowohl die GCs der Young Generation als auch die der Old Generation werden jeweils mit einem einzigen GC-Thread durchgeführt. Dieses Flag ist für JVMs zu empfehlen, denen nur ein einziger Prozessorkern zur Verfügung steht. Die Nutzung mehrerer GC-Threads wäre in diesem Fall sinnlos, da diese zwar um die CPU konkurrieren und Synchronisationsoverhead erzeugen, aber niemals gleichzeitig laufen würden.

-XX:+UseParallelGC

Mit diesem Flag wird die Young Generation GC parallel ausgeführt, d.h. es können mehrere GC-Threads eingesetzt werden, die gleichzeitig an einer GC arbeiten. Dieses Flag ist mittlerweile veraltet; falls die verwendete JVM-Version es erlaubt, so ist das Flag -XX:+UseParallelOldGC vorzuziehen.

-XX:+UseParallelOldGC

Dieses Flag ist etwas ungünstig benannt, weil das „Old“ gerne als „veraltet“ aufgefasst wird. Tatsächlich aber steht „Old“ für die Old Generation, und -XX:+UseParallelOldGC ist gegenüber dem verwandten Flag -XX:+UseParallelGC klar vorzuziehen. Es aktiviert nämlich zusätzlich zu den parallelen Young Generation GCs auch die parallele Ausführung der Old Generation GCs. Dieses Flag empfiehlt sich stets auf Systemen, die der JVM mehr als einen Prozessorkern zur Verfügung stellen.

Etwas Wissenswertes noch am Rande: Die parallelen Varianten des durchsatzorientierten GC-Algorithmus von HotSpot werden gerne als „Throughput“-Kollektoren bezeichnet, da sie durch die parallele Ausführung den Durchsatz ggf. beträchtlich erhöhen können.

-XX:ParallelGCThreads

Mit -XX:ParallelGCThreads=<value> legen wir die Anzahl der GC-Threads fest, mit denen parallele GCs ausgeführt werden. Beispielsweise bedeutet -XX:ParallelGCThreads=6, dass sämtliche parallelen GCs jeweils mit sechs Threads ausgeführt werden. Verzichten wir darauf, dieses Flag zu setzen, wird ein Standardwert verwendet, der sich an der Anzahl verfügbarer (virtueller) Prozessoren orientiert. Ausschlaggebend ist hier der Wert N, der von der Java-Methode Runtime.availableProcessors() zurückgeliefert wird. Für N <= 8 werden ebenso viele, d.h. N GC-Threads verwendet; erkennt die JVM hingegen N > 8 verfügbare Prozessoren, so berechnet sich die Zahl der GC-Threads als 3+5N/8.

Die Standardeinstellung macht vor allem dann Sinn, wenn die JVM die zur Verfügung stehenden Prozessoren exklusiv nutzt. Laufen jedoch mehrere JVMs oder noch andere CPU-hungrige Systeme auf dem gleichen Rechner, so sollte -XX:ParallelGCThreads verwendet werden, um die Anzahl der GC-Threads geeignet zu reduzieren. Laufen beispielsweise vier Server-JVMs auf einem System mit 16 Prozessorkernen, so ist -XX:ParallelGCThreads=4 ein sinnvoller Startwert, damit sich die JVMs nicht zu sehr ins Gehege kommen.

-XX:-UseAdaptiveSizePolicy

Die Throughput-Kollektoren bieten einen interessanten (bei modernen JVMs aber nicht unüblichen) Mechanismus an, um die Benutzerfreundlichkeit beim GC-Tuning zu erhöhen. Dieser Mechanismus ist Bestandteil der als „Ergonomics“ bezeichneten Automatismen, die mit Java 5 für die HotSpot-JVM eingeführt wurden. Der Garbage Collector erhält die Freiheit, an den Größen der verschiedenen Heap-Bereichen sowie an weiteren GC-Einstellungen selbstständig Anpassungen vorzunehmen, um die GC „besser“ zu machen. Was unter „besser“ zu verstehen ist, das kann der Benutzer mit den weiter unten vorgestellten Flags -XX:GCTimeRatio und -XX:MaxGCPauseMillis vorgeben.

Gut zu wissen ist, dass die automatische Heap-Anpassung des Garbage Collectors standardmäßig aktiviert ist – und das ist auch gut so, denn adaptives Verhalten ist generell eine der größten Stärken der JVM. Dennoch kommt es bisweilen vor, dass erfahrene Performance-Tuner so klare Vorstellungen von der Heap-Konfiguration einer vorliegenden Anwendung haben, dass sie sich nicht von der JVM daran „herumpfuschen“ lassen möchten. In solchen Fällen ist es dann ratsam, die Automatismen durch das Setzen von -XX:-UseAdaptiveSizePolicy auszuschalten.

-XX:GCTimeRatio

Mit -XX:GCTimeRatio=<value> machen wir der JVM eine Zielvorgabe für den zu erreichenden Durchsatz. Setzen wir -XX:GCTimeRatio=N, so berechnet sich die Zielvorgabe für den Anteil der Anwendungs-Threads an der Gesamtlaufzeit der Anwendung als N/(N+1). Beispielsweise fordern wir mit -XX:GCTimeRatio=9, dass die Anwendungs-Threads insgesamt mindestens 9/10 (und die GC-Threads entsprechend die verbleibenden 1/10) der Gesamtzeit für sich beanspruchen sollen. Die JVM wird dann basierend auf ihren Messungen zur Laufzeit versuchen, die Heap- und GC-Einstellungen so anzupassen, dass dieser Durchsatz erreicht wird. Der Standardwert für -XX:GCTimeRatio ist 99, d.h. die Anwendungs-Threads sollen mindestens 99 Prozent der Gesamtzeit erhalten.

-XX:MaxGCPauseMillis

Mit -XX:MaxGCPauseMillis=<value> geben wir der JVM einen Richtwert für die maximale Pausenzeit vor. Der Throughput-Kollektor berechnet dann zur Laufzeit der Anwendung Statistiken (konkret: einen gewichteten Mittelwert und die Standardabweichung) zu den auftretenden Pausenzeiten. Falls die Statistiken darauf hindeuten, dass eine Gefahr für das Auftreten zu hoher Pausenzeiten besteht, so passt die JVM die Heap- und GC-Einstellungen entsprechend an. Berechnet werden die Statistiken übrigens separat für die Young bzw. Old Generation GCs. Erwähnenswert ist noch, dass standardmäßig kein Maximalwert für die Pausenzeiten gesetzt ist.

Die Vorgabe für die Pausenzeit hat aus Sicht der JVM höhere Priorität als die Vorgabe für den Durchsatz. Die JVM versucht also zunächst, die gewünschte maximale Pausenzeit nicht zu überbieten. Ganz allgemein gilt jedoch, dass die JVM keine Garantie dafür geben kann, dass sie die Vorgaben zu Pausenzeiten bzw. Durchsatz tatsächlich immer einhalten wird.

Insgesamt ist davon auszugehen, dass eine sehr kleine Vorgabe für die Pausenzeiten die Anzahl der GCs deutlich erhöht und damit eine starke Beeinträchtigung des Durchsatzes zur Folge hat. Ich empfehle daher für Systeme, in denen geringe Pausenzeiten wichtiger als hoher Durchsatz sind (das gilt zum Beispiel für viele Webanwendungen), statt dem Throughput-Kollektor den CMS Garbage Collector einzusetzen und geeignet zu konfigurieren. Damit werden wir uns im nächsten Teil unserer Serie beschäftigen.

Patrick Peschlow

Dr. Patrick Peschlow ist Entwicklungsleiter bei der CenterDevice GmbH und verantwortlich für die Architektur sowie die technische Umsetzung der Anwendung. Es gibt Leute, die sagen, DevOps sei sein zweiter Vorname.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentare

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.