Memory Management: Ein Vergleich zwischen Java 11 und Java 17

Geschrieben von:

Ronny Kissing

Ronny Kissing ist seit Gründung als Backend Entwickler bei der Finatix und ist damit ein Mann der ersten Stunde. Ronnys Fokus liegt auf den JVM Sprachen und auf der Entwicklung von Microservices in Spring Boot.

Copy of Spring Boot Native

Einleitung

Java, eine weitverbreitete und vielseitig eingesetzte Programmiersprache, ist seit Jahren beliebt und scheint an Glanz nicht zu verlieren. Doch die Sprache hat eine Schattenseite, die primär in der Cloud sichtbar wird. Jede Java App, egal ob Spring Boot Microservice, Android App oder andere Arten, benötigt bereits nach dem Start eine gewisse Menge an Arbeitsspeicher. Dieser liegt wesentlich höher als bei nativ kompilierten Anwendungen. Nehmen wir C++ als Gegenbeispiel. C++ ist eine Hochsprache, für welche native Kompilate erzeugt werden müssen. Diese können so entwickelt werden, dass der Speicherverbrauch absolut minimal ist und diese Anwendungen selbst auf eingebetteten Systemen mit sehr geringem Arbeitsspeicher lauffähig sind.

Um zu verstehen, weshalb Java so speicherhungrig ist, müssen wir tiefer ins Detail gehen und die einzelnen Speicherbereiche der JVM [1] näher betrachten. Diese Speicherbereiche und vor allem deren Verwaltung hat sich im Laufe steigender Versionsnummer immer wieder verändert. Vor allem durch diese Veränderungen, Optimierungen und Anpassungen, lohnt sich der Blick auf neuere Java Versionen. Wir betrachten hier die beiden LTS [2] Versionen 11 und 17. Es soll gezeigt werden, worin sich beide unterscheiden und welche Vorteile Java 17 mit sich bringt.

Sparsam und effizient mit Speicher umzugehen, scheint in der heutigen Zeit, in der Speicher immer preiswerter wird, nicht interessant zu sein. Allerdings trügt das. Es werden, gerade im Cloudumfeld, immer mehr Microservices entwickelt und eingesetzt. Diese Microservices laufen meist in Kubernetes Clustern oder Docker Containern. Da man auf den Servern und bei dem Cloud-Anbieter begrenzte Ressourcen zur Verfügung hat, möchte man diese so effizient wie möglich nutzen. Dafür benötigt man zum einen eine in Hinsicht auf Ressourcenverbrauch optimierte Architektur, sauber geschriebenen Code und eine ressourcenschonende JVM. Durch Java 17 erhält man sie und kann mit einer begrenzten Menge an Arbeitsspeicher mehr erreichen als mit früheren Java Versionen.

Um das zu verdeutlichen, wird zuerst etwas auf das Speichermanagement eingegangen und das Verhalten mit einem kleinen Beispiel veranschaulicht.

Welche Speicherbereiche gibt es in Java?

Ein Java-Prozess bzw. eine Instanz teilt sich in verschiedene Speicherbereiche auf:

Heap Memory

Der Heap Memory speichert Objekte und dynamische Daten. Er ist der größte Speicherblock und wird von den Garbage Collectoren (GC) aufgeräumt. Die Größe kann über die Parameter -Xms und -Xmx kontrolliert werden. Innerhalb von Docker Container kann den Freiräumen des Speichers zusätzlich über den Parameter
-XX:MaxRAMPercentage=60.0 kontrolliert werden. In dem Beispiel darf der Microservice maximal 60 % des ihm zur Verfügung stehenden Speichers verwenden.

Metaspace

Der Metaspace hält mit Java 8 Einzug und ersetzt den PermGEN Space aus früheren Java Versionen. Er hält die Klassen-Metadaten und trägt damit einen großen Teil zum Fußabdruck bei. Folgendes wird im Metaspace gespeichert:

  • Methoden Informationen
  • Konstanten
  • Annotationen für Runtime-Retention
  • Bytecode
  • Exception Tables[3]
  • Etc.

