Blog

Extreme Java: Serialisierung – der Klotz am Bein!?

19 Jan 2018

Beitragsbild Extreme Java Serialisierung

Nachdem wir uns im ersten Teil der Reihe Extreme Java mit dem Thema Concurrency auseinandergesetzt haben, hatte ich mir eigentlich vorgenommen, in diesem Artikel über verschiedene Themen für fortgeschrittene Java-Entwickler zu schreiben. Allerdings habe ich einige meiner Newsletterabonnenten um Feedback zum Abschnitt „Serialisierung“ gebeten, und die Reaktionen waren überwältigend. So sehr, dass ich hier in Teil 2 die ganze Zeit nur über Serialisierung schreiben werde.

Serialisierung in Java – der Klotz am Bein

Vor vielen Jahren habe ich für eine Firma gearbeitet, die von ihrem Kunden gebeten worden ist, ein Back-up-System für ziemlich große Dateien zu entwerfen. Wir haben sofort etwas gebaut, das aus Treibern in Delphi, DAT-Bändern für die Hardware und natürlich Java bestand. Dieses Back-up-System haben wir dann fröhlich in Produktion gebracht, mit dem zufriedenen Strahlen getaner Arbeit, die pünktlich und den Kundenspezifikationen entsprechend erledigt wurde.

Ein paar Monate lang genossen wir den Klang der Stille. Dann stellte der Kunde auf einmal eine seltsame Frage: „Ähm, wie stellen wir die Dateien von einem Back-up wieder her?“ „Oh“, sagte der Chefentwickler, „der Kunde hat nie nach einer Wiederherstellungsfunktion gefragt!“

Ich mache keinen Witz. Wir hatten eine Einbahnstraße in die Datenvergessenheit gebaut! Da sich der Chefentwickler schnell einen neuen Job gesucht hat, wurde ich damit beauftragt, die Funktionen zur Wiederherstellung zu entwickeln. Wir hatten serialisierte Java-Objekte auf die DAT-Bänder geschrieben. In der Zwischenzeit hatten sich aber die Klassenstrukturen geändert. Und so begann mein langes Abenteuer, die Serialisierung zu entschlüsseln.

Die Geschichte der Objektserialisierung

Bei einer kürzlich stattgefundenen Podiumsdiskussion wurden die Java-Architekten gefragt, was sie an Java am meisten bedauern. Die Serialisierung stand ziemlich hoch oben auf ihrer Liste. Serialisierung kam in Java 1.1 hinzu, um Objekte persistieren zu können. Es war dazu bestimmt, transitiv zu arbeiten. Wenn also ein Objekt auf ein anderes zeigt, werden beide Objekte persistiert. Die Serialisierung ist klug genug, um mit Zirkelbezügen umgehen zu können, ohne dass es zu einem StackOverflowError kommt.

Mit der serialVersionUID können wir inkompatible Klassenänderungen feststellen, z. B. zwischen Java-Versionen. Warum sollten die Java-Architekten das Gefühl haben, dass dies einer ihrer größten Fehler war? In Java 8 wurde java.ioSerializable 5237-mal implementiert oder erweitert. Und jedes Mal, wenn eine dieser Klassen modifiziert wird, muss sie das gleiche serialisierbare Format beibehalten, da ansonsten bestehende Systeme zusammenbrechen könnten. Es ist ein sehr teurer Klotz am Bein. Kein Wunder, dass das Javadoc die Autoren als unbekannt auflistet. Niemand will dafür Verantwortung übernehmen!

Ein interessantes Beispiel, bei dem ich ein bisschen Code zum JDK beigesteuert habe, war ThreadLocalRandom. In Java 7 wurde dies als ThreadLocal implementiert, mit verschiedenen Thread-confined Instanzen für jeden Thread. In Java 8 änderten die Architekten dies in ein Singleton. ThreadLocalRandom ist serialisierbar. Sie fragen sich jetzt vielleicht, was der Sinn dahinter war. ThreadLocalRandom erweitert java.util.Random. Und das ist serialisierbar. Also müssen alle Unterklassen auch serialisierbar sein. Geben Sie ruhig Barbara Liskov dafür die Schuld.

In Java 7 war das Einzige, was ThreadLocalRandom gemacht hat, das Hinzufügen der seriellVersionUID. Denn es ist riskant, diese Nummer automatisch generieren zu lassen, da einige Compiler unterschiedliche anonyme interne Klassenzugriffsmethoden erzeugen könnten, auch synthetische Methoden genannt. Das kann dazu führen, dass verschiedene Compiler auf Klassen mit unterschiedlicher serialVersionUIDkommen können.

