Serialisierung

Aus der Java-Trickkiste: Java-Serialisierung – wann passt sie, wann nicht?

Arno Haase

Serialisierung ist ein Mechanismus, bei dem Objekte in eine Folge von Bytes verwandelt und umgekehrt daraus wieder Objekte erzeugt werden. Man braucht solche Mechanismen beispielsweise für das Aufrufen über ein Netzwerk oder um Objekte in einer Datenbank zu speichern. Java bringt dafür von Haus aus einen Mechanismus mit: die Serialisierung im engeren Sinne. Die ist trotz ihrer Schwächen so weit verbreitet, dass wir sie heute näher betrachten.

Grundlagen zur Serialisierung

Die größte Stärke der Java-Serialisierung ist, dass sie sehr einfach zu verwenden ist. Wenn eine beliebige Klasse das Interface java.io.Serializable implementiert, kann Java deren Instanzen serialisieren (Listing 1). Dabei muss man kein besonderes Programmiermodell beachten – es funktioniert z. B. auch mit final-Feldern und ohne Default-Konstruktor, weil die Deserialisierung Objekte ohne Konstruktoraufruf erzeugt und anschließend die Felder über Reflection setzt. Und Serializable ist ein reines Marker-Interface ohne Methoden, die man implementieren müsste – viel einfacher geht es nicht.

 public class Person implements Serializable {
  private final String name;

  public Person (String name) {
    this.name = name;
  }

  public String getName () {
    return name;
  }
}
 

Zum eigentlichen Serialisieren gibt es die Klasse java.io.ObjectOutputStream (Listing 2). Der Code erzeugt eine Person-Instanz und schreibt sie in die Datei dummy.ser. Dazu erzeugt er mit der try-with-resource-Syntax, die Java seit Version 1.7 unterstützt [1], einen FileOutputStream und einen ObjectOutputStream als Wrapper um diesen herum.

 Person p = new Person("Arno");
try (FileOutputStream fos = new FileOutputStream ("dummy.ser");
     ObjectOutputStream oos = new ObjectOutputStream (fos)) {
  oos.writeObject (p);
}
 

Dann ruft er die Methode writeObject() auf dem ObjectOutputStream mit der Person-Instanz als Argument auf. Die wandelt das Objekt intern in eine Bytefolge um und gibt diese an den FileOutputStream weiter – fertig ist die Serialisierung, das Objekt steht in der Datei. Und weil ObjectOutputStream als Wrapper um einen beliebigen anderen OutputStream funktioniert, kann man Objekte genauso leicht z. B. über eine Netzwerkverbindung (mit Socket.getOutputStream()) oder in ein Bytearray (mit ByteArrayOutputStream) übertragen.
Das Einlesen von Objekten funktioniert analog zum Schreiben, indem man einen ObjectInputStream um einen beliebigen anderen InputStream wickelt (Listing 3). Die Methode readObject() holt dabei so lange Bytes aus der Datei, bis sie daraus ein fertiges Objekt erzeugen kann, und liefert das zurück – der Code muss natürlich noch auf Person casten.

 try (FileInputStream fis = new FileInputStream ("dummy.ser");
    ObjectInputStream ois = new ObjectInputStream (fis)) {
  final Person p2 = (Person) ois.readObject ();
  assert (p2.getName ().equals ("Arno"));
}
 

Durch Hintereinanderschalten von Serialisierung und Deserialisierung kann man recht einfach eine generische Methode zum tiefen Kopieren von Objekten schreiben (Listing 4). Die ist zwar nicht besonders effizient, erfordert dafür aber praktisch keine Kooperation der zu klonenden Objekte. Sie kann nützlich sein, um in Tests verteilte Aufrufe zu mocken, wo Änderungen an Objekten im Server nicht unmittelbar auf Objekte im Client wirken.

static <T> T genericClone (T o) throws IOException, ClassNotFoundException {
  final ByteArrayOutputStream baos = new ByteArrayOutputStream ();
  try (ObjectOutputStream oos = new ObjectOutputStream (baos)) {
    oos.writeObject (o);
  }

  ByteArrayInputStream bais = new ByteArrayInputStream (baos.toByteArray ());
  try (ObjectInputStream ois = new ObjectInputStream (bais)) {
    return (T) ois.readObject ();
  }
}

Identität von Objekten bleibt erhalten

Java berücksichtigt beim Serialisieren ganze Objektnetze. Wenn es eine Referenz auf ein anderes Objekt gibt, wird das mit serialisiert. Die einzige Voraussetzung ist, dass alle beteiligten Klassen Serializable implementieren. Dabei bleibt die Objektidentität innerhalb eines ObjectOutputStreams erhalten: Wenn es mehrere Referenzen auf dasselbe Objekt gibt, zeigen auch nach dem Deserialisieren die Referenzen auf dasselbe Objekt.
Listing 5 zeigt das am Beispiel einer Liste mit drei Personen. Die ersten beiden Einträge zeigen auf dasselbe Objekt, während der dritte auf ein anderes Objekt mit dem gleichen Inhalt zeigt. Und das ist nach einem Serialisierungs- und Deserialisierungsdurchlauf immer noch so.

 Person p = new Person("Arno");