Im Laufe der Zeit und Java Versionen hat sich der Metaspace etwas gewandelt und wurde schlussendlich in Java 16 zum Elastic Metaspace. Durch den Elastic Metaspace wurden einige Optimierungen integriert, was dafür sorgt, dass der Speicher schneller wieder leer geräumt und der Speicherverbrauch optimiert wird. Dadurch sinkt die Wahrscheinlichkeit einer OutOfMemoryException stark. Um das zu erreichen, wird im Elastic Metaspace folgendes anders gehandhabt:

  • Speicherbereiche früh freigeben, wenn sie nicht verwendet werden
  • Granulares Allokieren von Speicherbereichen in gleich großen Speicherblöcken
  • Die Größe der Speicherblöcke ist über einen neuen CLI Parameter beeinflussbar:

-XX:MaxMetaspaceSize=<metaspace size>[unit]

  • Als weitere Optimierung kann der folgende Parameter verwendet werden:

-XX:MetaspaceReclaimPolicy=(balanced|aggressive|none)

balanced

Die meisten Anwendungen sollten über diesen Wert eine Optimierung des Memory Footprints sehen. Das Aufräumen des Speichers fällt hier moderat aus. Dieser Modus ist standardmäßig aktiv und zudem abwärtskompatibel. 

aggressive

In diesem Modus wird sehr aggressiv beim Aufräumen vorgegangen, was allerdings zu einer erhöhten Fragmentierung des virtuellen Speichers sorgt 

none

Das Aufräumen des Speichers wird komplett deaktiviert (nicht zu empfehlen) 

Ebenso sind Metadaten in ihrer Größe begrenzt, denn sie können standardmäßig nicht größer als 4MB sein.

Code Cache

In diesem Bereich speichert der JIT[4] Compiler kompilierte Code Blöcke, auf die öfter zugegriffen wird. Je nach Komplexität der Anwendung erhöht sich dadurch der Speicherverbrauch stark, allerdings sichert es auch eine gute Performance bei der Ausführung.

Thread Stacks

Unter Java werden für Threads sogenannte Threadobjekte angelegt. Diese werden im Thread Stack gespeichert. Der Thread Stack selbst hat eine festgelegte und beschränkte Größe, welche standardmäßig bei 512kB liegt [5]. Über den Parameter –Xss kann die Größe jedoch verändert werden. Es ist im Prinzip die maximale Größe, die ein Thread in der JVM haben darf. In dem Stack werden lediglich Laufzeitinformationen und Thread Locks gespeichert. Alles andere landet in den anderen Speicherbereichen.

Shared Libs

Hier wird der Binärcode aller shared libraries gespeichert. Es wird nur einmal pro Prozess gestartet und es findet kein Freigeben des Speichers statt. Aus diesem Grund ist es empfehlenswert, wirklich nur die Libs als Dependency aufzunehmen, die wirklich benötigt werden.

Welche Bedeutung hat die Garbage Collection?

Im Laufe der Javaversionen hat sich nicht nur das Speichermanagement verändert. Es gab auch ein paar Änderungen in der Garbage Collection, was einen großen Einfluss auf den Speicherverbrauch und vor allem das Freigeben von nicht mehr verwendetem Speicher hat.

In diesem Artikel soll allerdings nicht genauer auf Java Garbage Collection eingegangen werden, da dies den Rahmen sprengen würde. Es soll lediglich grob ein Blick auf die standardmäßig eingesetzten GCs in beiden Javaversionen geworfen werden.

In beiden Versionen wird der Garbage First (G1) standardmäßig verwendet. Der G1 arbeitet teilweise konkurrierend und soll GC Pausen einfacher verwalten können. Er ist ein Kompromiss aus Latenzzeit und Performance und arbeitet zuverlässig. Meiner Meinung nach ist der G1 allerdings nicht die optimale Lösung.

Was macht Java 17 nun aber anders als Java 11, um den Speicher sauber zu halten? Diese Frage lässt sich leicht beantworten: Nichts.

Allein die Aufteilung der Speicherbereiche, die ein leichtere Garbage Collection zulassen, machen hier bereits den Unterschied. In dem Beispielprojekt wird immer der G1 verwendet.

Java 11 und Java 17 im Vergleich

Ein Beispiel: Microservice

Um den Speicherverbrauch, auch unter Lastbedingungen, verdeutlichen zu können, wurde ein kleiner Microservice beispielhaft implementiert. Diese Microservice bietet die Möglichkeit IBANs zu berechnen und auf der anderen Seite auch IBANs auf ihre Richtigkeit zu prüfen. Die Berechnung von IBANs wird in der Finanzbranche standardmäßig benötigt.

