Java Virtual Machine und Container: ein Dream Team mit Startschwierigkeiten

Seite 3: Wie sich die JVM der Umgebung anpasst

Inhaltsverzeichnis

Bevor sich Container als Deployment-Methode etabliert hatten, war die JVM eine willkommene Möglichkeit, ein und dieselbe Java-Applikation portabel auf Windows- oder Linux-Plattformen zu betreiben. Zu dieser Zeit waren große Java-Client-Applikationen noch stärker verbreitet als heutzutage. Java-Anwendungen mussten auf unterschiedlichster Hardware und parallel zu anderen Applikationen einsetzbar sein. Um die knappen Ressourcen effizienter teilen zu können, überprüft die JVM beim Starten den Umfang der insgesamt verfügbaren Ressourcen und konfiguriert den eigenen Ressourcenbedarf wie etwa für die Heapgröße entsprechend. Die Standardeinstellungen lassen sich dabei über verschiedene Programmparameter überschreiben und steuern.

Dieses Anpassen der JVM an die begrenzten Hardwareressourcen bezeichnet man als Ergonomics.

Folgende Einstellungen der JVM werden durch die Hardware bestimmt:

java -XX:+PrintFlagsFinal -version | grep ergonomic
  intx CICompilerCount            = 4          {ergonomic}
  uint ConcGCThreads              = 2          {ergonomic}
  uint G1ConcRefinementThreads    = 8          {ergonomic}
size_t G1HeapRegionSize           = 2097152    {ergonomic}
 uintx GCDrainStackTargetSize     = 64         {ergonomic}
size_t InitialHeapSize            = 268435456  {ergonomic}
size_t MarkStackSize              = 4194304    {ergonomic}
size_t MaxHeapSize                = 4294967296 {ergonomic}
size_t MaxNewSize                 = 2575302656 {ergonomic}
size_t MinHeapDeltaBytes          = 2097152    {ergonomic}
size_t MinHeapSize                = 8388608    {ergonomic}
 uintx NonNMethodCodeHeapSize     = 5839372    {ergonomic}
 uintx NonProfiledCodeHeapSize    = 122909434  {ergonomic}
 uintx ProfiledCodeHeapSize       = 122909434  {ergonomic}
 uintx ReservedCodeCacheSize      = 251658240  {ergonomic}
  bool SegmentedCodeCache         = true       {ergonomic}
size_t SoftMaxHeapSize            = 4294967296 {ergonomic}
  bool UseCompressedClassPointers = true       {ergonomic}
  bool UseCompressedOops          = true       {ergonomic}
  bool UseG1GC                    = true       {ergonomic}
  bool UseNUMA                    = false      {ergonomic}
  bool UseNUMAInterleaving        = false      {ergonomic}

Die Wichtigsten darunter sind:

  • Die Auswahl des Garbage Collectors
  • Die Heapgröße
  • Die Anzahl der möglichen parallelen Threads (zum Beispiel für den Garbage Collector, CI Compiler aber auch für den Default Thread Pool für Parallel Streams)

Diese Einstellungen sind sinnvoll, wenn sich eine Applikation Ressourcen mit anderen teilen muss. In einer containerisierten Welt, in der alle Ressourcen innerhalb eines Containers im Normalfall dem laufenden Hauptprozess in Gänze zur Verfügung stehen, sind aber viele Standardeinstellungen nicht mehr zeitgemäß.

Um das Zusammenspiel von JVM und Containern besser verstehen zu können und etwaige Stolperfallen zu vermeiden, kommen in den nächsten Abschnitten alle Einstellungen separat auf den Prüfstand.

Standardmäßig nutzt die JVM ein Viertel des gesamten Speichers für die Heapgröße. In einem Container ist diese Einstellung höchst ineffizient, da dann drei Viertel des dem Container zugeteilten Speichers ungenutzt bleiben.

In moderneren Java-Versionen empfiehlt es sich daher, diesen Wert mit Hilfe des Parameters -XX:MaxRAMPercentage=XX zu überschreiben – etwa wie folgt:

docker run -m 512m eclipse-temurin:18.0.1_10-jre java -XX:MaxRAMPercentage=80 -XX:+PrintFlagsFinal -version | grep -E "\sMaxHeapSize"
   size_t MaxHeapSize                              = 429916160                                 {product} {ergonomic}