List<Person> persons = Arrays.asList (p, p, new Person("Arno"));

List<Person> cloned = genericClone (persons);

assert (cloned.get(0) == cloned.get(1));
assert (cloned.get(0) != cloned.get(2));
 

Schwieriger wird es, wenn die Identität eines Objekts global erhalten bleiben soll. Listing 6a zeigt eine Variante der Person-Klasse, die eine public static final-Variable mit einer THE_BOSS-Instanz hat und sicherstellt, dass nur diese eine Instanz den Namen „The Boss“ haben kann. Das Beispiel ist natürlich konstruiert, aber es gibt in der Praxis tatsächlich Fälle wie diesen – sie haben aber typischerweise mehr Kontext und taugen deshalb nicht so gut als Beispiel.

public class Person implements Serializable {
  public static final Person THE_BOSS = new Person ("The Boss");

  private final String name;

  private Person (String name) {
    this.name = name;
  }

  public static Person create (String name) {
    if (name.equals (THE_BOSS.getName ())) return THE_BOSS;
    return new Person (name);
  }

  public String getName () {
    return name;
  }
}

Diese Klasse hat allerdings ein Problem: Wenn man THE_BOSS serialisiert und wieder deserialisiert, dann erhält man eine Kopie. Und damit ist die Invariante gebrochen, dass es nur einen Boss geben kann.

public class Person implements Serializable {
  ...
  public Object readResolve() {
    if (name.equals (THE_BOSS.getName ())) return THE_BOSS;
    return this;
  }
}

Für solche Situationen gibt es die Möglichkeit, beim Deserialisieren quasi einzugreifen. Wenn eine Klasse eine Methode readResolve() implementiert, die public ist, den Rückgabetyp Object hat und keine Parameter erwartet, ruft Java diese Methode bei jedem Deserialisieren auf (Listing 6b). Dabei wird das Objekt zunächst komplett normal deserialisiert und anschließend auf diesem eigentlich fertigen Objekt readResolve() aufgerufen, deren Rückgabewert dann als deserialisiertes Objekt weiter verwendet wird.

Eine Implementierung kann im einfachsten Fall this zurückgeben, dann verhält sich die Deserialisierung unverändert. Sie kann vorher das zurückgegebene Objekt auch verändern, es irgendwo registrieren, initialisieren etc. Oder sie kann eine komplett andere Instanz zurückgeben, z. B. das THE_BOSS-Singleton.

Diese Form des Ersetzens durch Konstanten ist mit Abstand der häufigste Anwendungsfall für readResolve()-Methoden. Java-Enums verwenden sie z. B. auf diese Weise. Es gibt analog zu readResolve() auch die Möglichkeit, durch Implementieren einer Methode writeReplace() das zu serialisierende Objekt auszutauschen. Mir ist allerdings noch kein Anwendungsfall begegnet.

Customizing, um keine Bandbreite zu verschwenden

Per Default serialisiert Java alle nicht statischen Felder eines Objekts. Das funktioniert, ist aber manchmal überflüssig und verschwendet Platz bzw. Bandbreite. Nehmen wir zum Beispiel eine Variante der Klasse Person an, die sowohl den Vor- als auch den Nachnamen kennt und außerdem als Optimierung redundant den vollen Namen im Konstruktor erzeugt und in einem Feld speichert (Listing 7). Dieses Feld braucht man beim Serialisieren nicht mit zu übertragen, weil man es ja beim Deserialisieren aus Vor- und Nachnamen wieder ermitteln kann.

 public class Person implements Serializable {
  private final String firstName;
  private final String lastName;
  private transient final String name;

  public Person (String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.name = firstName + " " + lastName;
  }

  public String getFirstName () {
    return firstName;
  }
  public String getLastName () {
    return lastName;
  }
  public String getName () {
    return name;
  }

  public Object readResolve() {
    return new Person (firstName, lastName);
  }
}
 

Der erste Schritt dazu ist, das Feld mit dem Schlüsselwort transient zu versehen. Dadurch wird es vom Serialisierungsmechanismus ignoriert. In einem zweiten Schritt müssen wir dafür sorgen, dass es beim Deserialisieren initialisiert wird. Dazu ruft readResolve() den Konstruktor auf und gibt eine neue, voll initialisierte Person-Instanz zurück.

