JAX Blog

Welche Möglichkeiten bietet GraalVM in Spring?

Viel Licht, viel Schatten?

Feb 20, 2023

Dieser Artikel behandelt GraalVM und ihre Möglichkeiten bzw. Integration in Spring. Wir starten mit einer kleinen Historie, um zu verstehen, warum es Graal überhaupt gibt bzw. woher es kommt. Danach sehen wir uns die Funktionalitäten von Graal für Java im Allgemeinen an und danach, inwiefern Spring 6 bzw. Spring Boot 3 Unterstützung für GraalVM anbietet.

Graal selbst bietet eine Vielzahl von Features an. Bekannt ist es aber vor allem durch das Native Image, das es uns erlaubt, Java-Programme ohne Runtime wie ein klassisches Executable auszuführen. Dadurch hat man deutliche Geschwindigkeitsvorteile, das Programm startet wesentlich schneller und auch der Speicherverbrauch ist auf einem absoluten Minimum. Allerdings muss man bei länger laufenden Anwendungen auf die Optimierungen der JVM verzichten.

Historie: GraalVM

Im Gegensatz zu anderen Gebieten (J***Script) in der Softwareentwicklung ist das Java-Ökosystem auf Beständigkeit ausgerichtet. Man möchte keine Experimente und schon gar nicht alle paar Monate ein neues Tool einsetzen. Java-Programme sind (fast) für die Ewigkeit! Aus diesem Grund ist die Frage des Graal-Ursprungs eine wesentliche. Woher kommt Graal? Ist das irgendein „fancy“ Open-Source-Projekt, dessen Hype in ein paar Monaten vorbei ist? Nein. Von Kurzfristigkeit ist hier weit und breit keine Spur.

Die Anfänge von Graal begannen mit der Überlegung, dass man die Virtual Machine – gemäß dem Motto „eat your own dog food“ – in Java neuschreibt. Normalerweise verwendet man dafür C++. Das Projekt dafür hieß Maxine, hatte seinen initialen Release bereits 2005 und kam aus der Schmiede von Sun Labs, das bekanntlich von Oracle gekauft und mittlerweile auf Oracle Labs umgetauft wurde. Ein Teil dieses Vorhabens war auch das Neuschreiben des C1-Compilers in Java. Dieser läuft in der VM und kompiliert den zuvor durch javac in Bytecode transformierten Code in Maschinencode. Es gab nun die Idee, die Java-Version C1 von Maxine in die „handelsübliche“ HotSpot zu überführen. Nach diesem erfolgreichen Experiment wollte man eine Stufe höher gehen und sich den C2-Compiler vornehmen. Der C2-Compiler gilt als auch als der „Servercompiler“. Im Gegensatz zu C1-„Client Compiler“ liegt der Fokus auf Optimierung, weswegen C2 deutlich mehr Ressourcen und auch Zeit benötigt.

Die Rückführung der Compiler von Maxine in HotSpot nannte man intern die „heilige Mission“ („Holy Quest“). Die Königsdisziplin, also der Ersatz von C2, war dann dementsprechend passend der Gral. Und damit haben wir auch die Herkunft des Namens Graal behandelt, um final die Begriffe richtig einzuordnen. Mit Graal war am Anfang der Compiler genannt und die Integration bzw. was dann schlussendlich eine eigene VM werden sollte, ist GraalVM.

GraalVM emanzipierte sich von Maxine und wurde über die Jahre hinweg innerhalb von Oracle Labs weiterentwickelt. Maxine hingegen ist seit 2017 unter der Obhut der University of Manchester. In weiterer Folge verbesserten sich die GraalVM und ihr Compiler immer weiter. Es kamen neue Features hinzu, die wir im nächsten Abschnitt behandeln. Im Mai 2019 war es schließlich soweit, dass GraalVM als production-ready [1] eingestuft wurde.

GraalVM basiert intern auf HotSpot, verwendet allerdings ihren eigenen Compiler. Gibt es hierbei Inkompatibilitäten? Nein, man muss sich hier keine Sorgen machen, dass Bestandteile der Programmiersprache nicht unterstützt oder anders ausgeführt werden. Für das Native Image, das nicht der VM oder dem Compiler zuzurechnen ist, verhält sich die Sache etwas anders. Dazu aber später mehr.

Man sieht also, dass GraalVM eine sehr lange Entwicklungsgeschichte hat, dass dahinter Oracle steht und es somit eine moderne, performantere Alternative aus eigenem Haus bietet.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Das Framework “Truffle” für die GraalVM

Die GraalVM umfasst somit eine VM und einen Compiler. Das ist jedoch nicht alles. Es gibt noch zwei weitere sehr wichtige Features. Der erste Teil, der uns als Spring-Entwickler:in in den meisten Fällen nicht sehr stark tangieren wird, ist Truffle. Es ist ein Framework, mit dem man eigene oder bereits bestehende Programmiersprachen in GraalVM integrieren kann (Abb. 1). Unterstützung des „Who is who“ der Programmiersprachen ist bereits in GraalVM vorhanden. Unter anderem Python, Ruby oder auch JavaScript. Es ist auch möglich, diese Teile untereinander zu mixen. So kann beispielsweise ein in JavaScript geschriebener Programmteil auch von Java aufgerufen werden. Was JavaScript angeht, ist noch wichtig anzumerken, dass es bereits mit Nashorn eine von Oracle entwickelte JavaScript Engine gab, die allerdings mit Java 11 als deprecated erklärt wurde.

