Fast and Furious?

Performance auf der JVM: Überblick über CDS, AppCDS und AOT

Oliver Fischer

© Shutterstock / Luciano Meirelles

Eine gute Frage in einem Einstellungsgespräch ist immer noch, warum denn Programme auf der JVM eine recht ordentliche Performance aufweisen, obwohl nur mit Bytecode und nicht mit Maschinencode gearbeitet wird. Wer diese Hürde mit Leichtigkeit genommen hat, dem werden in diesem Artikel weitere Möglichkeiten des JDK verraten, mit denen Oracle versucht, die Leistung der JVM zu verbessern.

Die Performance von Programmen auf der JVM, dabei geht es explizit nicht nur um Java-Programme, steht der von nativen Anwendungen in nichts mehr nach. Das ist seit Jahren bekannt. Der JIT-Compiler des JDK leistet hervorragende Arbeit, wenn es um Codeoptimierung und Generierung von Maschinencode geht. Das ist einer der Gründe für die Stellung von Java bei der Entwicklung von Serveranwendungen. Jedoch konnte Java sich nie als Sprache für kleine Programme oder als Skripting-umgebung etablieren, besonders nicht in den Bereichen Administration oder heute DevOps. Dass dem so ist, liegt nicht an der Sprache an sich, sondern vielmehr an dem langsamen Start der JVM, der je nach Umgebung bis zu ein paar hundert Millisekunden brauchen kann.

Listing 1 zeigt das übliche Hello-World-Programm, Listing 2 dessen Ausführungsdauer und Listing 3 die eines äquivalenten C-Programms im Vergleich.

public class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Hello world!");
  }
}
time java -Xshare:off -jar helloworld.jar 
Hello world!

real     0m0,123s
user     0m0,084s
sys      0m0,027s
time ./hw.out
Hello World!

real     0m0,004s
user     0m0,001s
sys      0m0,002s

Der Unterschied zwischen beiden liegt bei 119 ms, eine Spanne, die auch für uns Menschen wahrnehmbar ist, oder anders gesagt, dass C-Programm wird in einem Dreißigstel der Zeit des Java-Programms ausgeführt. Genau genommen lassen sich beide Programme nicht vergleichen, da bei ihrer Ausführung unterschiedliche Dinge passieren. Aus Anwendersicht ist es allerdings egal, warum Programm A schneller ist als Programm B. Bei solchen Programmen kann die JVM ihre Vorteile wie die Just-in-Time Compilation nicht ausspielen.

Rufen wir uns grob vor Augen, was die JVM vor der Ausführung des eigentlichen Programms tut: Start der JVM selbst, Laden der für die JVM benötigten JARs bzw. Module, deren Initialisierung und Bytecodeverifizierung. Das alles kostet Zeit. Über die Jahre haben zuerst Sun und später Oracle viel für die die Verbesserung der Startzeit getan. Über verschiedene Java-Versionen hinweg wurden drei Techniken eingeführt, die wir als Java-Entwickler selbst direkt nutzen können.

CDC – Class Data Sharing

Class Data Sharing (CDS) hielt bereits mit Java 5 Einzug in die JVM und erlaubt einen Dump der internen Repräsentation der beim Start geladenen Klassen in eine Datei, die dann bei jedem Start der JVM, gesteuert über den JVM-Parameter -Xshare:[on|off|auto|dump], als Memory Mapped File geladen wird und das Laden der JARs überflüssig macht. Je nach Installationsart (Installer oder manuelle Installation) ist diese Datei bereits vorhanden oder nicht. Falls nicht, kann sie manuell mittels java -Xshare:dump erzeugt werden.

Da Java die Datei mit dem Dump in Installationsverzeichnis als $JAVA_HOME/lib/server/classes.jsa ablegt, braucht sie nur einmal erzeugt werden und kann dann von jedem Java-Programm genutzt werden. Folgendes Beispiel zeigt im Vergleich zu Listing 2 den Unterschied in der Ausführung mit CDS, hier expliziert aktiviert mit -Xshare:on:

time java -Xshare:on -jar helloworld.jar 
Hello world!

