W3docs

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:

  1. Das Serializable-Marker-Interface. Eine Klasse erklärt, dass sie serialisiert werden kann, indem sie java.io.Serializable implementiert. Das Interface hat keine Methoden; es ist ein Flag, das das JDK zur Laufzeit prüft.
  2. ObjectOutputStream. Ein Decorator, der jeden OutputStream umschließt und writeObject(Object) hinzufügt. Er ist die Engine, die den Graphen durchläuft und die Bytes schreibt.
  3. 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 (privatepublic).
  • Unsicher: nicht-transiente Felder entfernen, den Typ eines Feldes ändern, die serialVersionUID einer 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.

java— editable, runs on the server

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 von ObjectOutputStream.
  • Der Byte-Dump enthielt "alice" (ein nicht-transientes Feld) und enthielt nicht "hash-A" (ein transient-Feld). Ein Feld als transient zu markieren ist der unterstützte Weg, es auszuschließen. Sensible Felder (Passwörter, Tokens, Session-Schlüssel) gehören in transient.
  • Der BadEmployee-Schreibvorgang hat NotSerializableException geworfen, und die Meldung nannte Settings — 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 als transient). Die Prüfung findet auf Feldebene statt, nicht auf Klassenebene — eine einzelne nicht-serialisierbare Referenz reicht aus.
  • serialVersionUID = 1L wurde 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.

Übungen

Übung
Eine `Employee`-Klasse hat ein `transient String sessionToken`-Feld. Das Token ist bei der Serialisierung `'abc123'`. Was ist der Wert von `sessionToken` am rekonstruierten Objekt nach der Deserialisierung in einer neuen JVM?
Eine `Employee`-Klasse hat ein `transient String sessionToken`-Feld. Das Token ist bei der Serialisierung `'abc123'`. Was ist der Wert von `sessionToken` am rekonstruierten Objekt nach der Deserialisierung in einer neuen JVM?
Was this page helpful?