Auf Truffle aufsetzend gibt es noch die LLVM Runtime. Extrem vereinfacht ausgedrückt kann man sagen: Was Bytecode für Java, Kotlin oder Scala ist, ist die LLVM für C/C++ oder Rust. Zusammengefasst bedeutet das, dass wir sehr viele Anwendungen, die nicht in Java geschrieben wurden, mit GraalVM sehr einfach in einer JVM zum Laufen bekommen.

Abb. 1: GraalVM-Architektur mit Truffle

Native Image in der GraalVM

Der performante Compiler und die vielen Möglichkeiten, die Truffle mitbringt, machen die GraalVM bereits zu einem Werkzeug, das man in vielen Java-Projekten sofort einsetzen möchte. Der Teil, für den Graal wahrscheinlich bei den meisten der Java-Entwickler:innen bekannt ist, ist jedoch Native Image. Native Image bedeutet nichts anderes als eine Kompilierung zu Maschinencode, wie man es von C++ zum Beispiel kennt, und dem Wegfall der VM. Das ist jedoch mit einigen Einschränkungen versehen, die im Folgenden zu nennen sind.

Bei der Kompilierung zu einem nativen Image muss Graal unsere Codebasis durchforsten und fügt alle Elemente hinzu, die es aus dem reinen Programmcode herauslesen kann. Es startet also bei der public static void main und geht den ganzen Programmfluss durch. Das heißt, dass alle Klassen, die von main aus erreichbar sind, in die Kompilierung mit aufgenommen werden. Sollten irgendwelche kontextuellen Daten, die zum Beispiel während der Laufzeit erst durch das Einlesen von Umgebungsvariablen bekannt sind, dabei sein, werden diese in der Kompilierung fehlen. Konsequenterweise fällt darunter auch die Verwendung von Reflection, wobei wir beispielsweise Methoden aufrufen, oder das dynamische Laden von Klassen. Im fachtechnischen Jargon spricht man hier von der „Closed-World Assumption“, da eben nach der Kompilierung die Tür geschlossen ist und man nachträglich keine weiteren Dinge mehr hinzufügen kann. Bezüglich der Erreichbarkeit der einzelnen Elemente spricht man von Reachability.

Das Native Image ist grundsätzlich immer schneller als die Interpretation des Bytecodes. Es muss jedoch beachtet werden, dass die sogenannte Peak Performance bei Verwendung der VM nicht erreicht werden kann. Die Peak Performance gewinnt die VM, indem sie durch sorgfältiges Profiling unsere Anwendung zur Laufzeit wesentlich besser kompilieren kann, da es weiß, wie die Anwendung verwendet wird. Das setzt allerdings eine langläufige Anwendung voraus. In der neuen Cloudwelt, in der die Instanzen nur eine kurze Lebensdauer haben, wird die Peak Performance ohnehin nicht erreicht.

NEUES AUS DER JAVA-ENTERPRISE-WELT

Serverside Java-Track entdecken

 

Set-up GraalVM

Schauen wir uns das Ganze einmal selbst an und installieren uns die GraalVM auf unserer Maschine. Dazu navigieren wir nach [2]. Unter den ersten Punkten sehen wir die Downloads, in denen wir die Option zwischen Community und Enterprise haben. Wir downloaden nicht die Community Edition, sondern die Enterprise Edition, zum Zeitpunkt dieses Artikels Version 22.3.

Die geneigte Leserschaft wird sich nun sicherlich fragen, wieso die – wahrscheinlich kostenpflichtige – Enterprise Edition, wenn doch die Community Edition gratis zu beziehen sein wird? Der Grund liegt darin, dass die Enterprise Edition für Entwicklungstätigkeiten gratis ist, wir allerdings eine wesentlich höhere Performance bekommen als von der Community. Erst für den produktiven Einsatz sind Gebühren bei der Enterprise Edition notwendig, aber da können wir wieder auf die Community Edition ausweichen.

Wir klicken also auf Download (Abb. 2), was uns zu einem weiteren Bildschirm führt, wo wir Java-Plattform, Betriebssystem und Architecture auswählen. In diesem Artikel wurde Java 17, macOS und aarch64 verwendet. Zum Austesten nehmen wir eine Abwandlung von den offiziellen Graal-Beispielen (Listing 1, Listing 2).

Abb. 2: Downloadbildschirm

public class Main {
  public static void main(String[] args) throws Exception {
    var blender = new Blender();
    for (int j = 0; j < 10; j++) {
      long t = System.nanoTime();
      for (int i = 0; i < 100; i++) {
        blender.run();
      }
      long d = System.nanoTime() - t;
      System.out.println(d / 1_000_000 + " ms");
    }
  }
}
public class Blender implements Runnable {
 