Real     0m0,092s
user     0m0,085s
sys      0m0,026s

Der Einsatz von CDS hat gerundet eine Verbesserung von 25 Prozent bzw. 32 ms gebracht. Für kleine Programme, bei denen nicht viele eigene Klassen geladen werden, ist das eine immense Verbesserung. Wie oben schon gezeigt gibt es für den Parameter –Xshare vier Optionen: dump erzeugt die Datei mit dem Dump, on erzwingt dessen Nutzung und führt zu einem Abbruch von Java, wenn die Dumpdatei nicht existiert, off deaktiviert ihre Nutzung und auto aktiviert sie, falls sie existiert.

AppCDS – Application Class Data Sharing

Mit Java 8 Update 40 hat Oracle das Application Class Data Sharing (AppCDS) als kommerzielles Feature eingeführt, das die Idee hinter CDS auch auf Anwendungsklassen und Klassen aus den Extension Directories ausdehnt. Damit besteht die Möglichkeit eine Datei mit der internen Repräsentation der Klassen der Anwendung zu erstellen und diese daraus anstatt aus den JARs oder Modulen zu laden und so den Start der Anwendung zu beschleunigen.

AppCDS ist bis heute im Oracle JDK ein kommerzielles Feature, darf daher also im produktiven Einsatz nur mit einer Java-Lizenz von Oracle eingesetzt werden. Allerdings steht es seit Version 10 auch als nicht kommerzielles Feature für das OpenJDK bereit.

Die Nutzung von AppCDS erfordert mehr Schritte als die von CDS. Dabei ist zu beachten, dass die so erzeugte .jsaDumpdatei im Grunde nichts anderes als ein Cache ist. Für AppCDS heißt das, dass die generierte Datei immer wieder erzeugt werden muss, wenn sich die Klassen der Anwendung ändern. Sonst passen das gecachte Abbild und die JARs nicht mehr zueinander.

Im ersten Schritt auf dem Weg zur Nutzung von AppCDS müssen die für die Anwendung zu ladenen Klassen ermittelt werden, also die Klassen, deren interne Repräsentation im folgenden Schritt in eine Datei gedumpt werden soll. Dafür muss die Anwendung einmal mit dem Parameter XX:DumpLoadedClassList=<dateiname> ausgeführt werden:

java -XX:DumpLoadedClassList=hw-classes.lst \
     -jar helloworld.jar \

Im zweiten Schritt wird, basierend auf der erzeugten Klassenliste, die Cachedatei erzeugt:

java -Xshare:dump -XX:+UseAppCDS \
     -XX:SharedClassListFile=hw-classes.lst \
     -XX:SharedArchiveFile=hw.jsa \
     -jar helloworld.jar

Anschließend wird AppCDS für das Hello-World-Programm aktiviert:

java -Xshare:on -XX:+UseAppCDS \
     -XX:SharedArchiveFile=hw.jsa \
     -jar helloworld.jar 

Ein Vergleich der Ausführungsgeschwindigkeit zwischen der mit CDS und mit CDS plus AppCDS für das Beispielprogramm zeigt sehr unterschiedliche Ergebnisse, die von plus vier Prozent bis minus vier Prozent reichen. Eine kleine Spring-Boot-Anwendung, die via REST „Hello World!“ ausgibt, konnte fast überhaupt nicht von Application Class Data Sharing profitieren. Andere Entwickler hingegen berichten von Verbesserungen von bis zu zwanzig Prozent bei einigen Programmen, die dann allerdings auch mehrere Tausend Klassen laden . Der Nutzen von AppCDS ist folglich sehr abhängig vom jeweiligen Programm.

AOT – Ahead-of-Time Compilation

