Angelika Langer - Training & Consulting
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | NEWSLETTER | CONTACT | Twitter | Lanyrd | Linkedin
 
HOME 

  OVERVIEW

  BY TOPIC
    JAVA
    C++

  BY COLUMN
    EFFECTIVE JAVA
    EFFECTIVE STDLIB

  BY MAGAZINE
    JAVA MAGAZIN
    JAVA SPEKTRUM
    JAVA WORLD
    JAVA SOLUTIONS
    JAVA PRO
    C++ REPORT
    CUJ
    OTHER
 

GENERICS 
LAMBDAS 
IOSTREAMS 
ABOUT 
NEWSLETTER 
CONTACT 
Java Performance - Gabarge Collector Tuning

Java Performance - Gabarge Collector Tuning
Java Performance: 
Garbage Collection

Das Tunen des Garbage Collectors

JavaSPEKTRUM, September 2006
Klaus Kreft & Angelika Langer

Dies ist das Manuskript eines Artikels, der im Rahmen einer Kolumne mit dem Titel "Effective Java" im JavaSPEKTRUM erschienen ist.  Die übrigen Artikel dieser Serie sind ebenfalls verfügbar ( click here ).

In unserer Artikelreihe über Performance Tuning wollen wir uns diesmal ansehen, wie man den Garbage Collector konfigurieren kann, so dass er die Performance einer Java-Anwendung möglichst wenig belastet.  Als Vorbereitung haben wir uns im  letzten Beitrag angesehen, wie Garbage Collection prinzipiell funktioniert.  Wir haben erläutert, dass der Heap in eine Young Generation und eine Old Generation aufgeteilt ist.  Die beiden Generationen werden mit unterschiedlichen Garbage Collection Algorithmen aufgeräumt.  Diese Algorithmen können über JVM-Optionen beeinflusst werden. Wir wollen in diesem Artikel am Beispiel der Sun JVM besprechen, wie man JVM-Optionen nutzen kann, um durch Konfiguration der Garbage Collector Algorithmen eine Performance-Verbesserung zu erzielen.
 

Ziele des Garbage Collector Tunings

Die Verbesserung der Performance ist nicht das einzige Ziel, dass beim Tuning des Garbage Collectors verfolgt werden kann.  Zwar ist es das Ziel, das uns im Zusammenhang mit Performance-Tuning am meisten interessiert.  Aber es kann ja auch sein, dass eine Anwendung mit möglichst wenig Speicher auskommen muss.  Dann wäre das primäre Ziel eines Garbage Collector Tunings nicht die Verbesserung der Ablauf-Performance, sondern die Reduzierung des Speicherverbrauchs.  Dafür ist eine ganz andere Konfiguration des Garbage Collectors erforderlich, wie wir später sehen werden.  Ehe man also mit dem Konfigurieren des Garbage Collectors beginnen kann, muss man sich überlegen, was überhaupt erreicht werden soll.

Prinzipiell gibt es die folgenden vier Ziele:

  • Maximaler Durchsatz. Dabei geht um die Reduzierung der Zeit, die mit Garbage Collection verbraucht wird.  Das ist das klassische Performance-Ziel. Wenn weniger Zeit mit der Nebentätigkeit „Garbage Collection“ verbracht wird und mehr Zeit in der eigentlichen Anwendung, dann wirkt sich dies positiv auf die Ablauf-Performance des Programms aus.  Die Maximierung des Durchsatzes ist typischerweise wichtig in Programmen, die hauptsächlich Hintergrundsverarbeitung machen und keine Interaktion mit dem Benutzer haben.
  • Minimale Pause. Ein ganz anderes Ziel ist die Reduzierung der Pausen, die durch die Garbage Collection verursacht werden.  Wir haben im letzten Artikel erläutert, dass für gewisse Phasen der Garbage Collection die Anwendung komplett gestoppt werden muss, so dass der Garbage Collector ganz allein und ungestört auf dem Heap arbeiten kann.  Solche Pausen sind unangenehm in Programmen mit Benutzer-Interaktion, weil die Anwendung plötzlich für die Zeit der Garbage Collection stehen bleibt und nicht mehr auf den Benutzer reagiert.  Die Länge der Pausen ist noch weit problematischer in Anwendungen mit nahezu Echtzeit-Anforderungen.  Wenn ein Request innerhalb einer vorgegebenen Zeitspanne beantwortet werden muss, dann ist es schwierig, dies zu garantieren, wenn jederzeit die Anwendung durch die Garbage Collection für unbestimmte Zeit gestoppt werden kann. In solchen Situationen ist ein Tuning in Hinblick auf die Pausenzeiten nötig.
  • Minimaler Speicherverbrauch . Die Reduktion des Memory Footprints ist wichtig in Anwendungen mit beschränktem physikalischem Speicher, beispielsweise in PDAs.  Hier geht es darum, mit möglichst wenig Speicher auszukommen.  Das lässt sich erreichen durch besonders häufiges Aufräumen des Heaps.  Die vermehrten Garbage Collections reduzieren aber natürlich den Durchsatz.  Unterm Strich bedeutet es, dass die beiden Ziele Durchsatzmaximierung und Speicherverbrauchsminimierung nicht gleichzeitig verfolgt werden. Man kann bestenfalls nur das eine gegen das andere eintauschen.
Wir werden uns im Folgenden mit der Maximierung des Durchsatzes befassen.  Die Minimierung der Pausen und des Speicherverbrauchs werden wir am Rande erwähnen.

Garbage Collector Profiling