  public static final Colour[][][] colours = new Colour[100][100][100];
 
  @Override
  public void run() {
    var id = new Colour(0, 0, 0);
    for (int x = 0; x < colours.length; x++) {
      Colour[][] plane = colours[x];
      for (int y = 0; y < plane.length; y++) {
        Colour[] row = plane[y];
        for (int z = 0; z < row.length; z++) {
          Colour colour = new Colour(x, y, z);
          colour.add(id);
          if ((colour.r + colour.g + colour.b) % 42 == 0) {
            row[z] = colour;
          }
        }
      }
    }
  }
 
  public static class Colour {
    double r, g, b;
 
    Colour(double r, double g, double b) {
      this.r = r;
      this.g = g;
      this.b = b;
    }
 
    public void add(Colour other) {
      r += other.r;
      g += other.g;
      b += other.b;
    }
  }
}

Führen wir diesen Code einmal mit dem aktuellen OpenJDK 17.0.5 aus (Abb. 3), dauert es auf der Maschine des Autors 58 Sekunden. Tauschen wir nun OpenJDK durch GraalVM aus, reduziert sich die Zeit auf 53 Sekunden. Man sieht, dass die Ausführung mit der GraalVM (Abb. 4) schneller ist, doch nicht unbedingt in einem Ausmaß, das einen vom Hocker haut. Es handelt sich hier allerdings lediglich um eine andere VM mit einem besseren Compiler. Das Native Image ist noch nicht im Einsatz.

Fairerweise ist hier auch anzumerken, dass ein Beispiel, das wir von der GraalVM-Seite nehmen, natürlich für GraalVM optimiert ist. Aber es steht der geschätzten Leserschaft frei, eigene Codebeispiele zu nehmen.

Abb. 3: Ausführung OpenJDK

Abb. 4: Ausführung GraalVM

Native Image allgemein

Es wird Zeit, Gas zu geben. Wir bleiben bei unserem Blender-Beispiel und wandeln es in ein Native Image um.

Installation und Ausführung

Als Erstes müssen wir das Native Image installieren. Mit dem Befehl gu list sehen wir, dass bis dato nur die GraalVM Core installiert ist. Das Native Image beziehen wir mittels

gu install native-image

Bei der Verwendung der Enterprise Edition wird man nun aufgefordert, die E-Mail-Adresse anzugeben. Es wird dann ein Link an diese Adresse verschickt, den es zu bestätigen gilt. Danach kann man die Installation über die Konsole fortsetzen. Nun folgt die eigentliche Kompilierung, bei der wir unseren Blender in Bytecode überführen und von dort das Native Image bauen.

javac Main
native-image Main

Die Ausführung starten wir dann ganz normal (auf macOS) mittels

./main

Und siehe da (Abb. 5), von den anfangs 53 Sekunden (GraalVM), sind wir auf einmal bei acht Sekunden. Dazu sagen wir natürlich nicht nein.

Abb. 5: Ausführung Native Image

Einschränkungen durch Closed-World-Ansatz

Wie oben erwähnt, ist im Closed-World-Ansatz das Nachladen etc. nicht möglich, was auch sehr sinnvoll ist. Das erzeugte Executable läuft ohne JVM und es soll nur die Elemente beinhalten, die es wirklich benötigt. Unnützen Ballast möchten wir abwerfen. Dieses Verfahren wird in der Welt der Frontend Frameworks vom Prinzip her schon länger angewandt. Nur ist es dort unter dem Namen Tree Shaking bekannt.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Können wir also unser Beispiel so umkonstruieren, dass wir eine Dynamik hineinbekommen, bei der unser natives Image nicht mehr funktioniert? Wir können Blender mittels Class.forName dynamisch laden. Wir ändern die Main.java dahingehend ab und fügen noch hinzu, dass der Klassenname über Kommandozeile als Argument übergeben wird (Listing 3).

public class Main {
  public static void main(String[] args) throws Exception {
    String className = args[0];
    Runnable blender = (Runnable)Class.forName(className).getDeclaredConstructor().newInstance();
    for (int j = 0; j < 10; j++) {
      long t = System.nanoTime();
      for (int i = 0; i < 100; i++) {
        blender.run();
      }
      long d = System.nanoTime() - t;
      System.out.println(d / 1_000_000 + " ms");
    }
  }
}

Danach kompilieren wir nochmals Main und erstellen das Native Image:

javac Main.java
native-image Main
./main Blender

Es klappt zwar, allerdings mit Warnmeldungen und plötzlich sehr langsam. GraalVM sieht, dass wir hier über Reflection eine unbekannte Klasse laden, und geht auf Nummer sicher, indem es ein „Fallback“ Image generiert. Das ist nicht das, was wir wollen. Das Fallback Image bedeutet, dass die JVM wieder im Spiel ist. Wir müssen bspw. nur das Executable in einen anderen Ordner kopieren und es von dort aus neu ausführen. Es wird nicht funktionieren. Es gibt die Möglichkeit, das Fallback Image zu deaktivieren. Das können wir bei der Erzeugung des Image mittels eines Flags definieren.

