Native Java-Programme auf der GraalVM

Was steckt hinter den Frameworks?
11
Jan

Native Java-Programme auf der GraalVM

GraalVM und SubstrateVM, native Java-Programme: Schlagworte, die im Moment in ganz vielen Artikeln die Runde machen. Worum geht es da?

GraalVM

Die GraalVM ist eine hochperformante Runtime, die laut Webseite des Herstellers Oracle signifikante Performanceverbesserungen für Anwendungen und erheblich effizientere Microservices verspricht: „GraalVM is a high-performance runtime that provides significant improvements in application performance and efficiency which is ideal for microservices. It is designed for applications written in Java, JavaScript, LLVM-based languages.“

Sowohl ein neuer und verbesserter Just-in-Time-(JIT)-Compiler als auch ein Ahead-of-Time-(AOT-)Compiler werden erwähnt. Beim neuen JIT-Compiler wird bereits versprochen, dass er verschiedene Szenarien im Vergleich zum Standard-JDK deutlich beschleunigt.

In obiger Quelle heißt es dann weiter: „For existing Java applications, GraalVM can provide benefits by […] creating ahead-of-time compiled native images.“ Während das erstmal sehr geschwollen klingt, heißt es nichts anderes, als dass die GraalVM aus existierenden Java-Programmen EXE-Executables bzw. ELF-Programme erzeugen kann. Das GraalVM-Modul hinter diesem Feature ist die SubstrateVM.

SubstrateVM

Die SubstrateVM ist das Subsystem der GraalVM, das letzten Endes als Teil eines nativen Images selbiges ausführt und die Laufzeitumgebung repräsentiert. Aus dem entsprechenden Readme:
„(A native image) does not run on the Java VM, but includes necessary components like memory management and thread scheduling from a different virtual machine, called „Substrate VM“. Substrate VM is the name for the runtime components (like the deoptimizer, garbage collector, thread scheduling etc.). The resulting program has faster startup time and lower runtime memory overhead compared to a Java VM.“

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Auf der Seite des GraalVM-Teams finden sich einige Benchmarks, die die Vorteile zeigen, die sich ergeben, wenn zum Beispiel Java Microservices als native Programme ausgeführt werden. Dazu gehört der geringere Speicherbedarf, aber insbesondere die deutlich schnelleren Startzeiten. Sollen Microservices elastisch skaliert werden, ist das eine kritische Metrik. Die hier gezeigten Statistiken sind beeindruckend und werden zweifelsohne für viele Anwendungen erhebliche Vorteile bringen.

Mein Hintergrund: Library-Autor

Ich arbeite beim Hersteller der gleichnamigen Graphdatenbank Neo4j Inc. Dort bin ich Teil des Treiber- beziehungsweise Spring-Data-Neo4j-Teams. Unsere Aufgabe ist die Bereitstellung von Datenbankkonnektoren für unterschiedliche Sprachen, inklusive Java. Zusammen mit Gerrit Meier (@meistermeier) entwickle und pflege ich Spring-Data-Neo4j.

Die Aufgabe des Java-Treibers ist erst einmal simpel: Herstellung einer Netzwerkverbindung zur Datenbank. Diese kann natürlich SSL-verschlüsselt betrieben werden. Darüber hinaus hat der Treiber einige Funktionen, die für die Arbeit mit einem Cluster aus Neo4j relevant sind.

Spring-Data-Neo4j und unser Object Mapping Framework setzen auf dem Treiber auf. Ihre Aufgabe ist es, quasi beliebige Domain-Modelle, die unsere Kunden und Nutzer in Form von annotierten Java-Klassen auf diese Frameworks werfen, in Cypher-Abfragen abzubilden beziehungsweise aus den Ergebnissen von Cypher-Abfragen zu materialisieren. Die Object Mapping Frameworks setzen dazu in der Regel Java Reflection ein. Mit Java Reflection werden benutzte Annotationen, Namen von Attributen etc. gelesen. Das ist natürlich etwas, das auch zur Kompilierungszeit möglich ist, aber die Entscheidung fiel an diesen Stellen ganz klar zugunsten der dynamischen Variante.