Noch mehr Kontrolle bietet das Implementieren der Methoden readObject() und writeObject(), die das Lesen und Schreiben des Objektzustands direkt definieren (Listing 8). Das ist zum Beispiel nützlich, um große, komplexe Objekte kompakt zu repräsentieren – der Code im Listing zeigt das API, ohne in diesem Fall einen Mehrwert zu bringen.

public class Person implements Serializable {
  private String firstName;
  private String lastName;

  public Person (String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  public String getFirstName () {
    return firstName;
  }
  public String getLastName () {
    return lastName;
  }

  private void writeObject(ObjectOutputStream s) throws IOException {
    s.writeUTF (firstName);
    s.writeUTF (lastName);
  }

  private void readObject(ObjectInputStream s) throws IOException {
    firstName = s.readUTF ();
    lastName = s.readUTF ();
  }
}

Zum Schreiben einzelner Werte hat ObjectOutputStream Methoden wie writeInt(), writeBoolean() oder writeUTF()ObjectInputStream hat entsprechende Methoden zum Lesen.

Versionierung – muss sein!

Serialisierung und Deserialisierung von Objekten können in unterschiedlichen JVMs erfolgen, die potenziell auf unterschiedlichen Computern laufen. Und es kann z. B. bei Dateien beliebig viel Zeit zwischen dem Schreiben und Lesen liegen. So kann es passieren, dass Serialisieren und Deserialisieren auf verschiedenen Softwareständen erfolgt. Das kann zu Problemen führen.

Zunächst müssen alle benötigten Klassen beim Deserialisieren im Classpath liegen, sonst wirft der ObjectInputStream eine ClassNotFoundException. Man kann also durch Serialisieren nicht einfach Code übertragen, der dann auf einer anderen Maschine ausgeführt wird, und das ist gut so – alles andere wäre eine riesige Sicherheitslücke. Für eine Beschreibung, wie eine ungeschickte Implementierung der Deserialisierung z. B. in commons-collections trotzdem die Tür für Remote Code Execution öffnet, siehe [1].

Außerdem müssen die Klassen beim Schreiben zu den Klassen beim Lesen passen – wenn beim Schreiben eine Person zum Beispiel nur einen String mit dem gesamten Namen hat, beim Lesen aber eine spätere Version der Person-Klasse getrennte Vor- und Nachnamen hat, wirft der ObjectInputStream ebenfalls eine Exception.

Als zusätzliche Sicherheit schreibt der Serialisierungsmechanismus für jede verwendete Klasse noch einen Long mit einer Versionsnummer der Klasse, der serialVersionUID. Per Default ermittelt Java diese Zahl als eine Art Hashwert über die Definition der Klasse, sodass Änderungen an der Klasse den Wert ändern. Der ObjectInputStream überprüft dann beim Lesen, ob die Klasse noch dieselbe serialVersionUID hat wie beim Schreiben.

Der Algorithmus zum automatischen Ermitteln der serialVersionUID ist sehr konservativ, und manche Änderungen am Code ändern ihren Wert, obwohl das serialisierte Format kompatibel bleibt. Deshalb empfiehlt sogar die Javadoc von Serializable, dass jede serialisierbare Klasse eine explizite Version definieren sollte. Dazu dient ein Feld static final long serialVersionUID = …; mit beliebiger Sichtbarkeit.

Java-Serialisierung: Wann passt sie, wann nicht?

Java-Serialisierung ist ein einfacher Mechanismus, um Objektstrukturen zu speichern oder über Netzwerke zu übertragen. Er bietet über die Default-Mechanismen hinaus Möglichkeiten, die konkrete Repräsentation einzelner Datentypen festzulegen, z. B. um sie effizienter zu gestalten. Serialisierung ist weit verbreitet und hat ihre Stärken im Umgang mit Objektidentität und Objektnetzen. Wenn effizientes Speichern oder Übertragen von Objekten im Vordergrund steht, gibt es aber kompaktere und schnellere Alternativen zur Java-Serialisierung. Insbesondere ProtoBuf von Google ist weit verbreitet, extrem effizient und für verschiedene Programmiersprachen verfügbar.

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Aus der Java-Trickkiste: Java-Serialisierung – wann passt sie, wann nicht?"

avatar
400
  Subscribe  
Benachrichtige mich zu:
Rüdiger Möller
Gast

Kleiner Hinweis:
mit fst (https://github.com/RuedigerMoeller/fast-serialization) gibt es eine kompatible (drop-in) implementierung, die um Faktoren (4 bis >10) mal schneller ist.
Gerade in verteilten Systemen wird die doch recht gemächliche JDK Implementierung schnell ein Flaschenhals.
Alternativen wie Protobuf sind in der Regel deutlich aufwendiger und in der Entwicklung deutlich teurer (eigene Datentypen, Generate, kein Objectgraph support etc., Wandlung DatenNachrichten), sodaß die Nutzung von Serialisierung gerade in relativ homogenen verteilten Systemen einen signifikanten Kostenvorteil bringt.