Ehe wir die Garbage Collection in irgendeine Richtung optimieren können, müssen wir uns natürlich erst einmal Information darüber verschaffen, wie hoch der Durchsatz ist, wie lange die Pausen sind und wie viel Speicher verbraucht wird.  Mit anderen Worten, wir müssen ein Profiling der Garbage Collection vornehmen, damit wir den Erfolg unseres Tunings kontrollieren können.

Im einfachsten Fall kann man sich Informationen über die Garbage Collection direkt von der JVM holen, indem die Option –verbose:GC gesetzt wird. Man erhält dann Ausgaben wie in Abbildung 1 zu sehen. In Java 5.0 gibt es zusätzlich noch die Option –XX:+PrintGCDetails, die noch mehr Informationen ausgibt.


Abbildung 1: JVM-Ausgaben mit Option –verbose:GC

Die Optionen –verbose:GC und –XX:+PrintGCDetails sind die am häufigsten verwendeten Optionen für das Garbage Collection Tracing.  Es gibt eine Reihe weiterer Optionen wie z.B. -XX:+PrintGCTimeStamps, -XX:+PrintHeapAtGC, -XX:+PrintTenuringDistribution.  Eine Übersicht über die Optionen der JVM findet man in / MOCK /.

Aber allein die Informationen von –verbose:GC und –XX:+PrintGCDetails zu analysieren ist recht mühselig.  Deshalb verwenden wir ein Tool, GCViewer, das die Ausgaben der JVM graphisch aufbereitet (siehe Abbildung 2).  GCViewer ist frei verfügbar (siehe / GCVIEW /).  Wir haben für die Screenshots in diesem Artikel eine ältere Version des GCViewers verwendet.  Die neueste Version sieht etwas anders aus und ist Java-5.0-fähig, d.h. sie versteht auch den Output, den die JVM 5.0 über die Option –XX:+PrintGCDetails produziert.

Man kann in Java 5.0 auch alternativ mit JConsole arbeiten (siehe / JCONS /).  Das ist ein Tool, dass mit dem JDK 5.0 ausgeliefert wird und die Standard Management Beans der JVM verwendet.  Eine weitere Alternative ist das Tool VisualGC (siehe / VISGC /).  Es ist ein experimentelles Tool von Sun für das Monitoring des Garbage Collectors.  Die beiden letztgenannten Tools zeigen zwar sehr anschaulich die Entwicklung der verschiedenen Bereiche des Heaps, liefern aber keine oder keine ausreichende Statistik.  Zeitmessungen und die Durchsatzberechnung muss man bei beiden Tools selber machen. GCViewer hingegen liefert bereits recht brauchbare Statistiken fürs Tuning.


Abbildung 2 Darstellung der mit –verbose:GC erzeugten  JVM-Ausgaben im GCViewer-Tool

Die verschiedenen Linien in der graphischen Darstellung geben den Speicherverbrauch und die Pausenlängen an.

  • Die rote Linie zeigt den von der JVM reservierten Speicher an.  Man kann sehen dass er im obigen Beispiel über die Zeit anwächst.
  • Die blaue Linie gibt den belegten Speicher an.  Die Zickzacklinie kommt dadurch zustande, dass nach jeder Garbage Collection der belegte Speicher reduziert ist, danach langsam wieder ansteigt, bis die nächste Garbage Collection wieder Speicher freigibt.
  • Die grüne Linie zeigt die Pausenlänge an.  Man kann deutlich den Unterschied zwischen den häufigen kurzen Minor Garbage Collections und den seltenen langen Major Garbage Collections erkennen.
Das Statistikfeld unten rechts liefert Angaben über die Pausenlängen, den Speicherverbrauch und den Durchsatz.  In unserem Beispiel fehlen einige Zahlen.  Das liegt daran, dass wir die Daten mit einer JVM 1.3.1 erhoben haben.  Bei dieser alten JVM enthält der JVM-Output keine Zeitstempel, so dass gewisse Werte einfach nicht berechnet werden können.  Seit Java 1.4.0 gibt es diese Zeitstempel aber; sie können über die Option -XX:+PrintGCTimeStamps eingeschaltet werden.

Das Arbeiten mit dem GCViewer funktioniert so, dass man seine Anwendung in einer JVM startet, die mit –verbose:GC aufgerufen wurde.  Wahlweise können auch noch die Optionen -XX:+PrintGCDetails und -XX:+PrintGCTimeStamps angeben werden, um mehr Information zu bekommen.  Die Ausgaben der JVM leitet man sinnvollerweise in eine Datei um.  Dafür gibt es die Option -Xloggc=<filename>.  Dann startet man den GCViewer und lädt die Datei mit den Ausgaben der JVM.  Dann erhält man eine graphische Darstellung wie in Abbildung 2 gezeigt.
 

Garbage Collector Tuning

Nach diesen Vorbereitungen wollen wir uns nun dem eigentlichen Tuning zuwenden.  Wir wollen eine Reihe von Strategien für das Tuning besprechen.  Dabei werden wir uns auf Tuningmaßnahmen konzentrieren, die der Maximierung des Durchsatzes dienen.

Die Größe des Heaps und der Generationen

Wie wir im letzten Artikel erläutert haben, ist der Heap in eine Young und eine Old Generation unterteilt (siehe Abbildung 3).  Alle neu erzeugten Objekte werden im Eden-Bereich der Young Generation angelegt.  Sie werden von dort aus in einen der Survivor-Bereiche kopiert, dort eine Weile zwischen den beiden Survivor-Bereichen hin- und herkopiert, und am Ende in die Old Generation verschoben.  Mit steigendem Alter wandern die Objekte also von der Young Generation in die Old Generation.