Im Zusammenhand mit den beiden Schlagworten SSL und Java Reflection bin ich seit gut eineinhalb Jahren mit der GraalVM beschäftigt. Zum einen möchten wir Anwendungen, die unseren Java-Treiber verwenden, ermöglichen, nativ kompiliert zu werden, ohne auf SSL zu verzichten. Zum anderen möchten wir natürlich mit Version 6 von Spring-Data-Neo4j, einem kompletten Rewrite des Moduls, Teil des Spring Native Projects werden.

Auf diesem Standpunkt kann ich nicht nur großen Nutzen aus dem bestehenden Tooling um SubstrateVM ziehen, sondern auf der anderen Seite auch dazu beitragen, dass Menschen, die unseren Treiber nutzen oder Spring-Data-Neo4j nativ betreiben möchten, nicht selbst den Aufwand betreiben müssen, den wir und unsere Kollegen bei VMWare hinter sich haben, um Spring Data native zu kompilieren.

Frameworks

Neuere Microservices Frameworks wie Quarkus und Micronaut sind teilweise direkt unter dem Aspekt „Kompatiblität mit GraalVM Native“ entwickelt worden und bieten bereits seit 2019 dezidierte Hooks an, um die Erstellung von nativen Anwendungen zu vereinfachen.

In Hinblick auf Spring und Spring Boot existiert das aktuell als experimentell markierte Spring GraalVM Native Projekt. Es bringt sowohl Annotationen und andere Hilfsmittel, die von Entwicklerinnen und Entwicklern für ihre Anwendungen genutzt werden können, als auch bereits vorgefertigte Pakete von Hinweisen, um Spring-Projekte selbst native lauffähig zu machen. Während ich Spring GraalVM Native im Java Magazin 9.20 nur andeutete, schrieb mein Freund Jonas Hecht einen exzellenten Artikel zum Thema Spring GraalVM Native.

Ich will heute aber gar nicht auf eins der konkreten Frameworks eingehen oder diskutieren, welcher Ansatz besser ist. Mir geht es im Folgenden darum, zu zeigen, was alles gemacht werden kann – nicht unbedingt und in jedem Fall muss –, um eine normale Java-Anwendung so zu ertüchtigen, dass sie mit GraalVM Native funktioniert.

Für die meisten Anwendungsentwicklerinnen und -entwickler wird sich die Frage „native oder nicht“ in einigen Jahren sicherlich nicht mehr mit der Wahl des Frameworks entscheiden, sondern wird unter fachlichen Gesichtspunkten beantwortet werden können. Damit das möglich ist, betreiben wir Library-Autor*innen und Framework-Entwickler*innen teils erheblichen Aufwand. Aufwand, der im besten Fall wohlwollend zur Kenntnis genommen wird, im schlechtesten Fall als „Magie“ verschrien ist. Letzteres möchte ich vermeiden. Es ist keine Magie, es ist teilweise Detektivarbeit, und notwendige Schritte können auch „zu Fuß“ nachvollzogen werden. Ich für meinen Teil schätze aber die kollaborative Arbeit, die in diesem Raum von den beteiligten Menschen geleistet wird.

Native Image

Seit meiner ersten Begegnung mit GraalVM während der JCrete 2017 ist ein ganzer Zoo an Werkzeugen entstanden. Eines dieser Werkzeuge heißt Native Image (Kasten: „Installation der GraalVM und des Native-Image-Tools“).

Installation der GraalVM und des Native-Image-Tools

Oracle stellt unter Downloads sowohl die kostenfrei nutzbare Community-Edition als auch die lizenzpflichtige Enterprise-Version für mehrere Betriebssysteme zur Verfügung. Die Installation unterscheidet sich je nach Betriebssystem. Allen Varianten ist gemein, dass das Native-Image-Tool mit Hilfe des GraalVM Component Updater, kurz gu, nachinstalliert werden muss. Der Aufruf lautet – wenn GraalVM korrekt installiert wurde und das entsprechende bin-Verzeichnis im Pfad liegt – gu install native-image.

