Optimierung Container-basierter Java-Anwendungen

Seite 3: Heap-Speicher richtig skalieren

Inhaltsverzeichnis

Im obigen Beispiel sind dem Container 1024 MiB Arbeitsspeicher zugeordnet worden. Er steht der fachlichen Anwendung jedoch nicht vollständig zur Verfügung, denn der Arbeitsspeicher teilt sich in folgende Bereiche auf: Im Speicherbereich Heap werden Java-Objekte erzeugt und durch einen Garbage Collector verwaltet. Der restliche Speicherbereich (Non-Heap) wird für statische Objekte (Stack) und Metadaten der Klassen (Metaspace), sowie für die JVM selbst, und andere Prozesse genutzt (Native). Alle fachlich generierten Java-Objekte, Threads et cetera müssen in den Heap-Speicher passen, sonst kommt es zu einem Out-of-Memory-Fehler. Es ist demnach ein vernünftiges Verhältnis zwischen Heap- und Non-Heap-Speicher zu wählen, damit eine Java-Anwendung stabil und effizient läuft.

Seit Java 10 erkennt die Java Virtual Machine, wenn sie in einem Container ausgefĂĽhrt wird. Der zur VerfĂĽgung stehende Arbeitsspeicher wird aus der cgroup-Datei /sys/fs/cgroup/memory/memory.limit_in_bytes ausgelesen.

In der Standardeinstellung verwendet die JVM 25 Prozent des zur Verfügung stehenden Speichers für den maximalen Heap-Speicher, unabhängig davon, ob die Anwendung auf einem Rechner mit vielen weiteren Prozessen oder in einem Container läuft. Die genaue Formel ist etwas komplizierter und enthält sinnvolle Ober- und Untergrenzen.

Hier nun die Heap-Einstellungen mal etwas genauer betrachtet:

$ docker run --memory='1024m' adoptopenjdk/openjdk11 java -XX:+PrintFlagsFinal -version | grep 'MaxHeapSize\|MaxRAMPercentage'

size_t MaxHeapSize      = 268435456    {product} {ergonomic}
double MaxRAMPercentage = 25.000000    {product} {default}

In diesem Beispiel hat der Autor einen Docker-Container mit 1024 MByte Arbeitsspeicher ausgestattet, und die JVM hat für die Java-Anwendung 256 MByte als maximalen Heap-Speicher vergeben. Der Hinweis {ergonomic} bedeutet, dass die Einstellung abhängig von der Laufzeitumgebung ermittelt wurde.

Da in einem Container typischerweise nur ein einziger nativer Prozess läuft (die JVM), ist die Standardeinstellung mit einem 25-Prozent-Heap-Anteil konservativ. Nach Erfahrung des Autors reichen für die meisten Java-Anwendungen 100 bis 200 MByte für den Stack- und Metaspace-Bereich aus.

Bei entsprechend groß dimensioniertem Container (also ab 512 MByte) kann daher eine Erhöhung des Heaps durch den Parameter -XX:MaxRAMPercentage=50.0 sinnvoll sein. Damit wird die Hälfte des gesamten Speichers für den Heap verwendet:

docker run --memory='1024m' adoptopenjdk/openjdk11 java -XX:MaxRAMPercentage=50.0 -XX:+PrintFlagsFinal -version | grep 'MaxHeapSize\|MaxRAMPercentage\|InitialHeapSize\|InitialRAMPercentage'

size_t InitialHeapSize        = 16777216    {product} {ergonomic}
double InitialRAMPercentage   = 1.562500    {product} {default}
size_t MaxHeapSize            = 536870912   {product} {ergonomic}
double MaxRAMPercentage       = 50.000000   {product} {command line}

Es werden jetzt 512 MiB Heap-Speicher verwendet. Ein genauer Anteil des Heaps lässt sich durch einfaches Experimentieren ermitteln oder durch eine Hochrechnung des benötigten Stacks bezeihungsweise der Metaspaces (etwa mit dem Cloud Foundry Java Buildpack Memory Calculator) berechnen. Sinnvolle Werte liegen typischerweise im Bereich zwischen 25 und 75 Prozent. Die gleiche Einstellung sollte man für InitialRAMPercentage vornehmen, damit auch hier der initiale Heap-Speicher gleich dem maximalen ist.

In einem Dockerfile lassen sich die Parameter entsprechend setzen:

FROM adoptopenjdk:11.0.10_9-jre-hotspot
RUN useradd app
USER app
CMD ["java", "-XX:InitialRAMPercentage=50.0", "-XX:MaxRAMPercentage=50.0", "-jar", "/app.jar"]
COPY target/*.jar /app.jar

Eine durchdachte Dimensionierung des Heap-Speichers kann dazu beitragen, dass eine Java-Anwendung stabiler läuft. Zudem lassen sich durch eine solche Anpassung in einem größeren Kubernetes-Cluster schnell einige GByte an Arbeitsspeicher einsparen, was die Betriebskosten deutlich senkt.