native-image Main --no-fallback

Nach Neuausführung bekommen wir danach eine altbekannte Fehlermeldung:

Exception in thread "main" java.lang.ClassNotFoundException: Blender 

Der Grund, wieso überhaupt ein Fallback Image erstellt wird, ist ein rechtlicher [3]. Oracle ist hier offenbar gezwungen, dass das Native Image auf alle Fälle eine lauffähige Anwendung ist – selbst wenn die VM inkludiert ist. Hauptsache, es läuft. Aber sowohl mit als auch ohne Fallback kommen wir zu einem unbefriedigenden Ergebnis. Unsere Anwendung läuft nicht. In diesem Fall müssen wir selbst Hand anlegen und dem Native Image mitteilen, welche Klassen wir dynamisch laden, damit es diese auch während der Kompilierung mit aufnimmt. Das erfolgt mittels eigener Konfigurationsdateien. Diese können und werden bei größeren Umgebungen durchaus ausarten. Bei unserem Beispiel ist das nicht der Fall. Wir müssen lediglich die Blender-Klasse angeben. Dazu erstellen wir eine neue Datei namens reflect-config.json mit dem Inhalt aus Listing 4.

[
  {
    "name": "Blender",
    "allDeclaredConstructors": true
  }
]

Danach starten wir die Kompilierung und Ausführung erneut. Dieses Mal geben wir jedoch die Konfigurationdatei dazu:

native-image Main -H:ReflectionConfigurationFiles=reflect-config.json
./main Blender

Jetzt klappt es ohne Schwierigkeiten. Wir müssen uns aber natürlich im Klaren sein, dass wir durch eine fehlerhafte Konfiguration Fehler in der Laufzeit erzeugen können.

Es können – auch mehrere Konfigurationsdateien eingesetzt werden, und das ist auch der Fall. Konventionsmäßig gibt man diese in den Ordner META-INF/native-image und gruppiert sie dann in einem weiteren Unterordner mittels ihrer groupId und artifactId. GraalVM versucht, so viel wie möglich von dem dynamischen Verhalten abzudecken. Sollten wir bspw. den Klassennamen als statischen String verwenden, brauchen wir weder eine Konfigurationsdatei noch wird ein Fallback Image erstellt:

Runnable blender = (Runnable)Class.forName("Blender").getDeclaredConstructor().newInstance();

Metadata bei großen Projekten

Wir haben gesehen, dass das Native Image mit seinem Closed-World-Ansatz je nach Dynamik einmal mehr und einmal weniger Hilfe benötigt. Das hier vorgeführte Beispiel ist auf das absolute Minimum reduziert. Wir haben keine packages, builden kein jar, geschweige denn setzen wir Maven oder Gradle ein. In einem professionellen Umfeld würden wir hier entsprechende Plug-ins einsetzen, bei denen das Native Image in den Build integriert ist. Auch ist bei Java die Dynamik nicht nur auf Reflection basiert. Es gibt noch Proxies, Ressourcen usw. In diesem Fall gibt die offizielle Dokumentation [4] ausführlich Auskunft.

Das Erstellen der Konfigurationsdateien kann semiautomatisch durchgeführt werden. Eine gängige Variante ist, die Anwendung normal über die JVM hochzufahren, mit ihr zu arbeiten, während im Hintergrund ein Agent läuft, der die Konfiguration automatisch erstellt. Bei unserem Beispiel machen wir das mittels des Befehls:

java -agentlib:native-image-agent=config-merge-dir=META-INF/native-image Main Blender

Bei der Ausführung werden durch die merge-Option Warnungen kommen, weil es das Verzeichnis noch nicht gibt. Das können wir ignorieren. Nachdem wir aber die Konfigurationsdateien bereits in das Standardverzeichnis erzeugt haben, werden sie automatisch beim nächsten native-image Main mitverwendet. Wir können jetzt auch unsere selbstgestrickte reflect-config.json löschen. Wenn wir jetzt nochmals main ausführen, sollte es keine Schwierigkeiten mehr geben.

Für Drittbibliotheken müssen wir das Rad nicht neu erfinden. Es gibt hier beispielsweise das sogenannte GraalVM Reachability Metadata Repository [5] auf GitHub, wo Bibliotheken ihre Konfigurationsdateien hinterlegen. Man kann dort auch sehr schön erkennen, wieso man die Konfigurationen nicht selbst schreiben sollte. Die reflect-config.json von Hibernate 6.1.1 hat zum Beispiel über 12 000 Zeilen.

 

Zusammenfassung zu Native Images

Native Image beschleunigt unsere Anwendungen signifikant. Durch das Closed-World-Prinzip müssen wir jedoch mit Einschränkungen rechnen bzw. durch Metadaten dem Compiler auf die Sprünge helfen. Als Nächstes wenden wir uns dem Einsatz von Native Image in Spring zu.