Unter Linux und macOS kann SDKMan! genutzt werden, um GraalVM inklusive aller Tools zu installieren. Die ersten Schritte werden ausführlich unter Install GraalVM beschrieben.

 

Die vollständigen Quellen der folgenden Codeschnipsel stehen mit passender Verzeichnisstruktur auf meinem GitHub-Account zur Verfügung.
Gegeben sei das triviale Java-Programm in Listing 1.

 
package ac.simons.native_story.trivial;

public class Application {

  public static void main(String... args) {

    System.out.println("Hello, " + (args.length == 0 ? "User" : args[0]));
  }
}

Als Single-Source-File kann es mit jedem neuen JDK ohne Aufruf von javac gestartet werden. Ein java trivial/src/main/java/ac/simons/native_story/trivial/Application.java Michael produziert erwartungskonform Hello, Michael.

Um es hingegen als Input für Native Image zu verwenden, muss es kompiliert werden. Anschließend kann native-image wie folgt aufgerufen werden:

javac trivial/src/main/java/ac/simons/native_story/trivial/Application.java
native-image -cp trivial/src/main/java 
ac.simons.native_story.trivial.Application app

native-image wird Output wie in Listing 2 erzeugen.

 
Build on Server(pid: 21148, port: 50583)
[ac.simons.native_story.trivial.application:21148]    classlist:      71.34 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]        (cap):   1,663.79 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]        setup:   1,850.67 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]     (clinit):     107.06 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]   (typeflow):   2,620.63 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]    (objects):   3,051.08 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]   (features):      83.23 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]     analysis:   5,962.31 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]     universe:     112.18 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]      (parse):     218.57 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]     (inline):     494.42 ms,  4.55 GB
[ac.simons.native_story.trivial.application:21148]    (compile):     912.43 ms,  4.43 GB
[ac.simons.native_story.trivial.application:21148]      compile:   1,828.57 ms,  4.43 GB
[ac.simons.native_story.trivial.application:21148]        image:     465.08 ms,  4.43 GB
[ac.simons.native_story.trivial.application:21148]        write:     135.90 ms,  4.43 GB
[ac.simons.native_story.trivial.application:21148]      [total]:  10,465.92 ms,  4.43 GB

Nach einiger Zeit steht das native Binary app zur Verfügung und der Aufruf ./app Michael produziert dieselbe Ausgabe wie zuvor – nur schneller. Zum Aufruf von native-image stehen entsprechende Maven- und Gradle-Plug-ins zur Verfügung, die in den nachfolgenden Beispielen genutzt werden.

Viel komplizierter ist es eigentlich nicht, aus dem JIT-Bytecode ein AOT Image zu bauen, wären da nicht Frameworks, die Reflection nutzen, oder augenscheinlich triviale Dinge wie Ressourcen.

Ein fiktives Framework

Für die folgenden Erklärungen werde ich aus dem trivialen Hello-World-Beispiel ein unnütz kompliziertes Java-Programm machen, das hoffentlich einige der Dinge zeigt, die zumindest in einigen Frameworks und vermutlich auch Anwendungen „so passieren“.
Die Grußworte kommen natürlich aus einem Service (Listing 3).

 
public interface Service {

  String sayHelloTo(String name);

  String getGreetingFromResource();
}

Services fallen nicht einfach so vom Himmel, wir nutzen eine Factory wie in Listing 4. Diese könnte zum Beispiel notwendige Abhängigkeiten besorgen und in den Service injizieren. Im Beispiel selbst instanziiert sie dynamisch eine Implementierung des Service. Dynamisch, da keine Compile-Zeit-Konstante mit dem Namen der Implementierung genutzt wird, sondern der Name dynamisch bestimmt wird.

 
public class ServiceFactory {

  public Service getService() {
    Class<Service> aClass;
    try {
      aClass = (Class<Service>) Class.forName(ServiceImpl.class.getName());
      return aClass.getConstructor().newInstance();
    } catch (Exception e) {
      throw new RuntimeException("¯\\_(ツ)_/¯", e);
    }
  }
}

Unsere Beispielimplementierung, gezeigt in Listing 5, nutzt darüber hinaus einen Zeit-Service sowie Textressourcen.

 
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.util.stream.Collectors;