Abbildung 3: Aufteilung des Freispeichers in Generationen

Die Größen der verschiedenen Generationen haben Einfluss auf die Effizienz der Garbage Collection.  Wenn beispielsweise ein Programm ungewöhnlich viele kurzlebige Objekte erzeugt, dann kann es sein, dass die Young Generation zu klein ist.  Der Garbage Collector muss dann sehr viele Minor Collections machen, weil Eden andauernd voll ist.  Bei den Minor Collections wird es relativ häufig passieren, dass die Young Generation überläuft, weil sie zu knapp bemessen ist.  Dann müssen die überlebenden Objekte in die Old Generation kopiert werden.  Dadurch wird die Old Generation belastet, was zu vermehrten Major Collections führt.  Insgesamt keine günstige Situation, was den Durchsatz angeht. Eine Vergrößerung der Young Generation würde die Situation verbessern, weil der Garbage Collector dann nicht so viele Minor Garbage Collections machen müsste, weil die Young Generation einfach nicht so schnell voll würde. Es geht also beim Tuning mit dem Ziel der Durchsatzmaximierung darum, die Generationsgrößen so zu wählen, dass der Garbage Collector möglichst wenig zu tun hat.

Wir wollen das Vorgehen zur Einstellung der Generationsgrößen am Beispiel einer Server-Anwendung demonstrieren, die wir mit einer Sun JVM Version 1.3.1 ablaufen lassen.  Die Anwendung liest Daten aus einer XML-Datei in einer relationale Datenbank und braucht dafür ungefähr 13 Minuten.  Abbildung 4 zeigt die Profilingdaten des Garbage Collectors vor dem Tuning.  Die Ablaufzeit haben wir anderweitig gemessen; sie betrug 770 sek.  Das ergab einen Durchsatz von 96,18%.


Abbildung 4: Ausgangssituation vor dem Tuning

Wenn man sich die Graphik ansieht, stellt man fest, dass das Programm bei den ersten Major Garbage Collections jedes Mal den Heap vergrößert, bis sich schließlich die Heapgröße bei ca. 7700 kB einpendelt.  Offenbar braucht unser Programm diese Speichermenge und fängt eigentlich mit einem viel zu kleinen Heap an.  Das ist typisch für größere Anwendungen.  Die Defaultgröße des Heaps ist häufig zu klein.  Der Default ist plattformabhängig und betrug in unserem Falle 3584 kB, also nur etwa die Hälfte dessen, was gebraucht wurde.

Heapgröße

Die erste Tuningmaßnahme in einem solchen Fall ist die Vergrößerung des Heaps, so dass das Programm von Anfang an mit genügend viel Speicher losläuft.  Das erspart dem Garbage Collector die ersten Major Garbage Collections und die schrittweise Erhöhung des Heaps.

Für die Konfiguration der Heapgröße sind folgende Optionen relevant:

-Xms<value> minimale Heapgröße
-Xmx<value>  maximale Heapgröße
Beispiel:
java -Xms7168k -Xmx128m ...
Hier würde die Untergrenze auf 7168 kB und die Obergrenze auf 128 MB eingestellt.  In den Größenangaben stehen k, m und g für Kilobyte, Megabyte und Gigabyte.  Die Defaultwerte sind in 1.4 JVM-abhängig.  Um ein Beispiel zu nennen: Sun gibt an, dass die Defaultwerte in einer JVM 1.4 auf einem 32-bit Solaris System 3670 kB und 64 MB sind.  In Java 5.0 sind die Defaults nicht mehr JVM-abhängig, sondern werden abhängig von der Hardware-Ausstattung dynamisch bestimmt.  Als Anfangsgröße für den Heap wird ein Vierundsechzigstel des physikalischen Speichers genommen; die Maximalgröße liegt bei einem Viertel des physikalischen Speichers.  Diese Werte kann man über Optionen (-XX:DefaultInitialRAMFraction =<nnn> und -XX:DefaultMaxRAMFraction=<nnn>) ändern; dann liegen die Größen bei physikalischer Speicher / DefaultInitialRAMFraction  bzw. physikalischer Speicher / DefaultMaxRAMFraction.  Die Größenangabe per –Xms<value> und –Xmx<value> Option überschreiben diese Defaulteinstellungen.

In unserem Tuning-Beispiel waren wir sehr großzügig und haben unserem Programm 48 MB Speicher spendiert.  Das hat den Durchsatz auf von 96,18 % auf 99,46 % erhöht, die Ablaufzeit von 770 sek auf 745 sek gesenkt und die Pausen sind deutlich kürzer ausgefallen.

Das ist ein typischer Effekt.  Ganz generell erreicht man eine spürbare Verbesserung von Durchsatz und Pausenzeiten, wenn man der Anwendung möglichst viel Speicher zur Verfügung stellt.  Das geht natürlich nicht unbegrenzt.  Man muss dabei den physikalisch verfügbaren Speicher im Auge behalten.  Wenn man der Anwendung zu viel Speicher zuteilt, dann muss das Betriebsystem Teile des Speichers in einen Swap-Bereich auslagern und das Swapping kann die erreichte Performance-Verbesserung wieder zunichte machen. Außerdem besteht die Gefahr, dass die Pausenzeiten drastisch steigen, wenn die Anwendung den vielen Speicher auch tatsächlich ausnutzt.  Dann muss der Garbage Collector nämlich wesentlich größere Speicherbereiche aufräumen, was zu entsprechend langen Pausen führt.  Man bezahlt die Durchsatzverbesserung durch einen großen Heap mit einem entsprechend großen Memory Footprint und tendenziell längeren Pausen.

