Java-Serialisierung
Java-Objekte mit dem Serializable-Interface, ObjectOutputStream und serialVersionUID in Bytes serialisieren.
Die vorangegangenen Kapitel behandelten Streams mit Inhalten — Bytes, Zeichen, Primitive, Zeilen. Die Serialisierung ist eine Stufe höher: ein Stream von Objekten. Sie rufen writeObject(someObject) auf, und das JDK durchläuft den gesamten Referenzgraphen dieses Objekts, kodiert jedes Feld jedes erreichbaren Objekts als Bytes und schreibt das Ergebnis in den Stream. Auf der Leseseite rekonstruiert readObject() den Graphen.
Das ist ein großes Versprechen mit einem großen Sternchen. Serialisierung funktioniert, funktioniert seit Java 1.1, und Sie werden sie in alten Codebases finden (RMI, EJB, Session-Replikation, einige Caching-Schichten). Das Design hat jedoch bekannte Probleme — fragile Versionierung, Sicherheitslücken, enge Kopplung zwischen Persistenz und Klassenstruktur — und Oracle versucht seit Jahren öffentlich, sie abzuschaffen. Für neuen Code ist die Antwort fast immer JSON oder Protocol Buffers. Dieses Kapitel ist vorhanden, damit Sie vorhandenen Code lesen und pflegen können.
Der Mechanismus
Drei Teile:
- Das
Serializable-Marker-Interface. Eine Klasse erklärt, dass sie serialisiert werden kann, indem siejava.io.Serializableimplementiert. Das Interface hat keine Methoden; es ist ein Flag, das das JDK zur Laufzeit prüft. ObjectOutputStream. Ein Decorator, der jedenOutputStreamumschließt undwriteObject(Object)hinzufügt. Er ist die Engine, die den Graphen durchläuft und die Bytes schreibt.ObjectInputStream(nächstes Kapitel). Der Spiegel, der die Bytes liest und den Graphen rekonstruiert.
class User implements Serializable { // the marker
private static final long serialVersionUID = 1L;
String name;
int age;
User(String name, int age) { this.name = name; this.age = age; }
}
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(path))) {
out.writeObject(new User("alice", 30)); // the user is now on disk
}Das ist das minimale Rezept. Die Klasse implementiert Serializable; der Writer ist ObjectOutputStream; der Aufruf ist writeObject. Beim nächsten Lesen dieser Datei (behandelt im nächsten Kapitel) erhalten Sie eine User-Instanz zurück.
Was geschrieben wird
Alles, was vom Objekt aus erreichbar ist, standardmäßig:
- Jedes nicht-
transient-, nicht-static-Feld, per Reflection, in Deklarationsreihenfolge. - Rekursiv jedes Objekt, auf das diese Felder verweisen.
- Für jede beteiligte Klasse ein Deskriptor (der Klassenname, Feldtypen und
serialVersionUID), damit der Leser das Format validieren kann.
Das Format ist binär, selbstbeschreibend (es trägt Klassen-Metadaten) und nicht menschenlesbar. Es ist auch spezifisch für das Java-Typsystem — die Bytes kodieren Feld-Offsets, Typnamen und Vererbungshierarchien, die außerhalb von Java bedeutungslos sind. Das ist die grundlegende Einschränkung: Eine User.bin-Datei kann ohne einen benutzerdefinierten Parser nicht von Python, Go oder JavaScript gelesen werden.
transient: Felder, die nicht serialisiert werden sollen
Ein mit transient markiertes Feld wird bei der Serialisierung übersprungen. Der Leser sieht es als Standardwert für seinen Typ — null, 0, false. Verwenden Sie es für:
- Caches, die neu aufgebaut werden können:
transient Map<String, Result> cache; - Felder, die JVM-übergreifend keinen Sinn ergeben:
transient Thread worker;,transient Connection db; - Sensible Daten, die nicht auf die Festplatte gelangen sollen:
transient String password;
class Session implements Serializable {
private static final long serialVersionUID = 1L;
String userId;
long createdAt;
transient byte[] sessionToken; // never gets written
}Die deserialisierte Session wird sessionToken == null haben. Ihr Code muss damit umgehen, dass das Feld nach der Rekonstruktion fehlt.
Statische Felder werden ebenfalls übersprungen — static gehört zur Klasse, nicht zur Instanz, und ist daher kein Teil des objektbezogenen Zustands.
serialVersionUID: explizit deklarieren
Jede serialisierbare Klasse hat eine serialVersionUID — eine 64-Bit-Versionsnummer, die in den Stream geschrieben und auf der Leseseite gegen die Klasse geprüft wird. Stimmen sie nicht überein, wirft die Deserialisierung InvalidClassException.
Sie sollten sie immer deklarieren:
private static final long serialVersionUID = 1L;Wenn Sie das nicht tun, berechnet die JVM eine aus der Klassenstruktur — jedes Feld, jede Methodensignatur, jedes Interface. Fügen Sie ein Feld hinzu, ändern Sie den Rückgabetyp einer Methode, benennen Sie einen Parameter um, und die berechnete UID ändert sich. Code, der User.bin mit der Klasse der letzten Woche geschrieben hat, kann ihn mit der Klasse dieser Woche nicht lesen. Sie werden das nicht in Unit-Tests feststellen, weil beide Seiten dieselbe Klasse sehen. Sie werden es in der Produktion bemerken, wenn ein Benutzer aktualisiert.
Die UID explizit zu deklarieren gibt Ihnen die Kontrolle. Erhöhen Sie sie manuell nur dann, wenn Sie eine inkompatible Änderung vorgenommen haben. (Die vollständigen Evolutionsregeln finden Sie in der Serializable-Javadoc — sie sind komplex.)
Was Sie zwischen Versionen ändern können
Die Regeln für „kompatible" Änderungen sind überraschend streng. Grob gesagt:
- Sicher: neue Felder hinzufügen, transiente/statische Felder entfernen, den Zugriff erweitern (
private→public). - Unsicher: nicht-transiente Felder entfernen, den Typ eines Feldes ändern, die
serialVersionUIDeiner Klasse ändern, die Vererbungskette ändern.
Der Punkt: Die Bytes auf der Festplatte sind an die Struktur der Klassenhierarchie gekoppelt, nicht nur an die Daten. Langzeit-Speicherformate benötigen ein eigenes Schema. Serialisierung eignet sich gut für kurzlebige Caches und intra-JVM-Transport, ist aber fragil für alles, was einen Deploy überleben muss.
Der gesamte Graph, einschließlich Zyklen
writeObject folgt jeder Referenz. Wenn User ein Team hält und das Team eine List<User> hält, die den ersten User enthält, wird der Zyklus behandelt: Das JDK verfolgt die Identität jedes geschriebenen Objekts und schreibt, wenn es einem bereits bekannten Objekt begegnet, eine Rück-Referenz statt erneut zu rekursieren. Der rekonstruierte Graph auf der anderen Seite hat dieselben Identitätsbeziehungen.
Das ist leistungsstark und gleichzeitig eine Fehlerquelle. Ein serialisierbares Objekt zieht alles mit, was es erreichen kann — und wenn eines dieser erreichbaren Objekte nicht Serializable ist, schlägt der Schreibvorgang mit NotSerializableException fehl, wobei der Name des problematischen Typs angegeben wird. Die Lösung ist eine der folgenden: Serializable auf dem Verursacher implementieren, das Feld als transient markieren oder die Klasse umstrukturieren, damit sie die Referenz nicht mehr hält.
Sicherheit: niemals nicht vertrauenswürdige Bytes deserialisieren
Das ist hauptsächlich ein Thema für das nächste Kapitel, aber die Konsequenz beeinflusst auch die Schreibseite. Javas Serialisierungsformat führt beim Deserialisieren Code auf dem Leser aus — Klassen-Konstruktoren und readObject-Hooks. Manipulierte Byte-Streams wurden für Remote Code Execution gegen alle großen Java-App-Server eingesetzt. Die Regel, die sich aus Jahren von CVEs ergeben hat:
Deserialisieren Sie keine Bytes aus einer Quelle, die Sie nicht vollständig kontrollieren.
Auf der Schreibseite bedeutet das: Entwerfen Sie keine Protokolle, bei denen eine Partei Daten mit ObjectOutputStream serialisiert und eine andere sie mit ObjectInputStream deserialisiert. Verwenden Sie JSON oder Protocol Buffers über Vertrauensgrenzen hinweg; reservieren Sie die Serialisierung für „gleiche JVM, gleicher Classloader, gleiche Vertrauensdomäne"-Anwendungsfälle.
Wann Serialisierung verwenden (und wann nicht)
Greifen Sie darauf zurück, wenn:
- Sie einen Graphen von Objekten in der gleichen JVM für die Restart-Wiederherstellung prüfen müssen.
- Sie mit einem bestehenden Framework (RMI, JMX, EJB, einige Session-Replikationen) arbeiten, das dies erfordert.
- Sie eine 10-Zeilen-Implementierung für eine „Spielstand speichern"-Datei wollen, bei der Sie jederzeit die Kompatibilität brechen können.
Greifen Sie nicht darauf zurück, wenn:
- Das Format einen Deploy überleben muss. Verwenden Sie stattdessen ein schema-versioniertes Format (JSON + ein Versionsfeld, Protobuf, Avro).
- Die Daten eine Vertrauensgrenze überschreiten. Verwenden Sie JSON oder Protobuf.
- Eine andere Sprache die Daten lesen oder schreiben muss. Das Java-Serialisierungsformat ist Java-exklusiv.
Für die meisten neuen Code-Projekte ist Jackson.writeValueAsString(obj) in eine JSON-Datei die bessere Wahl. Es ist schemalos-aber-flexibel, menschenlesbar und aus jeder Sprache parsebar.
Ein vollständiges Beispiel: Schreiben eines Graphen von Datensätzen
Das folgende Programm definiert zwei einfache serialisierbare Typen, Department und Employee, mit einer Rück-Referenz (jeder Employee kennt seine Department, und jede Department hält eine Liste ihrer Employees — ein Zyklus). Es schreibt den Graphen mit ObjectOutputStream, gibt die Byte-Anzahl aus und zeigt die NotSerializableException, die Sie erhalten, wenn ein nicht serialisierbares Feld einschleicht. Das Zurücklesen der Bytes ist das nächste Kapitel; hier konzentrieren wir uns auf die Schreibseite.
Was der Durchlauf zeigt:
- Ein einziger
writeObject(eng)-Aufruf hat die Department, alle drei Employees, die Rück-Referenzen von Employee zu Department und die Liste innerhalb von Department serialisiert. Das ist das Hauptmerkmal der Serialisierung: Graphen, keine Datensätze. Zyklen werden behandelt, Identität erhalten, kein manuelles Durchlaufen. - Die ersten vier Bytes waren
AC ED 00 05— die Java-Serialisierungs-„Magic Number" und Stream-Version. Jede serialisierte Datei beginnt damit. Wenn Sie diesen Header in einer Produktionsdatei sehen, schauen Sie auf die Ausgabe vonObjectOutputStream. - Der Byte-Dump enthielt
"alice"(ein nicht-transientes Feld) und enthielt nicht"hash-A"(eintransient-Feld). Ein Feld alstransientzu markieren ist der unterstützte Weg, es auszuschließen. Sensible Felder (Passwörter, Tokens, Session-Schlüssel) gehören intransient. - Der
BadEmployee-Schreibvorgang hatNotSerializableExceptiongeworfen, und die Meldung nannteSettings— den genauen nicht-serialisierbaren Typ. So finden Sie Verursacher: versuchen Sie zu schreiben, lesen Sie die Ausnahme, beheben Sie die genannte Klasse (oder markieren Sie das Feld alstransient). Die Prüfung findet auf Feldebene statt, nicht auf Klassenebene — eine einzelne nicht-serialisierbare Referenz reicht aus. serialVersionUID = 1Lwurde für jede serialisierbare Klasse deklariert. Der aktuelle Durchlauf würde es nicht bemerken, wenn es fehlte, aber ein zukünftiges Ich, das die Klasse refaktoriert und versucht, eine alte Datei mit dem neuen Code zu laden, würde es sofort bemerken. Deklarieren Sie es; erhöhen Sie es bewusst, wenn Sie eine inkompatible Änderung vornehmen.
Was kommt als Nächstes
Dieses Kapitel behandelte das Schreiben — Serializable, ObjectOutputStream, das Graph-Durchlaufen, das Format. Das Lesen und Rekonstruieren des Graphen ist die Spiegeloperation mit eigenen Fallstricken (der Sicherheitsfaktor ist der größte). Das ist das nächste Kapitel, Java-Deserialisierung.