public class ServiceImpl implements Service {

  private final TimeService timeService = new TimeService();

  @Override
  public String sayHelloTo(String name) {
    return "Hello " + name + " from ServiceImpl at " + timeService.getStartupTime();
  }

  @Override
  public String getGreetingFromResource() {
    try (BufferedReader reader = new BufferedReader(
    new InputStreamReader(this.getClass().getResourceAsStream("/content/greeting.txt")))) {

      return reader.lines()
      .collect(Collectors.joining(System.lineSeparator()));
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }
}

Der Zeitservice ist natürlich ebenfalls vollkommen naiv implementiert und verlässt sich auf die Konstante STARTED_AT auf Klassenebene, um den Startzeitpunkt der JVM zu speichern. Das ist aus mehreren Gründen naiv, soll hier aber nicht thematisiert werden.

Zu guter Letzt wird aus dem einfachen Main-Programm das in Listing 6 dargestellte Biest.

 
import java.lang.reflect.Method;

public class Application {

  public static void main(String... a) {

    Service service = new ServiceFactory().getService();
    System.out.println(service.sayHelloTo("GraalVM"));

    System.out.println(invokeGreetingFromResource(service, "getGreetingFromResource"));
  }

  static String invokeGreetingFromResource(Service service, String theName) {

    try {
      Method method = Service.class.getMethod(theName);
      return (String) method.invoke(service);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }
}

Dieses konstruierte Beispiel zielt darauf, viele der unter Limitations of GraalVM Native Image dargestellten Punkte aufzuzeigen. Adressiert werden dabei:

  • Reflektive Erzeugung von Instanzen mit Hilfe dynamischer Werte: ServiceImpl.class.getName() kann nicht als konstanter Klassenname erkannt und entsprechend behandelt werden
  • Dynamische Methodenaufrufe (was analog auch für den Zugriff auf Felder gilt)
  • Zugriff auf Resource
  • Statische Initialisierung von Feldern während der Initialisierung von Klassen mit Werten, die vom Zeitpunkt der Initialisierung abhängen

Wird diese Anwendung nun als JAR-Datei paketiert und mit einem entsprechendem Manifest und Main-Eintrag versehen, kann sie wie folgt aufgerufen werden:

 
java -jar only-on-jvm/target/only-on-jvm-1.0-SNAPSHOT.jar
Hello GraalVM from ServiceImpl at 2020-09-15T09:37:37.832141Z
Hello, from a resource.

Das Native-Image-Tool kann nicht nur mit Bytecodedateien umgehen, sondern auch mit JAR-Archiven, sofern diese ein entsprechendes Manifest mit Zeiger auf die main-Klasse haben. Listing 7 zeigt, was passiert, wenn versucht wird, das paketierte Framework-Programm nativ zu kompilieren.

 
native-image -jar only-on-jvm/target/only-on-jvm-1.0-SNAPSHOT.jar
...
Warning: Reflection method java.lang.Class.forName invoked at ac.simons.native_story.ServiceFactory.getService(ServiceFactory.java:8)
Warning: Reflection method java.lang.Class.getMethod invoked at ac.simons.native_story.Application.invokeGreetingFromResource(Application.java:18)
Warning: Reflection method java.lang.Class.getConstructor invoked at ac.simons.native_story.ServiceFactory.getService(ServiceFactory.java:9)
Warning: Aborting stand-alone image build due to reflection use without configuration.
Warning: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Build on Server(pid: 26437, port: 61293)
...
Warning: Image 'only-on-jvm-1.0-SNAPSHOT' is a fallback image that requires a JDK for execution (use --no-fallback to suppress fallback image generation and to print more detailed information why a fallback image was necessary).

Wir sehen etliche Warnungen, und alle künstlich herbeigeführten Probleme werden zuverlässig erkannt. Das ist schon mal gut. Zu guter Letzt wird dennoch ein Binary erzeugt. Dieses Binary ist aber ein sogenanntes fallback image. Es benötigt ein lokal installiertes JDK zur Laufzeit. Falls dieses nicht im Pfad ist, führt der Start zu einem Fehler:

 
./only-on-jvm-1.0-SNAPSHOT
Error: No bin/java and no environment variable JAVA_HOME

Zu ergänzen bleibt hier: Das Binary ist nicht wirklich kleiner als ein pures SubstrateVM Binary, obwohl es auf das externe JDK angewiesen ist, und es ist nichtsdestotrotz plattformspezifisch. Ein auf macOS erzeugtes Binary wird nicht auf Linux starten. native-image kann mit der Option –no-fallback aufgerufen werden und bricht mit einem Fehler ab, anstatt ein Fallback Image zu erzeugen.

Welche Werkzeuge gibt es nun, um diese Probleme zu beheben? Zuerst adressieren wir die offensichtlichen: das dynamische Laden von Klassen und den reflektiven Zugriff auf Methoden und Felder. Dazu gibt es zwei Möglichkeiten: Wir können die benötigten Klassen aufzählen und explizit im Image inkludieren – oder sie ersetzen.

Aufzählung von Klassen, Methoden und Feldern die verfügbar sein müssen

Die Codeanalyse durch die GraalVM erkennt Aufrufe wie Class.forName(), die die Anwesenheit von Klassen erforderlich machen. Ist die GraalVM in der Lage, die Parameter solcher Aufrufe auf Compile-Zeit-Konstanten zurückzuführen, werden die entsprechenden Klassen und Methoden automatisch mit ins Image eingeschlossen. Unser Beispiel jedoch ist so formuliert, dass das nicht geht. An dieser Stelle kommt explizite Konfiguration ins Spiel. native-image wird über den Parameter -H:ReflectionConfigurationFiles der Ort einer JSON-Datei mitgeteilt, die wie in Listing 8 aussehen kann.

 
[
  {
    "name" : "ac.simons.native_story.ServiceImpl",
    "allPublicConstructors" : true
  },
  {
    "name" : "ac.simons.native_story.Service",
    "allPublicMethods" : true
  }
]

Die Konfiguration in Listing 8 bedeutet, dass wir den reflektiven Zugriff auf alle öffentlichen Konstrukte der Klasse ServiceImpl sowie auf alle öffentlichen Methoden des Service Interface erlauben. SubstrateVM wird diese Klassen mit in das binäre Image nehmen. Weitere Optionen dieser Konfiguration werden im entsprechenden Handbuch beschrieben.

Es besteht die Möglichkeit, eine zentrale Properties-Datei für das Tool zu erstellen, die es erspart, ein oder mehrere –H:ReflectionConfigurationFiles=/path/to/reflectconfig-Optionen anzugeben: Wird in den Resources einer JAR-Datei unter META-INF/native-image (sinnigerweise in einem Unterpfad wie META-INF/native-image/GROUP_ID/ARTIFACT_ID, um Konflikte mit anderen Libraries zu vermeiden) eine Datei namens native-image.properties angelegt, so wird diese zur Steuerung des Tools verwendet. Über die Eigenschaft Args werden die entsprechenden Kommandozeilenparameter gesetzt. Eine erste Variante sieht so aus:

 
Args = -H:ReflectionConfigurationResources=${.}/reflection-config.json

Das Programm wird nun ohne Warnhinweise in ein natives Binary kompiliert. Allerdings wird es mit einer NullpointerException abstürzen, da es /content/greeting.txt nicht mit ins Image geschafft hat. Die entsprechende Aufzählung wird in Listing 9 gezeigt. Das Listing repräsentiert die resources-config.json.

 
{
  "resources": [
    {
      "pattern": ".*greeting.txt$"
    }
  ]
}

Diese Datei muss native-image explizit mitgeteilt werden. Der Parameter (Kasten: „Zwei Varianten“) lautet –H:ResourceConfigurationResource. die entsprechende zweite Variante der Steuerungsdatei sieht so aus:

 
Args = -H:ReflectionConfigurationResources=${.}/reflection-config.json \
       -H:ResourceConfigurationResources=${.}/resources-config.json

Zwei Varianten

Die Parameter zur Steuerung der Konfiguration durch JSON-Dateien kommen in zwei Varianten. Einmal als XXXConfigurationResources und einmal als XXXConfigurationFiles. Die Resource-Form wird für alle Resources innerhalb eines Artefakts genutzt, die Files-Variante für externe Dateien. Die Wildcard ${.} verhält sich entsprechend. Die Hilfe gibt Auskunft über die möglichen Parameter: native-image –expert-options | grep Configuration.

 

Das Binary läuft ohne Ausführungsfehler (Listing 10), aber läuft es auch fehlerfrei? Nicht ganz. Nachdem ich das Binary erzeugt habe, habe ich den Text fortgesetzt. Es ist Zeit vergangen, aber das Programm zeigt auch bei wiederholtem Aufruf immer dieselbe Zeit an. Der Grund ist im TimeService zu suchen. Dieser Service hält eine Konstante vom Typ Instant, die beim ersten Zugriff auf die TimeService-Klasse laut Java Language Specification (JLS) zu initialisieren ist. Der erste Zugriff ist aber der Zugriff zur Kompilierungszeit des Images.

 
./reflection-config-1.0-SNAPSHOT
Hello GraalVM from ServiceImpl at 2020-09-15T15:02:47.572800Z
Hello, from a resource.

Wird eine Klasse schon zur Kompilierungszeit initialisiert, spart das natürlich Laufzeit, kann aber – wie in diesem Fall – zu Fehlern führen. Hier wird beschrieben, welche Klassen als Safe betrachtet werden und welche immer zur Laufzeitinitialisierung führen.

Für die Version von GraalVM, die ich zur Erstellung des Beispiels und des Artikels genutzt habe, wundert es mich, warum GraalVM diesen Zugriff als sicher betrachtet, da die java.time.* definitiv auf native Calls zurückgreifen. Für das Beispiel müssen wir daher sicherstellen, dass der kritische TimeService zur Laufzeit initialisiert wird. Das wird in einer dritten Variante der native-image.properties über den –initialize-at-run-time-Parameter gemacht:

 
Args = -H:ReflectionConfigurationResources=${.}/reflection-config.json \
       -H:ResourceConfigurationResources=${.}/resources-config.json \
       --initialize-at-run-time=ac.simons.native_story.TimeService

Damit entsteht ein korrekt lauffähiges, schnelles Binary.

Substitutions (Ersetzungen)

In meinem Team habe ich sicherstellen müssen, dass der Neo4j-Java-Treiber nativ läuft. Das war erheblich mehr Aufwand und nicht durch reine Konfiguration zu erledigen. Wir setzen Netty für SSL-Verbindungen ein. Damit entsprechende Infrastruktur überhaupt den Weg in das native Binary findet, müssen folgende Parameter gesetzt sein: –H:EnableURLProtocols=http,https –enable-all-security-services -H:+JNI. Quarkus und andere Frameworks bieten dazu entsprechende Hooks an. Die Parameter können wie in den anderen Beispielen in native-image.properties ergänzt werden.

Andere Dinge brauchten einen aktiven Ansatz, sogenannte Ersetzungen. Hier kommt das GraalVM-SVM-Projekt ins Spiel. SVM ist eine provided Dependency, die auf der JVM nicht bemerkt, aber von der SubstrateVM entsprechend ausgewertet wird. Listing 11 zeigt die entsprechenden Koordinaten.

 
<dependency>
  <groupId>org.graalvm.nativeimage</groupId>
  <artifactId>svm</artifactId>
  <version>${native-image-maven-plugin.version}</version>
  <!-- Provided scope as it is only needed for compiling the SVM substitution classes -->
  <scope>provided</scope>
</dependency>

Damit können nun package-private-Klassen erstellt werden, die innerhalb der SubstrateVM ganze Zielklassen oder Methoden austauschen oder löschen. In Listing 12 wird die reflektive Erstellung des Service durch einen konkreten Aufruf ersetzt.

 
import ac.simons.native_story.Service;
import ac.simons.native_story.ServiceImpl;

import com.oracle.svm.core.annotate.Substitute;
import com.oracle.svm.core.annotate.TargetClass;

@TargetClass(className = "ac.simons.native_story.ServiceFactory")
final class Target_ac_simons_native_story_ServiceFactory {