Für das Tuning empfiehlt es sich übrigens, die Ober- und Untergrenze für die Heapgröße auf den gleichen Wert zu setzen.  Dann kann man den Effekt anderer Einstellungen besser beobachten, weil sie nicht durch die Größenanpassungen des Garbage Collectors überlagert werden.  Für die echte Konfiguration der Anwendung ist eine fixe Heapgröße allerdings nicht empfehlenswert, weil sie dem Garbage Collector die Möglichkeit nimmt, auf unvorhergesehene Situationen zu reagieren.

Größe von Young Generation vs. Old Generation

Wie schon oben erwähnt, kann man nicht nur die Gesamtgröße des Heaps einstellen, sondern es kann auch die Größe jeder einzelnen Generation separat konfiguriert werden.  Auch darüber kann Einfluss auf den Durchsatz genommen werden. Eine große Young Generation führt beispielsweise zu selteneren Minor Garbage Collections und damit tendenziell zu höherem Durchsatz.  Gleichzeitig bedeutet eine große Young Generation aber auch, dass die Old Generation dann im Verhältnis zur Young Generation kleiner ausfällt.  Das könnte zu vermehrten Major Garbage Collections führen und damit tendenziell zu einem schlechteren Durchsatz.  Ob sich insgesamt durch Änderung der Generationsgrößen ein positiver oder negativer Effekt ergibt, hängt von der Altersverteilung der Objekte in der Anwendung ab.  Eine Anwendung mit vielen kurzlebigen Objekten wird tendenziell eine große Young Generation brauchen.  Eine eher speicher-residente Anwendung mit überwiegend langlebigen Objekten wird hingegen eine große Old Generation brauchen.

Wie groß sollte nun die Young Generation sein, und wie klein darf die Old Generation werden?  Idealerweise sollte die Young Generation, also Eden und die beiden Survivor–Bereiche, so groß sein, dass eine Minor Garbage Collection innerhalb der Young Generation stattfinden kann, ohne dass die Old Generation gebraucht wird.  Wir haben im letzten Artikel erläutert, dass die Minor Garbage Collections auf der Young Generation mit einem Copy-Algorithmus gemacht werden.  Einer der beiden Survivor-Bereiche ist immer leer und steht bereit, um die Überlebenden aus Eden und dem aktiven Survivor-Bereich aufzunehmen.  Der Garbage Collector sucht also zuerst nach lebendigen Objekten in Eden und dem aktiven Survivor-Bereich und kopiert sie anschließend in den bereitstehenden leeren Survivor-Bereich.

Damit eine Minor Garbage Collection innerhalb der Young Generation ablaufen kann, müssen alle lebenden Objekte in Eden und dem aktiven Survivor-Bereich in den bereitstehenden leeren Survivor-Bereich passen. Wenn der leere Survivor-Bereich zu klein dafür ist, dann werden die lebenden Objekte gleich in die Old Generation kopiert.  Dazu muss aber die Old Generation so groß sein, dass noch genügend Platz für die lebenden Objekte aus der Young Generation ist.  Wenn auch das nicht der Fall ist, dann ist eine Major Garbage Collection nötig, um erst einmal  Platz in der Old Generation zu schaffen.

Man darf die Young Generation also nicht zu groß wählen.  Eine Young Generation, die beispielsweise 50% des gesamten Heaps beansprucht, ist Unfug.  Denn dann würden zwar nur ganz selten Minor Garbage Collections gemacht, aber es würde häufig vorkommen, dass eine Minor Garbage Collection eine Major Garbage Collection auslöst, weil einfach nicht genug Platz in der Old Generation für die Überlebenden aus der Young Generation ist.  Das ist kontraproduktiv, weil praktisch jede Collection eine Major Collection wäre.  Das führt die Idee der Generational Garbage Collection ad absurdum, weil man die Generationen überhaupt nicht mehr braucht.
Prinzipiell macht es Sinn, im Rahmen eines Tunings die Young Generation zu vergrößern und auszuprobieren, ob der Durchsatz dadurch besser wird.

Das Verhältnis von Young zu Old Generation kann man mit der Option -XX:NewRatio=<ratio> einstellen.

Beispiel:

java –XX:NewRatio=3 ...
Hier wäre das Verhältnis von Young Generation (Eden und die beiden Survivor-Bereiche) zur Old Generation 1:3, d.h. die Young Generation nimmt ein Viertel des Heaps (abzüglich des Perm-Bereichs) ein.  Die Default-Einstellung auf einem 32-bit Solaris System ist 2 für die Server-VM und 8 für die Client-VM.

Die Größe der Young Generation kann auch absolut angegeben werden mit den folgenden Optionen:

-XX:NewSize=<value>     minimale Größe der Young Generation
-XX:MaxNewSize=<value>  maximale Größe der Young Generation
-Xnm<value>             fixe Größe der Young Generation
Beispiel:
java -XX:NewSize=16m -XX:MaxNewSize=16m ...
Die Default-Einstellungen auf einem 32-bit Solaris System sind 2172 kB als Untergrenze und nach oben unbegrenzt.

Darüber hinaus gibt eine Optionen, um die Größe der Survivor-Bereiche einzustellen (-XX:SurvivorRatio=<ratio>).  Man kann festlegen, wie oft die Objekte zwischen den Survivor-Bereichen hin- und herkopiert werden sollen, ehe sie in die Old Generation verschoben werden (-XX:MaxTenuringThreshold=<value>).  Man kann die anfängliche und maximale Größe des Perm-Bereichs festlegen ( -XX:PermSize=<value> und -XX:MaxPermSize=<value>). Und einiges mehr.

