Einiges hat sich seit der letzten LTS Version (Java 11) getan. Im September 2021 wurde die neue LTS Version, Java 17, veröffentlicht und langsam wird es Zeit einen Blick in diese neue Version zu werfen. Es lohnt sich vor allem für Projekte, die noch Java 8 verwenden, denn der Java 8 Support ist seit März 2022 ausgelaufen. Auch Java 11 Projekte sollten langsam umstellen, denn der offizielle Support für Java 11 endet im September 2023.
In diesem Artikel wollen wir einen kurzen Blick auf neue Sprachfeatures werfen und wie man sie am besten verwenden kann. Außerdem wollen wir uns ein Problem anschauen, welches beim Upgrade auf Java 17 in der Konstellation von Spring-Boot und JAX-WS auftreten kann.
Neues keyword „var“
Bisher war es nötig, den Typ einer Variable bei der Deklaration vollständig auszuschreiben. Das neue Keyword var
macht damit Schluss und sorgt stattdessen dafür, dass der Compiler selbständig den korrekten Typ ermittelt.
var elements = new HashMap<String, HashMap<String, List<Object>>>();
Durch die Verwendung von var
ist die Variablendeklaration deutlich kompakter geworden und hat damit deutlich an Lesbarkeit gewonnen. Durch den Konstruktoraufruf bleibt der Typ von elements
außerdem weiterhin klar ersichtlich. Möchte man die Lesbarkeit seines Codes verbessern, ist var
also ein gutes Mittel. Allerdings sollte man darauf achten, dass man die Typen seiner Variablen nicht verschleiert. Ist der Typ einer Variable nicht durch die direkte Codenachbarschaft ersichtlich, sollte auf var
verzichtet werden. Denn nicht zu wissen, welchen Typ eine Variable hat, erschwert das Lesen von Code erheblich.
Neuer Klassentyp „record“
Häufig ist es nötig, einen komplexen Parameter, Rückgabewert oder auch Schlüssel für eine HashMap zu definieren. Eigene Klassen zu erstellen, nur um Daten zwischen Methoden zu transportieren produziert viel Code für wenig Effekt. Insbesondere wenn noch eine toString() oder hashCode() und equals() Methode dazu kommt. Der neue Klassentyp record
reduziert die nötigen Zeilen Code für Datenklassen erheblich.
record SearchResult(List<String> elements, String error) { }
Was hier im Wesentlichen definiert wurde, ist der Konstruktor. Aus dem Konstruktor leitet der Compiler ab, welche Felder das record hat. Ein record kann überall da definiert werden, wo auch eine normale Klasse definiert werden kann und hat folgende Eigenschaften:
- Alle Felder sind final
- Es gibt keine Setter
- Es gibt eine Standardimplementierung von toString(), hashCode() und equals()
Außerdem kann man, wie in einer normalen Klasse, Methoden definieren. Das ist vor allem dann nützlich, wenn man einzelne Felder mit Standardwerten initialisieren will. Dazu sollte man statische Factory-Methoden nutzen. Folgendes Pattern erhöht die Lesbarkeit in meinen Augen enorm, da durch die Factory-Methoden direkt erkennbar wird, ob der Rückgabewert einen Fehler repräsentiert oder nicht.
record SearchResult(List<String> elements, String error) {
public static SearchResult failed(String error) {
return new SearchResult(null, error);
}
public static SearchResult succeeded(List<String> elements) {
return new SearchResult(elements, null);
}
public boolean isSuccessfull() {
return error == null;
}
}
public SearchResult search(String keyword) {
try {
...
return SearchResult.succeeded(foundElements);
} catch(SearchException e) {
return SearchResult.failed("Something did not work");
}
}
Möchte man auf ein Feld zugreifen, geht das folgendermaßen
List<String> resultElements = result.elements();
Ich persönlich verwende records meistens, um komplexe Rückgabewerte zu definieren. Sowohl von public als auch von private Methoden. In der Regel lege ich für records keine neuen Dateien an, sondern definiere sie einfach am Ende der Klasse, in der sie als Rückgabewert verwendet werden.
Switch-Expressions
Der große Unterschied zwischen dem normalen Switch-Statement und der Switch-Expression ist, dass die Switch-Expression, genau wie eine Methode, einen Rückgabewert hat.
String weekend = switch(today) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "no";
case SATURDAY, SUNDAY -> {
System.out.println("yes");
yield "yes";
}
}
Man hat die Wahl zwischen yield
oder der direkten Rückgabe des Wertes. yield
wird für einen mehrzeiligen Codeblock verwendet.
Eine weitere Besonderheit der Switch-Expression ist, dass der Compiler sicherstellt, dass alle Fälle behandelt werden. Wurde ein Fall vergessen, ist das ein Kompilierfehler. Es sei denn, es wurde ein default case angegeben.
Durch die Sicherstellung des Compilers, dass alle Fälle abgedeckt werden, ist die Switch-Expression dem Switch-Statement wo immer möglich vorzuziehen. Außerdem ist eine Variablenzuweisung mittels Switch-Expression kompakter.
Instanceof pattern matching
Instanceof checks sind nun etwas kompakter möglich:
if(handler instanceof CustomStringHandler stringHandler) {
stringHandler.handle(str);
}
Der Name der gecasteten Variable kann nun direkt im instanceof check angegeben werden, anstatt dass die Variable nochmal explizit gecastet werden muss.
Text Blocks
Es gibt nun mehrzeilige Strings. Sie sind vor allem nützlich beim Schreiben von SQL-Statements.
String query = """
SELECT * FROM user u
WHERE u.id = 123
AND u.deleted = false
""";
Wichtig im Hinterkopf zu haben ist, dass das was man sieht nicht das ist, was man bekommt. Der Compiler entfernt die Einrückung soweit wie möglich. Der eigentliche String ist also:
"""
SELECT * FROM user u
WHERE u.id = 123
AND u.deleted = false
""";
JAX-WS – ClassNotFoundException: ProviderImpl.java
Als letztes wollen wir uns noch einen Fehler anschauen, der bei der Verwendung von Spring-Boot und JAX-WS ab Java 9 passieren kann. Kann also zum Beispiel auch für das Upgrade auf Java 17 relevant sein.
Der Fehler tritt unter der folgenden Konstellation auf
- Spring-Boot Applikation aus .jar-Datei heraus starten
- JAX-WS verwenden
- Allgemeiner: java.util.ServiceLoader verwenden
- JAX-WS verwendet den ServiceLoader beim instantiieren einer Soap Service Klasse
- Instantiieren der Soap Service Klasse passiert asynchron mittels
CompletableFuture.supplyAsync
Über Spring-Boot und Classloader
Ein Classloader ist die Komponente in einer Java-Applikation, die dafür verantwortlich ist, Klassen zu laden. Heißt also, bevor ich in der Lage bin eine neue Instanz einer Klasse zu erstellen, muss die JVM diese Klasse erst geladen haben. Der Classloader erhält als Eingabe den Namen der zu ladenden Klasse. Der Classloader beginnt dann, den Classpath zu durchsuchen. Der Classpath ist eine Auflistung von .jar-Dateien, die wiederum Klassen enthalten. Um den Classpath zu durchsuchen, nimmt sich der Classloader eine .jar-Datei nach der anderen vom Classpath und durchsucht diese nach der gesuchten Klasse. Findet der Classloader die gesuchte Klasse, wird sie geladen und es ist nun möglich ein Objekt dieser Klasse zu instantiieren. Findet der Classloader die Klasse nicht, wirft er eine ClassNotFoundException. Eine ClassNotFoundException heißt im Wesentlichen also, dass die gesuchte Klasse nicht da liegt, wo der Classloader gesucht hat.
Eine entscheidende Eigenschaft des Classpaths ist es, dass er nur .jar-Dateien enthalten kann, die direkt im Dateisystem liegen. Oder anders gesagt: Es ist nicht möglich, .jar-Dateien zu referenzieren, die innerhalb einer anderen .jar-Datei liegen. Ich kann also nicht alle meine benötigten .jar-Dateien in einer einzelnen .jar-Datei verpacken und dann einfach die verpackten .jar-Dateien auf den Classpath legen. Damit kann der Classloader nicht umgehen.
Spring-Boot tut aber genau das. Es verpackt alle Dependencies, zusammen mit der eigentlichen Applikation, in einer einzigen .jar-Datei. Das hat den Vorteil, dass man sich keine Gedanken beim starten der Applikation machen muss, da alles benötigte bereits in der .jar-Daten enthalten ist. Bringt aber, wie bereits oben erwähnt, das Problem mit sich, dass der Classloader keine Klassen mehr laden kann. Aus diesem Grund bringt Spring-Boot seinen eigenen Classloader mit. Statt des JVM-eigenen Classloaders verwendet eine Spring-Boot Applikation den Spring-Boot-eigenen Classloader. Dieser ist in der Lage, .jar-Dateien zu durchsuchen, die innerhalb einer anderen .jar-Datei liegen.
Spring-Boot kümmert sich natürlich auch darum, dass die Applikation den richtigen Classloader verwendet und deshalb gibt es an dieser Stelle zu 99% auch keine Probleme.
Über java.util.ServiceLoader
Der ServiceLoader ist ein Mechanismus, der es ermöglicht, Implementierungen eines Services dynamisch zur Laufzeit zu Laden. Er erhält als Eingabe den Namen des Services und nutzt dann den Classloader, um nach Implementierungen dieses Services zu suchen. Heißt also, für den ServiceLoader ist der Classloader äußerst relevant. Findet der Classloader eine Klasse nicht, heißt das, dass der ServiceLoader die Klasse genauso wenig finden kann. Heißt im Falle von Spring-Boot aber auch, dass der ServiceLoader zwingen den Spring-Boot-Classloader nutzen muss.
Schauen wir uns dazu an, wie der ServiceLoader an den Classloader kommt:
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
}
Der ServiceLoader ruft also Thread.currentThread().getContextClassLoader()
auf. Ist der ContextClassloader des aktuellen Threads der Spring-Boot-Classloader, ist also alles gut.
Über CompletableFuture.supplyAsny
Die letzte Zutat für die ClassNotFoundException ist CompletableFuture.supplyAsync
. Im Prinzip erlaubt CompletableFuture.supplyAsync
das asynchrone Ausführen von Code. Um Code asynchron ausführen zu können, muss ein Thread erzeugt werden. Und hier beißt sich die Katze in den Schwanz. Woher weiß CompletableFuture.supplyAsync
, welcher der richtige ConextClassLoader ist?
CompletableFuture.supplyAsync in Java 8 vs. Java 9
Die entscheidende Änderung passierte im Update von Java 8 auf Java 9. Vor Java 8 bekam ein neu erzeugter Thread den ContextClassLoader des Threads, der den neuen Thread erzeugt hat. Heißt also, solange mein Root-Thread den Spring-Boot-Classloader als ContextClassloader hat, haben es auch alle Kind-Threads. Allerdings kann es sein, dass innerhalb einer Applikation nicht an jeder Stelle derselbe ContextClassloader verwendet wird. Das führte dazu, dass es schwer nachvollziehbar wird, welche Threads welchen ContextClassloader haben. Deshalb kam in Java 9 die entscheidende Änderung: Der ThreadPool, der hinter CompletableFuture.supplyAsync
steht, verwendet für seine Threads IMMER den JVM-Classloader als ContextClassloader. Das erhöht die Reproduzierbarkeit der Applikation, sorgt aber in unserem Fall für ein Problem: Der ServiceLoader bekommt als ContextClassloader den JVM-Classloader und nicht den Spring-Boot-Classloader und ist deshalba auch nicht in der Lage, die gesuchte Service-Implementierung zu finden.
Die Lösung
Die Lösung ist so simple wie sie klingt: Den richtigen ContextClassloader setzen. Denn das eigentliche Problem ist nicht, dass der ServiceLoader die Service-Implementierung nicht findet, sondern das Problem ist, dass der Thread den falschen ContextClassloader hat.
public CompletableFuture<String> performSoapCall() {
var springBootClassloader = this.getClass().getClassLoader();
return Completablefuture.supplyAsync( ()-> {
Thread.currentThread().setContextClassLoader(springBootClassloader);
...
// perform soap call
};
}