Der Service besitzt drei Endpunkte. Einen, der eine Liste von IBANs in einer JSON Datei auf ihre Gültigkeit prüft. Einen weiteren, der mittels Zufallsgenerator eine Liste von Testibans erzeugt und als JSON Datei ausgibt, sowie einen Dritten, mit dem eine einzelne IBAN auf ihre Gültigkeit geprüft werden kann. Über alle Endpunkte kann ausreichend Last erzeugt werden, um den Speicherverbrauch beurteilen zu können.

Der Beispielmicroservice kann unter folgender URL mit Git geklont werden.

Prüfung einzelner IBANs:

curl http://localhost:8080/v1/iban/checkIban?ibanToCheck=DE59800530000123456789

Prüfung mehrere IBANs:

curl -d @src/test/resources/ibanCheckTestList.json -H "Content-Type: application/json"  http://localhost:8080/v1/iban/checkIbanList

Erzeugen von IBANs als Testdaten:

curl http://localhost:8080/v1/iban/generateTestData?numberOfIbans=1000 > testdata.json

Die erzeugten Testdaten können für die Gegenprobe, dem Testen der IBANs, verwendet werden.

Damit ergeben sich zwei Möglichkeiten, Last zu erzeugen. Damit eine Vergleichbarkeit gegeben ist, wird der Microservice einmal mit Java 11 und einmal mit Java 17 kompiliert. Diese Kompilate werden anschließend in entsprechende Docker Container gesteckt und über Docker-Compose parallel ausgeführt. So lassen sie sich direkt miteinander vergleichen.

Der Speicherverbrauch wird über die Metriken des Actuator ausgelesen. Zusätzlich werden beide Microservices in ihrem Speicher so begrenzt, dass sie nicht mehr Speicher verbrauchen können, als der DockerContainer zur Verfügung stellt. Das erreicht man über den bereits erwähnten Parameter -XX:MaxRAMPercentage.

Beide Microservices werden auf 60 % des maximalen Speichers begrenzt. Die Dockerfiles sehen entsprechend wie folgt aus:

# For Java 11, try this
FROM openjdk:11

# Refer to Maven build -> finalName
ARG JAR_FILE=JVM-Demo-JAVA11.jar

# cd /opt/app
WORKDIR /opt/app

# cp target/JVM-Demo-0.0.1-SNAPSHOT.jar /opt/app/app.jar
COPY ${JAR_FILE} app.jar

# Expose Port for profiling the memory

EXPOSE 9010

# java -jar /opt/app/app.jar
ENTRYPOINT ["java","-jar","app.jar", "-XX:MaxRAMPercentage=60.0"]

(Für Java 17 analog)

Die Speichergrenzen im Dockercompose File sehen, die Abschnitte entsprechen dem Speicherlimit wie folgt aus – Beispiel für V2:

jvm-demo-api-java11:
container_name: jvm-java11-container
hostname: jvm-demo-java11
mem_limit: 512M
build:
context: .
dockerfile: Dockerfile-Java11
ports:
- 8080:8080

Was bedeutet, dass jeder Service maximal 308 MB an Arbeitsspeicher verbrauchen dürfte. Die Annahme ist nun, dass Java 17 durch die Anpassung des Elastic Metaspace Vorteile bietet und genügsamer zu Werke geht. Beobachten wir nachfolgend die Situation unter verschiedenen Belastungen beider Services mit gleichen Operationen.

Um Lasttests einfacher aufzurufen, ist dem Git Repository ein Bash Script beigelegt: lasttest.sh

Darin werden die gleichen Requests für jeden der beiden Java Container ausgeführt.

Speicherverbrauch im Container

Nach dem Starten der Container kann das Lastverhalten untersucht werden. Wie bereits beschrieben, liegt dem Beispielprojekt eine docker-compose.yml bei, über welche beide Services parallel gestartet werden können. Beide laufen dabei auf unterschiedlichen Ports. Es genügt hier ein docker-compose up –d für den Start. Man erhält dann folgendes:

10

Speicherverbrauch Idle

Im Idle-Zustand, direkt nach dem Start, verhalten sich beide Container ähnlich. Allerdings ist hier auch noch kein Unterschied zu erwarten. 

11

Hier zeigt sich bereits direkt nach dem Start, dass der Java 11 Container deutlich mehr RAM als der Java 17 Container verbraucht. Der Grund hierfür findet sich in der verbesserten Verwaltung des Metaspace sowie in zahlreichen Optimierungen der Garbage Collectoren.