  @Substitute
  private Service getService() {
    return new ServiceImpl();
  }
}

@TargetClass(className = "ac.simons.native_story.Application")
final class Target_ac_simons_native_story_Application {

  @Substitute
  private static String invokeGreetingFromResource(Service service, String theName) {

    return "#" + theName + " on " + service + " should have been called.";
  }
}

class CustomSubstitutions {
}

Die Namen der Ersetzungen spielen keine Rolle, die @TargetClass-Attribute müssen hingegen exakt sein. In unserem fiktiven Beispiel kann damit reflection-config.json entfallen. Die Ersetzungen sind sehr mächtig. Die entsprechenden Ersetzungen zeigen zum Beispiel im Neo4j-Java-Driver, was möglich ist.

Der Tracing-Agent

Da wir nun den Mechanismus hinter Native Image und seiner Konfiguration kennengelernt haben, können wir uns das Leben einfacher machen. Die GraalVM stellt den Reflection-Tracing-Agenten zur Verfügung. Dieser JVM-Agent kann etliche der hier gezeigten Dinge automatisch erkennen, vorausgesetzt, die entsprechenden Codepfade werden durchlaufen.

Wird das Beispiel mit aktiviertem Agenten als JVM-Programm ausgeführt, erkennt er in diesem Fall alle Probleme. Der Agent steht im GraalVM OpenJDK zur Verfügung. Er generiert Dateien wie in Listing 13.

 
java --version
openjdk 11.0.7 2020-04-14
OpenJDK Runtime Environment GraalVM CE 20.1.0 (build 11.0.7+10-jvmci-20.1-b02)
OpenJDK 64-Bit Server VM GraalVM CE 20.1.0 (build 11.0.7+10-jvmci-20.1-b02, mixed mode, sharing)

java  -agentlib:native-image-agent=config-output-dir=only-on-jvm/target/generated-config -jar only-on-jvm/target/only-on-jvm-1.0-SNAPSHOT.jar
Hello GraalVM from ServiceImpl at 2020-09-16T07:12:27.194185Z
Hello, from a resource.

dir only-on-jvm/target/generated-config
total 32
14417465 0 drwxr-xr-x  6 msimons  staff  192 16 Sep 09:12 .
14396074 0 drwxr-xr-x  8 msimons  staff  256 16 Sep 09:12 ..
14417471 8 -rw-r--r--  1 msimons  staff  278 16 Sep 09:12 jni-config.json
14417468 8 -rw-r--r--  1 msimons  staff    4 16 Sep 09:12 proxy-config.json
14417470 8 -rw-r--r--  1 msimons  staff  226 16 Sep 09:12 reflect-config.json
14417469 8 -rw-r--r--  1 msimons  staff   77 16 Sep 09:12 resource-config.json

Der Agent generiert eine Konfiguration, die schärfer ist als unsere eigene und somit zu einem kleineren Binary führt, wie ein Blick in die reflect-config.json in Listing 14 zeigt.

 
[
  {
    "name":"ac.simons.native_story.Service",
    "methods":[{"name":"getGreetingFromResource","parameterTypes":[] }]
  },
  {
    "name":"ac.simons.native_story.ServiceImpl",
    "methods":[{"name":"<init>","parameterTypes":[] }]
  }
]

Der Agent ist ein fantastisches Tool, um eine Ausgangsbasis für die eigene Konfiguration des nativen Image zu haben.

Fazit

Es benötigt nicht viel Ehrgeiz, Java und seine Features so einzusetzen, dass Anwendungen oder Frameworks entstehen, die nicht mit GraalVMs Native Image harmonieren. Das obige Beispiel ist natürlich erzwungen konstruiert, aber die Erfahrung zeigt, dass genau solche Dinge nicht nur in alten Anwendungen und Frameworks existieren, sondern auch neu noch so geschrieben werden.

Reflektion wird in etlichen Frameworks wie Spring Core, Hibernate ORM, Neo4j OGM und Spring Data genutzt, um dynamisch Attribute von Klassen zu ermitteln und Abfragen zu generieren oder Instanzen zu hydrieren. Dependency Injection Frameworks nutzen Reflection, um Injektoren zu erstellen und Abhängigkeiten zu verbinden. Object Mapper haben natürlich im Vorfeld keine Vorstellung davon, wie ein Domain Model aussehen mag.

Viele der erwähnten und gezeigten Dinge könnten bereits elegant zur Kompilierungszeit gelöst werden, zum Beispiel durch Prozessoren, die zur Kompilierungszeit Annotationen lesen und Indizes schreiben oder Bytecode generieren. Das ist zum Beispiel der von Micronaut verfolgte Ansatz. Hibernate in Quarkus generiert Indizes für das Domain Model.

Ältere Frameworks wie Spring, die über etliche andere Libraries integrieren, haben diesen Luxus nicht. Dennoch: Auch dort wird das Tooling verbessert. Im Rahmen von Spring-GraalVM-Native durfte ich unsere aktuelle Spring-Data-Neo4j-Version ertüchtigen.

Die Mittel, die Frameworks wie Quarkus, Spring-Native und Micronaut zur Verfügung stehen – sei es in Form von programmatischen Hooks, deklarativen Annotationen oder anderem – basieren schlussendlich alle auf Konfiguration und Substitutions wie oben gezeigt. Zumindest von Spring-Native weiß ich darüber hinaus, dass SubstrateVM-Plug-ins erstellt worden sind, die der GraalVM Wissen über das Framework mitgeben.

Die Werkzeuge rund um GraalVM sind großartig und adressieren obige Probleme sowie Dinge wie JNI und Proxies. Sie sind gut dokumentiert. Es reicht allerdings nicht aus, einfach nur Klassen auf das Native-Image-Tool zu werfen und zu glauben, die entstehenden Binaries verhalten sich in jedem Fall korrekt.

Im nächsten Jahr wird sich die Welt weiterbewegt haben und es wird zum guten Ton gehören, dass Frameworks native Kompilierung unterstützen. Dabei sollte nicht vergessen werden, dass dort keine Magie, sondern sorgfältige, jedoch teilweise ermüdende Arbeit geschieht. Ermüdend deshalb, weil es erstaunlich ist, was alles verfügbar sein muss, damit eine Standardanwendung im Kontext eines Frameworks funktioniert. Die Vorteile einer nativen Framework-basierten Anwendung kommen nicht umsonst.

Hinweise

Die originale englischsprachige Version erschien im September 2020 auf meinem Blog. Mein Dank geht an meine Freunde und Kollegen Gerrit, Michael und Gunnar sowie an Oleg von Oracle, die den Artikel gegengelesen und die gröbsten Fehler und Schnitzer korrigiert haben.

Die Beispiele wurden mit GraalVM 20.1.0 CE Edition, sowohl in der JDK-8- als auch in der JDK-11-Version getestet. Es ist nicht auszuschließen, dass neuere Varianten bessere Heuristiken in der automatischen Erkennung obiger Szenarien haben.

 

Quarkus-Spickzettel


Quarkus – das Supersonic Subatomic Java Framework. Wollen Sie zeitgemäße Anwendungen mit Quarkus entwickeln? In unserem Quarkus-Spickzettel finden Sie alles, was Sie zum Loslegen brauchen.

 

Jetzt herunterladen!

Alle News der Java-Welt:
Alle News der Java-Welt:

Behind the Tracks

Agile & Culture
Teamwork & Methoden

Data Access & Machine Learning
Speicherung, Processing & mehr

Clouds, Kubernets & Serverless
Alles rund um Cloud

Core Java & JVM Languages
Ausblicke & Best Practices

DevOps & Continuous Delivery
Deployment, Docker & mehr

Microservices
Strukturen & Frameworks

Web Development & JavaScript
JS & Webtechnologien

Performance & Security
Sichere Webanwendungen

Serverside Java
Spring, JDK & mehr

Digital Transformation & Innovation
Technologien & Vorgehensweisen

Software-Architektur
Best Practices

Domain-driven Design
Grundlagen und Ausblick

Spring Ecosystem
Wissen in Spring-Technologien

Web-APIs
API-Technologie, Design und Management