Das Konfigurieren der Survivor-Bereiche und des Copy-Vorgangs und die Einstellung der Perm-Größe bringen aber in der Praxis meistens nicht viel.  Sie helfen in speziellen Situationen, z.B.

  • wenn die Anwendung ungewöhnlich viele Klassen lädt, so dass der Perm-Bereich größer sein muss als üblich, oder
  • bei Anwendungen, die keine Objekte mittlerer Lebensdauer produzieren, so dass das Hin- und Herkopieren zwischen den Survivor-Bereichen überflüssig ist.
Für die meisten Anwendungen ist es jedoch wichtiger, die richtige Größe für den Heap insgesamt zu finden und ggf. die Young Generation zu vergrößern, damit die Objekte bereits in der Young Generation sterben statt mit erhöhtem Aufwand erst in der Old Generation weggeräumt zu werden.
 

Parallele und Konkurriende Garbage Collection

Für Anwendungen, die auf Multiprozessor-Plattformen ablaufen sollen, sind die parallelen und konkurrierenden Garbage Collectoren von Interesse, die es seit dem JDK 1.4 gibt.

Wir hatten im letzten Artikel erläutert, dass es für die Young Generation parallele Garbage Collection gibt, bei der die Garbage Collection in mehreren Threads abläuft, aber nicht konkurrierend zur Anwendung.  Die muss immer noch angehalten werden.  Die parallele Garbage Collection kann auf einer Multiprozessor-Maschine einen höheren Durchsatz erzielen, weil sie die zur Verfügung stehenden Prozessoren besser ausnutzt als die serielle Garbage Collection in nur einem Thread.

Wir haben außerdem im letzten Artikel über die konkurrierende Garbage Collection auf der Old Generation gesprochen.  Der Concurrent Garbage Collector läuft quasi-konkurrierend zur Anwendung.  Es gibt kurze nicht-konkurrierende Phasen, aber ein großer Teil der Garbage Collection auf der Old Generation kann erledigt werden, ohne die Anwendung anzuhalten.  Konkurrierende Garbage Collection ist natürlich auf einer Multiprozessor-Architektur interessanter als auf einer Single-Prozessor-Maschine, wo die Konkurrenz nur zusätzlichen Synchronisationsaufwand kostet, aber nichts bringt.  Das primäre Ziel der Concurrent Garbage Collection ist aber die Reduktion der Pausen und nicht die Durchsatzerhöhung.  Der Durchsatz kann sogar heftig beeinträchtigt werden durch die höhere Komplexität der Algorithmen.

In der Old Generation gibt es außerdem noch einen inkrementellen Modus, bei nicht die gesamte Old Generation auf einmal aufgeräumt, sondern immer nur kleine Häppchen.  Auch hier ist das Ziel die Pausenreduktion auf Kosten des Durchsatzes.

Für die Durchsatzerhöhung ist also in erster Linie die parallele Garbage Collection auf der Young Generation relevant.  Sie ist in der JVM 1.4 optional und kann per -XX:+UseParallelGC eingeschaltet werden.  Dieser parallele Algorithmus funktioniert aber nur zusammen mit der nicht-konkurrierenden Garbage Collection auf der Old Generation.  Wenn die parallele Young Generation Garbage Collection zusammen mit der konkurrierenden Old Generation Garbage Collection verwendet werden soll, dann muss die Kombination -XX:+UseParNewGC (für die parallele GC) und -XX:+UseConcMarkSweepGC (für die konkurrierende GC) verwendet werden.  In der JVM 5.0 ist die parallele Garbage Collection per Default eingestellt.

Die parallele Garbage Collection zahlt sich nur auf einer Multiprozessor-Maschine wirklich aus und ist auf einer Single-Prozessor-Maschine kontraproduktiv.  Die parallele Garbage Collection kann auch noch konfiguriert werden.  Man kann die Anzahl der Threads einstellen (-XX:ParallelGCThreads=<number>). Defaultmäßig ist die Zahl der Threads so eingestellt, dass sie der Zahl der Prozessoren entspricht.  Man kann den Durchsatz leicht verbessern, wenn man die Zahl der Threads für die parallele Garbage Collection etwas senkt, wenn die Prozessoren auf dem System ohnehin schon von anderen Anwendungen heftig in Anspruch genommen werden und gar nicht für die Garbage Collection zur Verfügung stehen.

Die parallele Garbage Collection kann dynamisch die Größe der Young Generation und der Survivor-Bereiche anpassen mit dem Ziel, die Pausenzeiten zu reduzieren und den Durchsatz zu erhöhen.  Dies geschieht über die Option -XX:+UseAdaptiveSizePolicy.  Diese Option für die parallele Garbage Collection sollte eigentlich immer verwendet werden, es sei denn, die Größen der Bereiche werden explizit manuell gesetzt.  Sie steht aber nur für den -XX:+UseParallelGC Collector zur Verfügung und kann bei -XX:+UseParNewGC nicht benutzt werden.

Adaptive Garbage Collectoren

Wir haben in uns jetzt einen Teil der Tuning-Optionen angesehen und dabei längst nicht alle Optionen erwähnt, lediglich die für die Durchsatzmaximierung wesentlichen.  Weil das ganze Konfigurieren erstens mühselig und zweitens statisch ist, gibt es in Java 5.0 Optionen, bei denen lediglich ein Ziel vorgegeben wird, und der Garbage Collector stellt sich dann dynamisch so ein, dass das Ziel möglichst erreicht wird.  Zu diesem Zweck macht die JVM ein Profiling des Garbage Collectors und vergleicht nach jeder Garbage Collection, ob das Ziel erreicht wurde. Wenn nicht, dann werden die Größen der Generationen oder andere Einstellungen entsprechend angepasst. Die interessanten Ziele sind wieder Durchsatz, Pausenlänge und Speicherverbrauch.