Native Image in Spring

Spring Ahead-of-Time

Wir haben bereits gesehen, dass bei dynamischen Java-Applikationen der Konfigurationsaufwand nicht zu unterschätzen ist. Wie verhält es sich jetzt mit Frameworks, wie zum Beispiel Spring, bei dem dynamisches Java das Fundament bildet?

Beispielsweise ist der Einsatz von Proxies omnipräsent und Features wie Profile über @Profile sind prinzipiell ausgezeichnet, allerdings schwer vereinbar mit dem Closed-World-Prinzip. Wenn wir uns ansehen, wie andere Frameworks damit umgehen, erkennen wir, dass zum Beispiel Micronaut [6], die komplette Dynamik bereits während des Builds durchführt. Das heißt, dass im kompilierten Bytecode, der dem Native Image vorgelegt wird, bereits alles statisch ist. Bei Spring wird dies bekanntlich während der Laufzeit gemacht.

Befinden wir uns also mit Spring in einer Sackgasse bzw. müsste man Spring von Grund auf neuprogrammieren, damit es ähnlich verfährt wie Micronaut & Co? Dadurch, dass die GraalVM nicht plötzlich und vollkommen unerwartet erschienen ist, hatte Spring natürlich dementsprechend viel Vorlaufzeit, um sich darauf vorzubereiten. Die Arbeiten begannen bereits vor 2019, jedoch wurde 2019 unter dem Namen Spring Native [7] ein GraalVM-spezifisches Projekt in den Incubator-Status erhoben. Es hatte die Aufgabe, Möglichkeiten herauszufinden und auszuprobieren, wie man Spring am besten an die Erfordernisse von GraalVM anpasst. Nach erfolgreicher Mission wurde Spring Native mehr oder weniger als deprecated erklärt und das Resultat floss direkt in Spring 3.0, wo nun die Unterstützung für GraalVM nativ dabei ist.

Das Erstellen eines Native Image in Spring setzt sich aus zwei Teilen zusammen: der Vorbereitung und dem eigentlichen Build des Native Image, das von Graal übernommen wird. Bei der Vorbereitung transformiert Spring den bestehenden Source Code dahingehend, dass er nicht mehr auf Reflection oder sonstigen dynamischen Elementen basiert. Das heißt, die ganzen Konfigurationen werden dahingehend umgeschrieben, dass die main-Methode spezielle BeanFactories aufruft, die das direkte Resultat der Transformation sind. Damit ist es für das Native Image sehr einfach herauszufinden, welche Klassen benötigt werden.

Diese „Transformation“ wird bei Spring die Ahead-of-Time Compilation genannt. Man muss sie nicht zwangsweise verwenden, um ein Native Image zu erstellen. Man kann sie auch direkt innerhalb einer VM ausführen. Das hat dann den Vorteil, dass das Auffinden der Beans nicht mehr zur Laufzeit stattfindet, wodurch man bereits einen schnelleren Start-up hätte. Wir gehen aber einen Schritt weiter und möchten ein Native Image bauen (Abb. 6).

Abb. 6: Graal Native Image mit Spring

Um Spring Boot 3 mit der GraalVM-Integration zu installieren, erstellen wir ein neues Projekt über den Initializr [8] und wählen als Abhängigkeiten GraalVM Native Support sowie Spring Web aus. Die restlichen Optionen lassen wir einfach auf Standard. In diesem Artikel wurde jedoch com.rainerhahnekamp als groupId und graalspring als artifactId verwendet.

Es ist an dieser Stelle anzumerken, dass mit Spring Boot 3 Gradle als standardmäßiges Build-Tool eingesetzt wird. Wer also nach wie vor mit Maven arbeitet, muss das explizit anwählen.

Wir verwenden 1:1 den Blender von früher, statten ihn aber mit @Service aus, sodass er als Bean erkannt wird. Des Weiteren fügen wir einen BlenderController hinzu, in dem der Blender injectet werden soll (Listings 5 und 6).

package com.rainerhahnekamp.graalspring;
 
@Service
public class Blender {
 
  public static final Colour[][][] colours = new Colour[100][100][100];
 
  // Implementierung von Blender aus vorigem Beispiel hineinkopieren
}
package com.rainerhahnekamp.graalspring;
 
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("blender")
public class BlenderController {
  private final Blender blender;
 
  BlenderController(Blender blender) {
    this.blender = blender;
  }
 
  @GetMapping("")
  public String blend() {
    blender.blend();
    return "blended";
  }
}public interface Blender {
  void blend();
}

Mit ./gradlew bootRun starten wir den Server, und wenn wir über den Browser http://localhost:8080/blender aufrufen, sollte nach einiger Zeit blended ausgegeben werden. Durch bootRun wurde aber auch schon Spring AoT aktiv. Der generierte Source Code ist im Verzeichnis build/generated/aotSources. Dort finden wir auch die für GraalVM optimierte Registrierung der Blender und BlenderController als Beans unter den Dateinamen Blender__BeandDefinitions.java sowie BlenderController__BeanDefinitions.java. Noch interessanter ist die Datei SpringGraalApplication__BeanFactoryRegistrations.java, in der alle Bean Definitions zusammenkommen. So sehen wir neben den Blendern auch die Beans, die von Spring direkt kommen.