Damit stehen 80 Prozent der zugewiesenen 512 MByte als Heap zur Verfügung.

Zu beachten ist, dass der MaxRAMPercentage-Wert bei sehr kleinem Speicher nicht greift. Nehmen wir als Beispiel 248 MByte für den Container.

docker run -m 248m eclipse-temurin:18.0.1_10-jre java -XX:MaxRAMPercentage=80 -XX:+PrintFlagsFinal -version | grep -E "\sMaxHeapSize"
   size_t MaxHeapSize                              = 130023424                                 {product} {ergonomic}

Die Heapgröße beträgt dann nicht wie zu vermuten 80, sondern nur 50 Prozent des gesamten Speichers. Die Erklärung dafür ist, dass bei wenig verfügbarem Speicher automatisch der Parameter -XX:MinRAMPercentage=XX greift, der standardmäßig auf 50 steht.

In einem containerisierten Umfeld sind kleine Container unterhalb von 250 MByte keine Seltenheit. Wenn ein Container daher unerwartet mit einem OOM aussteigt oder der Heap in der JVM vollläuft, kann das häufig an einer fehlenden oder falschen Einstellung von MinRAMPercentage liegen. Unterhalb von 250 MByte greift MinRAMPercentage, erst darüber MaxRAMPercentage.

Noch undurchsichtiger wird die Situation dadurch, dass ab 250 MByte nicht einfach der von MaxRAMPercentage berechnete Wert angesetzt wird, sondern erst eine Prüfung erfolgt, ob der Wert größer ist als der größtmögliche mit MinRAMPercentage berechnete. Der größte MinRAMPercentage-Wert ist:

possible_min_heap_size = MinRamPercentage * 249m

Dieser Wert gilt, wenn er größer ist als der mit MaxRAMPercentage berechnete – zum Beispiel:

docker run -m 512m eclipse-temurin:18.0.1_10-jre java -XX:MaxRAMPercentage=10 -XX:+PrintFlagsFinal -version | grep -E "\sMaxHeapSize"
   size_t MaxHeapSize                              = 132120576                                 {product} {ergonomic}

Obwohl als MaxRAMPercentage 10 angegeben ist, nimmt die Heapgröße 126 MByte anstatt 51 MByte an. Im Beispiel kommt daher so lange der von MinRAMPercentage berechnete Wert zum Einsatz, bis der von MaxRAMPercentage berechnete größer ist.

Um den Heap in einem Container optimal zu konfigurieren, müssen Entwicklerinnen und Entwickler daher immer beide Einstellungen im Augen behalten.

Die Anzahl der möglichen Threads, der sogenannte ActiveProcessorCount, richtet sich nach den verfügbaren CPUs, die die JVM sehen kann. Dieser Wert lässt sich durch die Parameter --cpus oder --cpu-shares beim Starten des Containers beeinflussen.

Sind beide Parameter gesetzt, wird stets nur --cpus ausgewertet, wie das folgende Beispiel zeigt:

docker run --cpus 3 --cpu-shares 1024 eclipse-temurin:18.0.1_10-jre java -Xlog:os+container=trace -version | grep -A3 -E "CPU Shares"
[debug][os,container] CPU Shares is: 1024
[trace][os,container] CPU Quota count based on quota/period: 3
[trace][os,container] CPU Share count based on shares: 1
[trace][os,container] OSContainer::active_processor_count: 3

Das Ergebnis der CPU-Shares-Berechnung ist 1, da aber drei CPUs gesetzt wurden, hat dieser Wert Vorrang. Wenn nur CPU Shares gesetzt wird, kommt 1 als Ergebnis zum Einsatz.

docker run --cpu-shares 1024 eclipse-temurin:18.0.1_10-jre java -Xlog:os+container=trace -version | grep -B3 -A2 -E "CPU Shares"
[trace][os,container] Path to /cpu.weight is /sys/fs/cgroup//cpu.weight
[trace][os,container] Raw value for CPU shares is: 39
[trace][os,container] Scaled CPU shares value is: 1024
[debug][os,container] CPU Shares is: 1024
[trace][os,container] CPU Share count based on shares: 1
[trace][os,container] OSContainer::active_processor_count: 1