Aber es ist ebenso oder vielleicht noch riskanter, diese Nummer hart zu codieren. Der Zweck der serialVersionUID ist es, Klassenänderungen zu erkennen, die zu einem neuen serialisierbaren Format führen können. Wenn wir sie hartcodieren, sehen wir nur Nullwerte.

In Java 8 haben die Java-Architekten die Struktur von ThreadLocalRandom wesentlich verändert, damit es ein Singleton wird. Die Serialisierung muss jedoch trotzdem über verschiedene Versionen hinweg funktionieren. Zwischen den Versionen von Java sollte sich das serielle Format einer Klasse nicht ändern. Daher sollte eine Java-8-ThreadLocalRandom-Klasse in der Lage sein, ein Java-7-ThreadLocalRandom-Objekt zu lesen und umgekehrt. Das ist der Klotz am Bein, über den sich die Architekten in den letzten Jahren immer beschweren. Aber wie funktioniert das genau? Wir haben damit angefangen, indem wir ein spezielles Array mit dem Namen serialPersistentFields hinzugefügt haben:

1
2
3
4
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField("rnd", long.class), new
ObjectStreamField("initialized", boolean.class),
};

Die Java-8-Singleton-Version von ThreadLocalRandom speichert die Zufallsfelder in der Datei der Threadklasse selbst. Es hat keine nicht statischen Felder. Aber das serialPersistentFields-Array gibt vor, dass es zwei Felder hat, rnd und initialized.

Der nächste Schritt war, ein privates writeObject() hinzuzufügen, mit dem wir Daten schreiben können, die eine Java 7 JVM bei der Deserialisierung eines ThreadLocalRandom erwarten würde:

1
2
3
4
5
6
7
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
java.io.ObjectOutputStream.PutField fields = s.putFields();
fields.put("rnd", U.getLong(Thread.currentThread(), SEED));
fields.put("initialized", true); s.writeFields();
}

Schließlich mussten wir uns überlegen, wie Java 8 reagieren sollte, wenn jemand versucht, ein ThreadLocalRandom zu deserialisieren. Bedenken Sie, dass es nun als Singleton implementiert ist und wir deswegen mehrere Instanzen vermeiden sollten. Die Architekten haben dies vermieden, indem sie eine readResolve()-Methode hinzugefügt haben. Diese wird aufgerufen, nachdem das Objekt bereits deserialisiert wurde. Und es ruft einfach die Accessor-Methode auf, in unserem Fall current():

1
2
3
private Object readResolve() {
return current();
}

Als wir diesen Code geändert haben, haben wir uns gefragt, ob jemals jemand ein ThreadLocalRandomserialisiert hat. In gewisser Weise wäre es besser gewesen, wenn wir eine writeObject()-Methode in Java 7 veröffentlicht hätten, die so hätte aussehen können:

1
2
3
4
private void writeObject(java.io.ObjectOutputStream s)
  throws java.io.IOException {
throw new java.io.NotSerializableException("Don't be silly");
}

Es gibt über 5000 Klassen, die Serializable implementieren. Aber in JRE 8 haben wir über 15 000 Klassen, die nicht serialisierbar sind. Dies kann zu Problemen führen, wenn Sie mit Bibliotheken wie Apache Spark arbeiten, die erwarten, dass alles serialisierbar ist, sogar vorübergehend. Manchmal erfordert dies alle möglichen Arten an kreativem Wrapping, um das Spark-Haus nicht zum Einsturz zu bringen.

Die Architekten haben sich aber bei Optional durchgesetzt. Da es als ein Rückgabetyp und nicht als ein Feld gedacht ist, haben die Java-Architekten es nicht serialisierbar gemacht. Und wie Sie sich vielleicht vorstellen können, beschweren sich Entwickler darüber am meisten, weil sie es als Feld nutzen wollen. Immerhin sind 5237 andere Klassen serialisierbar, warum nicht Optional? Einige Frameworks benötigen Rückgabetypen von Methoden, die serialisierbar sind.

Für Lambdas mussten die Architekten eine spezielle Syntax erfinden, damit sie serialisierbar sind. Wenn ich Java-8-Kurse gebe, sage ich normalerweise, dass Lambdas eine Abkürzung für anonyme innere Klassen sind. Das ist nicht ganz korrekt, aber in 99,9 Prozent der Fälle ist es korrekt genug. Sie unterscheiden sich nur an einer Stelle: Eine anonyme innere Klasse kann nur ein Interface implementieren, nicht zwei. Deswegen können Sie dies nicht schreiben:

1
2
3
4
5
Object serializableTask = new Runnable() & Serializable() {
  public void run() {
  System.out.println("Some task");
  }
};

Aber mit Lambdas können Sie dies schreiben:

1
2
Object serializableTask = (Runnable & Serializable) () ->
  System.out.println("Some task");

Am Anfang dieses Artikels habe ich erwähnt, wie ich versuchte, alte Java-Objekte von einem DAT-Band zu entziffern. Das war ziemlich knifflig. JSON und XML waren nämlich noch nicht en vogue. Ich bezweifle auch, dass sich der anonyme Designer von Serializable vorgestellt hat, dass ausgerechnet dieses kostbare Objektformat den Weg in einen Langzeitspeicher findet. Es war vielmehr dazu gedacht, Parameter über RMI oder über einen Socket zu übergeben, vielleicht auch für kurzzeitige Persistenz auf einer Festplatte.

Das Mantra in Java ist jedoch Abwärtskompatibilität, und das wurde während der gesamten Lebenszeit der Programmiersprache auch konsequent angewandt. Das ist auch der Grund, warum Java von Cobol den Rang als Sprache der Banken und Versicherungen übernommen hat.

Der Zweck der serialVersionUID war es, Inkompatibilitäten zwischen Klassen aufzudecken, die in einem Netzwerk kommunizierten. Wenn wir also eine Nachricht von einer neueren Klasse erhalten, wissen wir, dass wir unsere JAR-Datei aktualisieren müssen. serialVersionUID litt jedoch unter zwei Dingen. Erstens: Compiler hatten einen gewissen Spielraum, wie sie Quelltext kompilieren, insbesondere wenn es um Infrastrukturcode geht, z. B. Switch-Anweisungen für Enums und synthetische Zugriffsmethoden für innere Klassen. So kann der gleiche Quellcode, aber kompiliert mit Eclipse oder javac, verschiedene serialVersionUIDs erzeugen. Wir könnten argumentieren, dass wir immer mit javac kompilieren sollten. Aber irgendwie wird dieses Argument immer ignoriert.

Zweitens war es übersensibel und schloss auch Methoden und statische Felder ein, die nichts mit der Serialisierung zu tun hatten. Fügen Sie doch einmal Ihrer Klasse eine toString()-Methode hinzu. Tada, die Versions-ID ist jetzt eine andere.

Um Ordnung in dieses Chaos zu bringen, wurde von Java 5 an angeordnet, dass javac eine Warnung ausgibt, wenn wir versuchen, eine serialisierbare Klasse zu kompilieren, die keine hartcodierte serialVersionID hat. Eclipse war dabei besonders gewissenhaft. Es hält Entwickler lautstark dazu an. Betrachten wir diese Klasse:

1
public class MyClass implements java.io.Serializable {}

Wenn wir es mit javac -Xlint MyClass.java kompilieren, erscheint Folgendes:

1
2
3
4
MyClass.java:1: warning: [serial] serializable class MyClass has no definition of serialVersionUID
public class MyClass implements java.io.Serializable {}
  ^
1 warning

Wenn wir diese Zahl jedoch hartcodieren, stimmen wir zu, dass wir ab sofort die volle Verantwortung für die Änderungen übernehmen, immer wenn wir die Klasse so modifizieren, dass sie inkompatibel werden würde. Nehmen wir z. B. an, dass wir auf den Compiler hören und die serialVersionUID hartcodieren.

Etwas später fügen wir ein Feld hinzu, aber behalten die gleiche Version. Wenn wir nun das neue Objekt an eine alte Klasse schicken, die sie uns zurückschickt, sehen wir Null oder Nullwerte für dieses Feld. Das ist sehr frustrierend, und es kann stundenlang dauern, bis wir den Fehler gefunden haben. Viel sicherer ist es, die UID die Arbeit für uns erledigen zu lassen und uns zu warnen, wenn wir einen Fehler gemacht haben.

Fazit

Serializable hat noch viele andere Komplexitäten, auf die wir hier nicht eingehen werden. Es ist etwas, das ein fortgeschrittener Java-Programmierer verstehen muss, aber wo Anfänger ihre Klassen einfach als Serializable markieren können und fertig. Ich bin für eine Menge wunderbarer Leute sehr dankbar, die einen früheren Entwurf dieses Artikels gelesen und mir ausgezeichnete Vorschläge gegeben haben, wie man ihn verbessern kann. In alphabetische Reihenfolge: Casper Madsen, Cay Horstmann, Guy Pardon, Jonathan Rosenne, Kosta Kontos, Maz Rashid, Paul Golick, Steven Aviv und Tasos Zervos.

Keine Infos mehr verpassen!