Theoretisch könnten wir bootRun bzw. den eigentlichen Build bereits mit den AoT-Klassen starten. Für bootRun muss man dafür in der build.gradle folgenden Eintrag hinzufügen:

bootRun {
  systemProperty("spring.aot.enabled", "false")
}

Bei der erneuten Ausführung von bootRun sollte in der Konsole die Meldung Starting AOT-processed SpringGraalApplication  vorkommen. Es ist auch zu beachten, dass bereits eine Menge an Metadatenkonfiguration vorhanden ist. Diese befindet sich unter /build/aotResources/META-INF/ …

Nun lassen wir das Native Image erstellen. Mittels ./ gradlew nativeCompile wird an GraalVM das Native Image übergeben und wir sehen die bekannte Ausgabe, die wir bereits in unseren vorigen Experimenten hatten. Im Unterschied zu vorher wird allerdings die Erstellung des Native Image deutlich länger dauern. Wir müssen berücksichtigen, dass es sich hier nicht um zwei Java-Klassen handelt, sondern das Spring Framework mit Spring MVC dabei ist.

Native Image als Docker Image

Ein Problem von Native Images ist, dass sie speziell auf unseren Rechner zugeschnitten sind. Builde ich also auf macOS, werde ich nicht in der Lage sein, dies auf Windows-Rechnern laufen zu lassen. Das alte Java-Motto „Write once, run anywhere“ ist hier nicht gültig. Diese Aufgabe hat bei modernen Deployments allerdings bereits Docker übernommen. Das heißt, wenn wir unser Native Image in ein dazugehöriges Docker Image stecken, haben wir an und für sich die Plattformunabhängigkeit wieder zurückgewonnen.

Was ist also zu tun? Spring ist bereits mit allem ausgestattet. Wir brauchen lediglich einen laufenden Docker Daemon und können dann mittels ./gradlew buildBootImage unser Native Image direkt in einem Docker Image builden, das wir dann überallhin deployen können.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Closed-World in Spring

Das Closed-World-Prinzip trifft natürlich auch auf Spring zu. Wie können wir also dort mit dynamischem Java umgehen? Nun, extrahieren wir erst einmal von unserem Blender ein Interface. Das heißt, unser Blender.java teilt sich nun in zwei Klassen auf und wir verwenden eine eigene Konfigurationsklasse, die die Bean registriert (Listing 7, 8 und 9).

public interface Blender {
  void blend();
}
public class BlenderImpl implements Blender, Runnable {
 
  public static final Colour[][][] colours = new Colour[100][100][100]; 
}
@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Bean
  public Blender getBlender() {
    return new BlenderImpl();
  }
}

Nun geben wir jedoch eine zweite Implementierung von Blender hinzu, die bei dem Profil dev verwendet werden soll. Bei dem Profil default soll nach wie vor BlenderImpl verwendet werden. Dazu ändern wir unsere AppConfiguration.java dahingehend ab (Listing 10).

@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Bean
  @Profile("default")
  public Blender getBlender() {
    return new BlenderImpl();
  }
 
  @Bean
  @Profile("dev")
  public Blender getDevBlender() {
    return new Blender() {
      @Override
      public void run() {
        System.out.println("DevBlender tut nichts");
      }
    };
  }
}

Ein kurzer Check mittels SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun und der Aufruf von http://localhost:8080/blender sollte in der Konsole DevBlender tut nichts erscheinen lassen. Wie erwartet, funktionieren hier die Profile.

Builden wir hingegen das native Image neu und starten es dann mit einem aktiven (Umgebungsvariable-setzen-)Profil dev, dann werden wir beim Hochfahren von Spring sehen, dass hier das dev-Profil aktiv ist. Allerdings wird die Ausgabe DevBlender nicht kommen. Es wird nämlich – egal welches Profil wir setzen – immer BlenderImpl verwendet.

Im AoT-generierten Quellcode werden wir auch keine Spur von unserem DevBlender finden. Es ist nur die Standardimplementierung vorhanden. Der Grund liegt darin, dass bei der Generierung dieser Dateien (also durch AoT) Spring im Hintergrund hochgefahren wird und – in unserem Beispiel – Code erzeugt wird, der die unterschiedlichen Konfigurationsklassen explizit aufruft. Nachdem bei den Tasks nativeCompile bzw. aotClasses kein Profil angegeben wurde, ist das default-Profil im Einsatz. Andersherum ausgedrückt: Das @Profile ist nicht mehr dynamisch, sondern wird zur Build-Zeit fixiert. Man könnte nun theoretisch beim Task nativeCompile das Profil dev setzen. Das würde dann den DevBlender als Bean registrieren. Und es wäre komplett egal, mit welchem Profil wir dann das Native Image starten. Es wird immer der DevBlender sein.