Mit Java 9 hat mit Ahead-of-Time Compilation ein neues, auch in Java 10 noch experimentelles Feature den Weg ins JDK gefunden – zuerst nur für Linux, ab Java 10 auch für Windows und MacOS. Java-Entwickler sind an die Just-in-Time Compilation des JDK gewöhnt, bei der der JIT-Compiler umfangreiche Messungen zur Laufzeit des Programms vornimmt und darauf basierend Codeoptimierungen wie Inlining oder Loop Unrolling sowie die Erzeugung von Maschinencode aus dem Java-Bytecode übernimmt. Bei der Ahead-of-Time Compilation hingegen erzeugt das sich unter $JAVA_HOME/bin neu hinzugekommene Tool jaotc eine für die Plattform passende native Shared Library direkt aus dem Java-Bytecode, also vor der Ausführung des Codes (Aheadof-Time), so, wie das ein C++-Compiler tut.

jaotc unterstützt die AOT Compilation für Module, JAR-Archive und einzelne .class-Dateien. Beispielsweise lässt ich das Modul java.base des JDK 10 mittels jaotc–output java_base.so –module java.base in eine Shared Library umwandeln, die später java mit dem Parameter -XX:AOTLibrary=./output/java_base.so übergeben werden kann. Die vollständige Nutzung von jaotc für die Beispielaktion sieht so aus:

jaotc --output java_base.so --module java.base
jaotc --output helloworld.lib.so --jar ./helloworld.jar

In Listing 4 erfolgt die Ausführung des Programms samt Zeitmessung. Im Codebeispiel wird zuerst das Modul java.base übersetzt, wobei es reicht, seinen Namen anzugeben, da jaotc bereits eine Liste von ausgewählten Java-Modulen kennt.

time java -Xshare:on XX:+UseAOT \
          -XX:AOTLibrary=java_base.so,helloworld.lib.so \
          -jar helloworld.jar 
Hello world!

real     0m0,141s
user     0m0,095s
sys      0m0,044s

Überraschenderweise verschlechtert sich die Ausführungszeit des Programms nun. Woran das liegt, lässt sich nur vermuten. Vielleicht trägt dazu auch die Größe der für java.base erzeugten Shared Library bei, die ohne jegliche Optimierung 321 MiB groß ist, wodurch mehr I/O-Operationen bei ihrem Lesen notwendig sind. Um hier feinkörniger zu bestimmen, welche Klassen übersetzt werden sollen, bietet jaotc die Option –compile-commands <dateiname>.

Die so übergebene Datei kann die beiden Anweisungen exclude und compileOnly, gefolgt von einem regulären Ausdruck, zur Einschränkung der in die zu erzeugende Shared Library aufzunehmenden Klassen und Methoden enthalten. Um davon Gebrauch zu machen, kann über die JVM-Optionen -XX:+UnlockDiagnosticVMOptions und -XX:+LogTouchedMethods eine Liste der wirklich verwendeten Methoden erstellt werden, die mittels etwas Shellmagie in eine Liste von Befehlen für jaotc verwandelt werden kann. Listing 5 zeigt den ganzen Prozess, Listing 6 die Veränderung der Ausführungszeit.

java -jar helloworld.jar \
     -XX:+UnlockDiagnosticVMOptions \
     -XX:+LogTouchedMethods \
     -XX:+PrintTouchedMethodsAtExit > touched_methods.lst

grep -v 'Hello world!' touched_methods.lst | \
  grep -v '^#' | \
  sed -e 's/^/compileOnly /' > compile.commands

jaotc --output touched_methods.so \
      --compile-commands compile.commands \
      --module java.base \
      --jar helloworld.jar
time java -Xshare:on -XX:+UseAOT \
          -XX:AOTLibrary=touched_methods.so \
          -jar helloworld.jar
Hello world!

real     0m0,105s
user     0m0,097s
sys      0m0,027s

Diese Optimierung bringt immerhin eine sichtbare Verbesserung, liegt aber immer noch hinter den Ergebnissen von CDS zurück.

Ehe es Zeit ist, ein Fazit zu ziehen, noch ein paar Worte zum Verhältnis von AOT und JIT. AOT und JIT auf der JVM schließen sich nicht aus. Standardmäßig erzeugt jaotc nur Code ohne jegliche Profiling-Informationen, auf die der JIT-Compiler zugreifen kann, um den Code während der Ausführung zu optimieren. Wird jaotc jedoch mit der Option –compile-for-tiered aufgerufen, werden dem generierten Code alle notwendigen Informationen hinzugefügt, um dem JIT-Compiler die gleichen Optimierungs-möglichkeiten einzuräumen wie bei normalem Bytecode.