Speicherverbrauch Last

Um den Speicherverbrauch unter Last zu demonstrieren, wird eine „große“ JSON Datei mit 100.000 IBANs erstellt. Hierbei werden entsprechend viele Java Objekte erzeugt, was den Speicherverbrauch nach oben treibt. Das betrifft zum einen den Metaspace, da in diesen Meta-Informationen pro Klasse gespeichert werden, genauso wie den Heap Space, in welchem die restlichen Daten gespeichert werden. 

Ausführung für den Java 11 Container:

curl http://localhost:8080/v1/iban/generateTestData?numberOfIbans=100000 > testdata.json

Ausführung für den Java 17 Container: 

curl http://localhost:8090/v1/iban/generateTestData?numberOfIbans=100000 > testdata.json 

Durch das Erzeugen der JSON Files steigt der Speicherverbrauch beider Microservices moderat an. Um es besser verdeutlichen zu können und um ein Gefühl dafür zu bekommen, kann der Abruf variiert und beliebig oft ausgeführt werden. Das Zwischenergebnis nach fünfmaliger Ausführung auf beiden Services sieht wie folgt aus: 

12

Über den Ausführungszeitraum entwickelt sich der Speicherverbrauch wie folgt: 

13

Um die Last noch höher zu treiben und die Endpunkte parallel aufzurufen, gibt es in dem Projekt ein Shellscript. Während der Ausführung des Scripts, mit 20 Aufrufen pro Microservice, zeigt sich folgendes Bild:

12

Allerdings kann man beobachten, dass es nicht lange dauert, bis der Java 11 Container mit einer OutOfMemory Exception aussteigt. Mittels des Scripts kann das Verhalten immer nachvollzogen werden. Der Java17 Container bleibt dabei stabil und bei einem niedrigen Speicherniveau. 

Hierbei muss erwähnt werden, dass beide Microservice Code-identisch sind und lediglich mit unterschiedlichen Java Versionen kompiliert wurden. Das allein ist schon erstaunlich und zeigt, dass Java 17 scheinbar einen deutlichen Vorteil hat. 

Fazit

Das Speichermanagement hat sich mit Java 17 deutlich verbessert. Meiner Meinung nach liegt dies vor allem an den “Garbage Collectoren” und des” Elastic Metaspace”. Es wurde gezeigt, dass unter Lastbedingungen Java 17 genügsamer ist und es bei den durchgeführten Tests zu keinem Absturz/OutOfMemory gekommen ist. 

Allein deshalb lohnt sich meiner Meinung nach der zügige Umstieg auf Java 17 und vor allem dann, wenn man seine Microservices ressourcensparender in der Cloud laufen lassen möchte bzw. sie dafür vorgesehen sind.  

Ich gehe davon aus, dass Java 17 durch das verbesserte Speichermanagement auch Vorteile in der Performance bietet. Diese Vorteile werden allerdings erst zu einem späteren Zeitpunkt in einem nachfolgenden Artikel aufgegriffen und näher untersucht. 

[1] JVM: Java Virtual Machine. Die JVM ist die virtuelle Maschine, in der alle Java Anwendungen ausgeführt werden. Da Java plattformunabhängiger interpretierter Code ist, wird eine Laufzeitumgebung benötigt. Diese wird wiederum für jede Plattform (Windows, Linux, Mac, Unix …) bereitgestellt und kann den Code ausführen.

[2] LTS: Long Termin Support. Damit sind Versionen mit Langzeitsupport gemeint, welche meist die einzigen Versionen sind, die produktiv eingesetzt werden.

[3] Exception Tables: Exception Tables befinden sich im Metaspace und beschreiben genau, was beim Werfen von Exceptions wirklich passiert. Eine Exception Table hat 4 Felder: Startpunkt, Endpunkt, Ziel und Exception Type. Weitere Informationen: https://dzone.com/articles/the-truth-of-java-exceptions-whats-really-going-on

[4] JIT: Just in Time

[5] https://docs.informatica.com/data-engineering/shared-content-for-data-engineering/h2l/tuning-the-performance-of-the-monitoring-model-repository/tuning-the-performance-of-the-monitoring-model-repository/guidelines-to-improve-the-performance/tune-the-monitoring-model-repository/setting-the-java-stack-size-property.html