Die Verwendung von @Profile in Verbindung mit Spring AoT ist etwas verwirrend. Aus diesem Grund sollte man auf @Profile beim Einsatz von GraalVM bzw. AoT verzichten. Auch von Ableitungen, wie zum Beispiel @ConditionalOnProperty, sollte man die Finger lassen. Detaillierte Informationen sind in der Dokumentation [9] bzw. im Wiki [10] nachschlagbar.

Was macht man allerdings nun, wenn man trotzdem konditionelle Abhängigkeiten hat? Man könnte die Implementierung des Blenders über eine Property definieren. Wir würden also unsere AppConfiguration.java umgestalten, wie in Listing 11 gezeigt.

@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Value("${app.blender-type}")
  public String blenderType;
 
  @Bean
  public Blender getBlender() {
    if ("dev".equals(this.blenderType)) {
      System.out.println("Providing DevBlender");
      return new Blender() {
        @Override
        public void run() {
          System.out.println("DevBlender tut nichts");
        }
      };
    }
 
 
    System.out.println("Providing BlenderImpl");
    return new BlenderImpl();
  }}

Es gibt nun eine Methode, die abhängig von der gesetzten Property den DevBlender oder BlenderImpl zurückgibt. Zur Laufzeit setzen wir dann den Wert der Property über eine Umgebungsvariable und haben das gewünschte Ergebnis. Es ist jedoch hier zu beachten, dass im Native Image beide Blender-Implementierungen enthalten sind. Das wirkt sich negativ auf die Größe aus. Spring AoT könnte natürlich dasselbe machen und alle Profile in das Native Image laden. Man hat sich allerdings gegen diese Vorgehensweise entschieden.

Spring und Metadata

Gut, wie geht aber nun Spring AoT vor, wenn wir in unserem eigenen Programmcode Reflection verwenden? Dazu laden wir die BlenderImpl über Class.forName dynamisch nach. Die geänderte AppConfiguration sieht nun aus, wie in Listing 12 gezeigt.

@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Value("${app.blender-class}")
  public String blenderClass;
 
  @Bean
  public Blender getBlender() throws Exception {
    return (Blender)
Class.forName(blenderClass).getDeclaredConstructor().newInstance();
  }
}

Durch das Wissen, das wir mittlerweile angesammelt haben, können wir schon voraussagen, was passieren wird. Wir werden einen ClassNotFoundException bekommen. Die BlenderImpl wird über den normalen Programmcode (verfolgt man von der static void main() die imports) nicht erreichbar sein und ist aus dem Grund auch nicht beim Image dabei.

Wenn wir in /build/generated/aotResources/**/reflect-config.json nach BlenderImpl suchen, werden wir nichts finden. Wir können nun einen entsprechenden Eintrag in der reflect-config.json vornehmen oder uns wieder des Agents bedienen. Ist alles kein Problem, nur dass es von Spring eine typensichere Alternative gibt. Diese kommt in der Form des Interface RuntimeHintsRegistrar. Wir implementieren es und erhalten über die zu implementierende Methode registerHints mit RuntimeHints ein Objekt, mit dem wir typensicher den Constructor für die BlenderImpl für die Reflection registrieren können. Wir erstellen dafür eine eigene Klasse, betten diese aber sogleich als statische verschachtelte Klasse in unsere AppConfiguration ein (Listing 13).

@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(AppConfiguration.RegistryHinter.class)
public class AppConfiguration {
  @Value("${app.blender-class}")
  public String blenderClass;
 
  @Bean
  public Blender getBlender() throws Exception {
    return (Blender) Class.forName(blenderClass).getDeclaredConstructor().newInstance();
  }
 
  public static class RegistryHinter implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
      hints.reflection().registerConstructor(BlenderImpl.class.getDeclaredConstructors()[0], ExecutableMode.INVOKE);
    }
  }
}

Wir sehen, dass wir mittels @ImportRuntimeHints explizit angeben müssen, dass es RegistryHinter gibt. Diese wird auch nur dann aktiviert, wenn GraalVM die AppConfiguration als erreichbar ansieht (wofür Spring AoT sorgt). Also noch einmal das Native Image erstellen, beim Starten die Umgebungsvariable APP_BLENDER_CLASS auf den vollen Namen der BlenderImpl setzen, und dann sollte es funktionieren. Zur Sicherheit kann man sich auch in der reflect-config.json davon überzeugen, dass dieses Mal die BlenderImpl auftaucht.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Testen von Native Image mit Spring Boot

Wir möchten natürlich überprüfen können, ob das Native Image wie geplant funktioniert. In unserem Fall würde das bedeuten, dass wir einen Test benötigen, der die entsprechende Property setzt. Wir können mit Hilfe von Spring Boot einen sehr einfachen Test schreiben, der aussieht, wie in Listing 14 gezeigt.

@SpringBootTest(properties =
{"app.blenderClass=com.rainerhahnekamp.graalspring.BlenderImpl"})
class BlenderControllerTest {
  @Autowired
  BlenderController controller;
 