Das Durchsatzziel wird mit der Option -XX:GCTimeRatio=<nnn> spezifiziert.  Damit wird das Verhältnis zwischen Zeitverbrauch in der Garbage Collection und Zeitverbrauch in der Anwendung vorgegeben.  Für die Garbage Collection soll maximal 1 / (1 + <nnn>) der Gesamtzeit aufgewandt werden.

Beispiel:

java -XX:GCTimeRatio=19 ...
Für die Garbage Collection soll höchstens 5% der Zeit verwendet werden.  Der Default ist bei 1%.

Das Pausenziel wird mit der Option -XX:MaxGCPauseMillis=<nnn> eingestellt.  Hier versucht der Garbage Collector die maximale Pausenzeit geringer als <nnn> Millisekunden zu halten.  Defaultmäßig ist kein Pausenziel vorgegeben. Das Erreichen kurzer Pausenzeiten kann natürlich auf Kosten des Durchsatzes gehen.  Da stellt sich die Frage, was passiert, wenn sowohl ein Durchsatzziel als auch ein Pausenziel vorgegeben wird.  Die Ziele sind priorisiert:  der Garbage Collector versucht als erstes das Pausenziel zu erreichen, danach das Durchsatzziel, und erst am Ende das Speicherverbrauchsziel.

Das Speicherverbrauchsziel kann ohnehin nicht explizit angegeben werden.  Geringer Speicherverbrauch ist ein implizites Ziel.  Der Garbage Collector versucht generell, die übrigen Ziele mit dem geringstmöglichen Speicherverbrauch zu erreichen.

Man kann Einfluss nehmen auf den Umfang der dynamischen Anpassungen nach jeder Garbage Collection.  Mit den Optionen -XX:YoungGenerationSizeIncrement=<nnn> und -XX:TenuredGenerationSizeIncrement=<nnn> kann spezifiziert werden, um wie viel Prozent die Young Generation bzw. die Old Generation bei einer Anpassung wachsen darf.  Der Default ist 20%.  Für das Verkleinern wird ein Faktor angegeben über die Option -XX:AdaptiveSizeDecrementScaleFactor=<nnn>. Wenn eine Generation um xxx Prozent wachsen darf, dann darf sie um xxx / AdaptiveSizeDecrementScaleFactor verkleinert werden.  Der Defaultfaktor ist 4.

Tuning-Beispiele

Zum Abschluss noch einige Beispiele für ein Tuning mit dem Ziel der Durchsatz-Maximierung.  Für eine Server-Anwendung auf einem Multi-Prozessor-System mit 4 GB Speicher, auf dem 32 Threads gleichzeitig laufen können, wäre folgende Einstellung denkbar:
java -Xmx3800m -Xms3800m
     -XX:+UseParallelGC -XX:ParallelGCThreads=20
     -XX:+UseAdaptiveSizePolicy
     -verbose:GC -XX:+PrintGCDetails
     -XX:+PrintHeapAtGC
     -XX:+PrintAdaptiveSizePolicy
     ...
Hier ist der  Heap mit 3800 MB groß gewählt (-Xmx3800m -Xms3800m), um vom reichlich vorhandenen RAM Gebrauch zu machen.  Die Young Generation wird mit paralleler Garbage Collection aufgeräumt (-XX:+UseParallelGC), wobei dafür 20 Threads verwendet werden (-XX:ParallelGCThreads=20).  Der Defaultwert hätte auf dem angenommenen System bei 32 Threads gelegen, was vermutlich unnötig hoch gewesen wäre.  Die Größe der Young Generation und der Survivor-Bereiche wird dynamisch bestimmt (-XX:+UseAdaptiveSizePolicy).  Die übrigen Optionen bestimmen den Inhalt und Umfang des Garbage Collection Traces (-verbose:GC -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintAdaptiveSizePolicy).

Hier ein weiteres Beispiel, bei dem neben einen hohen Durchsatz auch noch die Pausen gering gehalten werden sollen, indem die Old Generation konkurrierend aufgeräumt wird.

java    -Xmx3550m -Xms3550m -Xmn2g
        -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
        -XX:ParallelGCThreads=20
        -XX:SurvivorRatio=8
        -XX:TargetSurvivorRatio=90
        -XX:MaxTenuringThreshold=31
        ...
Hier wird der  Heap mit 3550 MB etwas kleiner gewählt als eben (-Xmx3550m -Xms35500m). Die Young Generation wird explizit auf 2 GB festgelegt (-Xmn2g) und ist damit recht groß.  Sie wird parallel mit 20 Threads (-XX:+UseParNewGC -XX:ParallelGCThreads=20) aufgeräumt.  Die Old Generation wird konkurrierend aufgeräumt (-XX:+UseConcMarkSweepGC), um die Leistung der vielen Prozessoren auch während der Old Generation Collection auszunutzen.  Man beachte, dass zusammen mit der konkurrierenden Garbage Collection nicht die adaptive parallele Garbage Collection über -XX:+UseParallelGC verwendet werden kann.   Deshalb wurde die Größe der Young Generation explizit angegeben.