Das Beispiel macht noch eine Besonderheit deutlich. Es läuft mit cgroup v2, daher rechnet Docker den CPU-Shares-Wert um in Weight – hier im Beispiel 39. Die JVM nutzt aber wieder den CPU-Shares-Wert, um den ActiveProcessorCount zu berechnen, das heißt, die JVM rechnet Weight eigenhändig wieder mit der bekannten Formel in CPU Shares um.

Dieses zweifache Umrechnen führt zu Rundungsfehlern, wie im folgenden Beispiel zu erkennen ist:

docker run --cpu-shares 1048 eclipse-temurin:18.0.1_10-jre java -Xlog:os+container=trace -version | grep -B3 -A2 -E "CPU Shares"
[0.001s][trace][os,container] Path to /cpu.weight is /sys/fs/cgroup//cpu.weight
[0.001s][trace][os,container] Raw value for CPU shares is: 40
[0.001s][trace][os,container] Scaled CPU shares value is: 1050
[0.001s][trace][os,container] Closest multiple of 1024 of the CPU Shares value is: 1024
[0.001s][debug][os,container] CPU Shares is: 1024
[0.001s][trace][os,container] CPU Share count based on shares: 1
[0.001s][trace][os,container] OSContainer::active_processor_count: 1

Obwohl 1048 gesetzt ist und Weight den Wert 40 anstatt 39 annimmt, berechnet die JVM den CPU-Shares-Wert zu 1024. Das kann irritieren, insbesondere dann, wenn man die gleiche Applikation mit denselben Einstellungen in einer Umgebung startet, in der noch cgroup v1 aktiv ist. Dasselbe Beispiel auf cgroup v1 kommt nämlich zu einem anderen Ergebnis.

docker run --cpu-shares 1048 eclipse-temurin:18.0.1_10-jre java -Xlog:os+container=trace -version | grep -B1 -A2 -E "CPU Shares"
[0.001s][trace][os,container] Path to /cpu.shares is /sys/fs/cgroup/cpu,cpuacct/cpu.shares
[0.001s][trace][os,container] CPU Shares is: 1048
[0.001s][trace][os,container] CPU Share count based on shares: 2
[0.001s][trace][os,container] OSContainer::active_processor_count: 2

Mit dem identischen Docker-Aufruf berechnet die JVM jetzt, dass sie zwei aktive Prozessoren zur Verfügung hat anstatt einem. Dies bleibt Entwicklerinnen und Entwicklern im Betrieb vermutlich verborgen, kann aber große Auswirkungen auf die Performance der Applikation haben.

Über die genannten Unterschiede zwischen cgroups v1 und v2 hinaus gilt es noch eine weitere Besonderheit beim Festlegen von CPU Shares zu beachten, und zwar dann, wenn man aktiv den Standardwert von 1024 setzt (bei cgroups v1):

docker run --cpu-shares 1024 eclipse-temurin:18.0.1_10-jre java -Xlog:os+container=trace -version | grep -B1 -A2 -E "CPU Shares"
[0.001s][trace][os,container] Path to /cpu.shares is /sys/fs/cgroup/cpu,cpuacct/cpu.shares
[0.001s][trace][os,container] CPU Shares is: 1024
[0.001s][trace][os,container] OSContainer::active_processor_count: 4
[0.001s][trace][os,container] CgroupSubsystem::active_processor_count (cached): 4

Die JVM berechnet bei 1024 nicht den zu erwartenden Wert von 1, sondern 4. Das Beispiel läuft auf einer Maschine mit vier physischen Kernen. Die JVM ignoriert in diesem Fall also die CPU Shares komplett und berechnet den Wert anhand der physikalischen Umgebung.

Da keine befriedigende Dokumentation zur JVM existiert, die diesen Sachverhalt erklärt, liegt die Vermutung nahe, dass die JVM nicht unterscheiden kann, ob der Standardwert aktiv gesetzt oder gar nicht erst angegeben wurde. Ist kein Wert gesetzt, dürfen Entwicklerinnen und Entwickler davon ausgehen, dass der Prozess auch nicht limitiert ist.

Der Vollständigkeit halber sei erwähnt, dass dieser Effekt auch in cgroups-v2-Umgebung auftritt. Der Standardwert für CPU Weight ist dort 100. Umgerechnet mit der Formel ergibt sich ein CPU-Shares-Wert von 2623. Ist dieser Wert beim Start des Containers gesetzt, ergibt sich dasselbe Ergebnis:

docker run --cpu-shares 2623 eclipse-temurin:18.0.1_10-jre java -Xlog:os+container=trace -version | grep -B1 -A2 -E "CPU Shares"
[0.026s][trace][os,container] Raw value for CPU shares is: 100
[0.026s][debug][os,container] CPU Shares is: -1
[0.026s][trace][os,container] OSContainer::active_processor_count: 4

Auch hier droht stets die Gefahr, durch falsch eingestellte Werte ein unerwartetes Verhalten auszulösen.

Vor diesem Hintergrund überrascht es, dass die CPU Shares überhaupt zur Berechnung des ActiveProcessorCounts herangezogen werden – zumal deren Wert nur sinnvoll ist im Vergleich mit anderen Containern, die auf demselben Server laufen. Die Berechnung, die ergibt, dass 1025 CPU Shares nur einer CPU entsprechen, kann dazu führen, dass nur wenige Threads zum Einsatz kommen, obwohl dem Container sehr viel mehr CPU-Zeit zur Verfügung stünde.

Solche Probleme lassen sich durch eine der folgenden Maßnahmen beheben:

  • Stets ein CPU-Limit setzen mit —cpus
  • oder die Berechnung des ActiveProcessorCount deaktivieren und ihn direkt mit -XX:ActiveProcessorCount=X setzen.

Die zweite Lösung kommt häufig bei Buildpacks zum Einsatz, die ein Java-Image nur auf Basis des Sourcecodes erzeugen können. Die Vorgehensweise scheint ganz allgemein geeignet zu sein, um sich vor Überraschungen zu schützen.

Traditionell kennt die JVM zwei Betriebsmodi: auf dem Server oder auf dem Client. Da unterschiedliche Anforderungen zu erfüllen sind, kommen in beiden Fällen verschiedene Gargabe Collectors (GC) zum Einsatz.

Bei Serverapplikationen stehen der Durchsatz und niedrige Latenzen im Vordergrund, daher empfiehlt sich ein GC, der keine Stop-the-world-Events auslöst und seine Arbeit parallel zu den von der Applikation verwendeten Threads ausübt. Dabei ist es akzeptabel, einen aufwendigeren GC zu nutzen, der insgesamt mehr Ressourcen verbraucht.

Bei Clientapplikationen spielt der Durchsatz keine Rolle, wichtiger ist ein kleiner Footprint. Im Clientumfeld kommt daher standardmäßig der weniger komplexe SerialGC zum Einsatz.

Auch wenn diese Unterscheidung im Allgemeinen heutzutage nicht mehr relevant ist, spielt sie für die JVM weiterhin eine Rolle, denn die JVM definiert einen Server als eine Maschine mit:

  • >= 1 CPU
  • >= 2 GB (um genau zu sein 1792m)

Alle Maschinen, die weniger Ressourcen bieten, sind aus Sicht der JVM ein Client. Mithin kommt dann der SerialGC zum Einsatz, wie im folgenden Beispiel beschrieben:

docker run -m 1791m eclipse-temurin:18.0.1_10-jre java -XX:+PrintFlagsFinal -version | grep -E "Use(G1|Serial)GC"
bool UseG1GC     = false {product} {default}
bool UseSerialGC = true  {product} {ergonomic}

Die Wahl des SerialGC kann maßgeblichen Einfluss auf die Performance haben, insbesondere, wenn Entwicklerinnen und Entwickler hohe Anforderungen bezüglich der Antwortzweiten stellen.

Darüber hinaus kommt es in der Praxis häufig vor, dass Java-Anwendungen weniger als zwei GByte Speicher zur Verfügung stehen, um den Ressourcenverbrauch im Container möglichst niedrig zu halten. Eine Skalierung erfolgt dann horizontal, in dem dieselbe Anwendung mehrfach läuft. Infolge dieser Vorgehensweise dürften viele Applikationen – gerade in Produktion – ungewollt mit dem SerialGC laufen.

Umgehen lässt sich das Problem, in dem Entwicklerinnen und Entwickler aktiv den gewünschten GC vorgeben, etwa mit -XX:+UseG1GC:

docker run -m 1791m eclipse-temurin:18.0.1_10-jre java -XX:+PrintFlagsFinal -XX:+UseG1GC -version | grep -E "Use(G1|Serial)GC"
bool UseG1GC     = true  {product} {default}
bool UseSerialGC = false {product} {ergonomic}