Fazit

Nachdem alle drei Techniken betrachtet wurden, bleibt die Frage, welche sich wofür eignet und ob sie sich überhaupt lohnt. Class Data Sharing stellt von den dreien die einfachste und am leichtesten zu nutzende Technik dar. Obwohl effizient, ist sie doch wenig effektiv bei größeren oder langlaufenden Anwendungen. Hier fällt nämlich die Start-up-Zeit der JVM selbst weniger ins Gewicht. Da CDS jedoch leicht einzurichten ist, bietet es sich trotzdem an, seine Vorteile mitzunehmen.

Application Data Sharing setzt dort an, wo Class Data Sharing aufhört. Wie die Beispiele zeigen, sind mehrere Schritte zu seiner Einrichtung notwendig, was auch die Ausführung des Programms selbst einschließt. AppCDS hat zudem das gleiche Problem wie CDS: Sein Nutzen hängt von der Anwendung ab, und viele Anwendungen werden nicht davon profitieren.

Bleibt als Letztes die Ahead-of-Time Compilation. Sie hilft der JVM, die erste Phase des JIT-Compilers, die initiale Umwandlung von Bytecode in Maschinencode, zu überspringen. Doch die eigentliche Arbeit des JITCompilers, das Profiling und die Optimierung des Codes zur Laufzeit, profitiert nicht davon. Zudem zeigen die Ergebnisse der Beispiele dieses Artikels eher einen negativen Einfluss auf die Laufzeit. Allerdings gibt es auch andere Stimmen im Netz, die von wahrnehmbaren Verbesserungen berichten. Aktuell ist AOT noch experimentell und kein offizieller Teil des JDK, weshalb es für eine abschließende Bewertung noch zu früh ist.

Lesen Sie auch: GraalVM 1.0: Die virtuelle Maschine der polyglotten Zukunft

Ob eine Anwendung von diesen drei Techniken in der realen Welt profitiert, scheint vom Einzelfall abhängig zu sein und muss individuell bestimmt werden. Wahrscheinlich dürfte es sich dabei um Anwendungen handeln, bei denen es auf jede Millisekunde ankommt und die selbst bereits ausgiebig profiled und optimiert wurden. Zudem fügt sich die Einrichtung von AppCDS und AOT nicht in den üblichen Build-Prozess ein und wird nicht direkt durch Standardtools wie Maven oder Gradle unterstützt.

Der Eindruck ist also gemischt. Auf der einen Seite ist es interessant, zu sehen, welche Anstrengungen in die JVM gesteckt werden. Auf der anderen Seite sind die Ergebnisse dafür oft nicht ausreichend. In einem weiteren Artikel wenden wir uns der GraalVM zu, die einen anderen Ansatz verfolgt und dabei auch noch wirklich polyglott ist, da sie sowohl die Ausführung von JVMSprachen wie Java oder Kotlin als auch die von LLVM-Bytecode sowie die Erzeugung von nativem Code ermöglicht, der ohne JVM lauffähig ist.

Geschrieben von
Oliver Fischer
Oliver Fischer
Oliver B. Fischer ist Senior Software Engineer bei der E-Post Development GmbH und engagiert sich in der JUG Berlin-Brandenburg.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Performance auf der JVM: Überblick über CDS, AppCDS und AOT"

avatar
400
  Subscribe  
Benachrichtige mich zu:
Entwickler
Gast

Jlink kann die JVM auf das Wesentliche reduzieren und damit die Startzeit ebenfalls erhöhen. Wie erwähnt bringt der Graal-Compiler einen signifikanten Geschwindigkeitszuwachs und „native-image“ sollte ein Java-Programm sogar auf C/C++-Geschwindigkeitsniveau bringen.

Hier halbiert CDS + AOT die Zeit sogar.

https://mjg123.github.io/2017/10/02/JVM-startup.html