Die Annahme für unser Tuning ist, dass die Server-Anwendung viele kurzlebige Objekte erzeugt, die möglichst alle in der Young Generation noch sterben sollen.  Dieses Ziel spiegelt sich in der Größe der Young Generation aber auch in den übrigen Einstellungen wider.  Die Survivor-Bereiche werden relativ groß gewählt (-XX:SurvivorRatio=8); sie nehmen jeweils 1/10 der Young Generation ein.  Die Survivor-Bereiche dürfen zu 90% gefüllt sein, ehe die Objekte in die Old Generation verschoben werden (-XX:TargetSurvivorRatio=90).  Damit werden die großen Survivor-Bereiche besser ausgenutzt; der Default wäre bei 50% gewesen.  Zusätzlich werden die Objekte 31mal zwischen den Survivor-Bereichen hin- und herkopiert, ehe sie in die Old Generation ausgelagert werden (-XX:MaxTenuringThreshold=31).  Damit haben die kurzlebigen Objekte mehr Zeit, in der Young Generation zu sterben.

Per Default wären die Objekte gar nicht kopiert worden, sondern sofort in die Old Generation verschoben worden.  Das liegt daran, dass bei der konkurrierenden Garbage Collection für die Old Generation die Defaults auf SurvivorRatio=1024 (d.h. winzige Survivor-Bereiche) und MaxTenuringThreshold=0 (d.h. sofortige Promotion in die Old Generation ohne irgendwelches Hin- und Herkopieren zwischen den Survivor-Bereichen) gesetzt sind.   Die Default-Annahme für die konkurrierende Garbage Collection ist offenbar, dass es hauptsächlich langlebige Objekte gibt, die am besten von der konkurrierenden Garbage Collection in der Old Generation aufgeräumt werden.   Das ist das glatte Gegenteil dessen, was wir für unsere Anwendung unterstellen.  Deshalb haben wir die Einstellungen komplett anders gewählt.

Das sind natürlich nur zwei von vielen möglichen Tunings.  Das Optimum zu finden, bedarf sorgfältiger Test mit möglichst repräsentativen Testumgebungen.
 

Schlussbetrachtung

Abschließend kann man sich nun noch die Frage stellen, wozu ein manuelles Tuning überhaupt noch nötig ist, wenn doch der Garbage Collector allerlei Strategien zur Selbst-Adaption bereithält.  Warum sollte man dem Garbage Collector nicht einfach ein Ziel vorgeben und ihn dann machen lassen?

In den meisten Fällen klappt die Selbstadaption ganz gut, aber eben nicht immer.  Die Selbstadaption beruht auf der Idee, dass aus den Daten der Vergangenheit Schlüsse für die Zukunft gezogen werden können.  Wenn die Anwendung bisher soundso viele Objekte dieser oder jener Lebensdauer erzeugt hat, dann wird sie das wohl auch im weiteren Verlauf so tun.  Unter dieser Annahme justiert der Garbage Collector die Generationengrößen und sonstigen Parameter, um die vorgegebenen Ziele zu erreichen.  Die Annahme konstanten Verhaltens wird auch häufig zutreffen, so dass positive Effekte eintreten.  Es kann aber auch schief gehen.

Eine Anekdote zur Illustration der Problematik haben wir in Jack Shirazi’s Java Performance Tuning Newsletter #61 vom Dezember 2005 (siehe / SHIRAZI /) gefunden.  Er berichtet dort von den Erfahrungen, die er bei der Portierung einer Anwendung von Java 1.4.1 nach Java 1.4.2 gemacht hat.  Bei der Portierung hat sich plötzlich die Performance der Anwendung spürbar verschlechtert.  Langes Suchen hat dann ergeben, dass der Effekt wegen einer nicht dokumentierten Änderung im Garbage Collector zustande gekommen war.  Eine Option, die in 1.4.1 noch optional gewesen war, war in 1.4.2 plötzlich defaultmäßig eingeschaltet.  Es ging um die Option –XX:+UseAdaptiveSizePolicy, die den Garbage Collector dazu veranlasst, die Größe der Young Generation dynamisch zu optimieren.  Diese gut gemeinte Selbstadaption hatte im geschilderten Fall recht negative Effekte.  Aus solchen Beobachtungen kann man schließen, dass adaptive Garbage Collection nicht in allen Fällen zu optimalen Ergebnissen führt.

In welchen Fällen eine adaptive Garbage Collection besser oder schlechter als ein manuelles Tuning ist, lässt sich kaum vorhersagen, weil die Adaptionsstrategien nicht dokumentiert sind.  Das muss man mit der jeweiligen Anwendung ausprobieren, genauso wie man alle anderen Einstellungen experimentell bestimmt.

Man kann aus der Anekdote noch eine weitere Lehre ziehen:  Man sollte generell Vorsicht walten lassen im Umgang mit den Garbage Collector Optionen.  Praktisch alle Optionen sind JVM-spezifisch, nicht zuverlässig dokumentiert, können sich jederzeit ändern oder auch von einer zur anderen JVM-Version verschwinden.  Ganz besonders unangenehm kann es werden, wenn sich die Defaults stillschweigend von einer zur anderen JVM-Version ändern, wie im geschildert Fall.  Deshalb empfiehlt es sich, Optionen stets explizit ein- und auszuschalten.  Einschalten geht mit –XX:+Option; Ausschalten mit –XX:-Option.  Und man sollte experimentelle Optionen möglichst ganz vermeiden.