  @Test
  void testBlendShouldNotThrow() {
    assertDoesNotThrow(() -> controller.blend());
  }
}

Wir können den Test starten und er wird funktionieren, weil er ganz einfach in der JVM läuft und dort das Class.forName kein Problem ist. Was wir jedoch wirklich möchten, ist, dass dieser Test gegen das Native Image ausgeführt wird. Auch hier hat bereits Spring vorausschauend etwas für uns vorbereitet. Wir müssen lediglich folgenden Befehl ausführen:

./gradlew nativeTest

Hier wird erst einmal das Native Image erstellt und die Tests werden auch dagegen ausgeführt. Bitte bei diesem Beispiel darauf achten, dass es nur diese eine Testdatei gibt, in der auch der richtige Wert für die Property gesetzt wird. Normalerweise kommt der Spring Initializr bereits mit einem vorkonfigurierten Test, der hier fehlschlagen wird.

Abschluss: GraalVM in Spring

„Wo viel Licht ist, ist auch viel Schatten.“ – trifft dieses Sprichwort auch auf GraalVM zu? Man muss hier einerseits Jürgen Höller zitieren, der in seinem Vortrag über Spring 6 [11] meinte, der Einsatz von GraalVM führe zu einer Performancesteigerung. Wie viel das ist, hängt allerdings vom Einsatzfall ab und ist immer hochindividuell. Klar ist, dass der großflächige Produktiveinsatz von GraalVM erst im Entstehen ist. Es wäre illusorisch anzunehmen, dass nicht das eine oder andere Problemchen noch irgendwo auftaucht. Daneben gilt es auch noch andere Aspekte zu beachten, für die jedoch hier auf [12] verwiesen werden soll.

Als Nächstes kann der Autor nur empfehlen, GraalVM auf eine bestehende Spring-Applikation auszuführen. Aber natürlich erst, wenn diese auch auf Spring 6 bzw. Spring Boot 3 läuft. Zur weiteren Vertiefung bieten sich der Vortrag von Stéphane Nicoll und Brian Clozel von der Devoxx 2022 [13] und natürlich die offizielle Dokumentation [14] an.

Cloudarchitekturen zwingen unsere Java-Anwendungen, schneller zu starten und einen kleineren Memory Footprint (Kosten) zu haben. Ist das „alte Java“ für die modernen Zwecke nicht mehr passend? Obwohl es bezüglich des Real-World-Einsatzes noch in den Kinderschuhen steckt, haben wir eine Antwort auf diese Frage: GraalVM.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 


Links & Literatur

[1] https://blogs.oracle.com/java/post/for-building-programs-that-run-faster-anywhere-oracle-graalvm-enterprise-edition

[2] https://www.graalvm.org/

[3] https://github.com/oracle/graal/issues/2648#issuecomment-788780365

[4] https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/

[5] https://github.com/oracle/graalvm-reachability-metadata

[6] https://micronaut-projects.github.io/micronaut-aot/latest/guide/

[7] https://github.com/spring-projects-experimental/spring-native

[8] https://start.spring.io

[9] https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html#native-image.introducing-graalvm-native-images.understanding-aot-processing

[10] https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-with-GraalVM

[11] https://www.youtube.com/watch?v=mitWK_DwKGs&t=1923s

[12] https://www.infoq.com/articles/native-java-aligning/

[13] https://youtu.be/TS4DpYSmfXk

[14] https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html

[15] Podcast mit Thomas Würthinger: https://poddtoppen.se/podcast/1296655154/airhacksfm-podcast-with-adam-bien/from-maxwell-over-maxine-to-graal-vm-substratevm-and-truffle

[16] https://youtu.be/h419kfbLhUI

[17] Würthinger, Thomas: GraalVM History, Status, Vision: https://www.graalvm.org/uploads/workshop-nov-2019/2019-11-25-GraalVMCommunityWorkshop-HistoryStatusVision.pdf

[18] Interview mit Thomas Würthinger: https://www.infoq.com/news/2018/04/oracle-graalvm-v1/

[19] https://openjdk.org/projects/graal/

[20] https://www.graalvm.org/

[21] https://maxine-vm.readthedocs.io/en/latest/#

[22] https://maciejwalkowiak.com/blog/spring-boot-3-native-image-not-a-free-lunch/

Alle News der Java-Welt:

Behind the Tracks

Agile, People & Culture
Teamwork & Methoden

Clouds & Kubernetes
Alles rund um Cloud

Core Java & Languages
Ausblicke & Best Practices

Data & Machine Learning
Speicherung, Processing & mehr

DevOps & CI/CD
Deployment, Docker & mehr

Microservices
Strukturen & Frameworks

Performance & Security
Sichere Webanwendungen

Serverside Java
Spring, JDK & mehr

Software-Architektur
Best Practices

Web & JavaScript
JS & Webtechnologien

Digital Transformation & Innovation
Technologien & Vorgehensweisen

Domain-driven Design
Grundlagen und Ausblick

Spring Ecosystem
Wissen in Spring-Technologien

Web-APIs
API-Technologie, Design und Management