Information über die JVM-Optionen für den Garbage Collector, von denen wir hier nur eine kleine Auswahl vorgestellt haben, findet man in / JDK4 / und / JDK5 /. Auch auf der GCViewer-Webseite / GCVIEW / gibt es eine gute, aber keineswegs vollständige Übersicht über die Optionen.  Die vermutlich umfangreichste Sammlung von JVM-Optionen hat Joe Mocker zusammengestellt (siehe / MOCKER /).
Zusammenfassung
In diesem Beitrag haben wir uns einige der Optionen angesehen, die die Sun JVMs 1.4. und 5.0 bieten, um durch Konfiguration der Garbage Collection die Performance einer Anwendung zu verbessern, indem die Zeit, die auf Garbage Collection verwendet wird, minimiert wird.  Die wesentlichen Maßnahmen sind

  • die Vergrößerung des Heaps sowie die Vergrößerung der Young Generation gegenüber der Old Generation,
  • die Verwendung von paralleler Garbage Collection, falls eine Multiprozessor-Plattform zur Verfügung steht, und
  • die Vorgabe eines Durchsatzziels für die adaptive Garbage Collection in Java 5.0.
Neben den besprochenen Tuning-Maßnahmen gibt es eine Reihe weiterer Techniken, insbesondere für die Minimierung der Pausenzeiten.  Eine vollständige Darstellung hätte den Rahmen dieses Beitrags bei weitem gesprengt.  Informationen zu den nicht besprochenen Techniken findet an in Artikeln und Beiträgen auf den Webseiten von Sun (siehe / JDK4 /, / JDK5 / und / WHITE /).
 

Literaturverweise und weitere Informationsquellen

/JDK4/ "Improving Java Application Performance and Scalability by Reducing Garbage Collection Times and Sizing Memory Using JDK 1.4.1"
Nagendra Nagarajayya and J. Steven Mayer, November 2002 
URL: http://developers.sun.com/techtopics/mobility/midp/articles/garbagecollection2/
/JDK5/ Tuning Garbage Collection with the 5.0 JavaTM Virtual Machine
URL: http://java.sun.com/docs/hotspot/gc5.0/gc_tuning_5.html
/WHITE/  Sun's "Java Tuning White Paper"
URL: http://java.sun.com/performance/reference/whitepapers/tuning.html
/SHIRAZI/ Java Performance Tuning Newsletter #61
Jack Shirazi, December 2005 
URL: http://www.javaperformancetuning.com/news/news061.shtml
/MOCKER/  Joseph D. Mocker's Collection of  JVM Options
URL: http://blogs.sun.com/roller/resources/watt/jvm-options-list.html

Since the above mentioned link is dead meanwhile (as of January 2016) you might want to use the one below instead.  My thanks to André Treffeisen for bringing the dead link to my attention and suggesting an alternative.

The most complete list of -XX options for Java JVM by Stanislav Kobylansky 
URL: http://stas-blogspot.blogspot.co.at/2011/07/most-complete-list-of-xx-options-for.html

/GCVIEW/  GCViewer
URL: http://www.tagtraum.com/gcviewer.html
/JCONS/
Using jconsole
URL: http://java.sun.com/j2se/1.5.0/docs/guide/management/jconsole.html
/VISGC/  VisualGC
URL: http://java.sun.com/performance/jvmstat/visualgc.html

Die gesamte Serie über Java Performance:

/KRE1/  Java Performance, Teil 1: Was ist ein Micro-Benchmark?
Klaus Kreft & Angelika Langer
Java Spektrum, Juli 2005
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/21.MicroBenchmarking/21.MicroBenchmarking.html
/KRE2/  Java Performance, Teil 2: Wie wirkt sich die HotSpot-Technologie aufs Micro-Benchmarking aus?
Klaus Kreft & Angelika Langer
Java Spektrum, September 2005
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/22.JITCompilation/22.JITCompilation.html
/KRE3/  Java Performance, Teil 3: Wie funktionieren Profiler-Tools?
Klaus Kreft & Angelika Langer
Java Spektrum, November 2005
URL:  http://www.AngelikaLanger.com/Articles/EffectiveJava/23.ProfilingTools/23.ProfilingTools.html
/KRE4/ Java Performance, Teil 4: Performance Hotspots - Wie findet man funktionale Performance Hotspots?
Klaus Kreft & Angelika Langer
Java Spektrum, Januar 2006
URL:  http://www.AngelikaLanger.com/Articles/EffectiveJava/24.FunctionalHotSpots/24.FunctionalHotSpots.html
/KRE5/ Java Performance, Teil 5: Performance Hotspots - Wie findet man Memory Hotspots?
Klaus Kreft & Angelika Langer
Java Spektrum, März 2006
URL:  http://www.AngelikaLanger.com/Articles/EffectiveJava/25.MemoryHotSpots/25.MemoryHotSpots.html
/KRE6/ Java Performance, Teil 6: Garbage Collection - Wie funktioniert Garbage Collection?
Klaus Kreft & Angelika Langer
Java Spektrum, Mai/Juli 2006
URL:  http://www.AngelikaLanger.com/Articles/EffectiveJava/26.GarbageCollection/26.GarbageCollection.html
/KRE7/  Java Performance, Teil 7: Garbage Collection - Das Tunen des Garbage Collectors
Klaus Kreft & Angelika Langer
Java Spektrum, September 2006
URL:  http://www.AngelikaLanger.com/Articles/EffectiveJava/27.GCTuning.html/27.GCTuning.html
 

If you are interested to hear more about this and related topics you might want to check out the following seminar:
Seminar
 
High-Performance Java - programming, monitoring, profiling, and tuning techniques
4 day seminar ( open enrollment and on-site)
 
  © Copyright 1995-2016 by Angelika Langer.  All Rights Reserved.    URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/27.GCTuning/27.GCTuning.html  last update: 4 Jan 2016