Titel   Inhalt   Suchen   Index   DOC  Handbuch der Java-Programmierung, 3. Auflage
 <<    <     >    >>   API  Kapitel 41 - Serialisierung

41.2 Weitere Aspekte der Serialisierung



Mit den Grundlagen aus dem vorigen Abschnitt sind bereits die wichtigsten Prinzipien der Serialisierung in Java erklärt. Beeindruckend ist dabei einerseits, wie das Konzept in die Klassenbibliothek eingebunden wurde. ObjectOutputStream und ObjectInputStream passen in natürlicher Weise in die Stream-Hierarchie und zeigen, wie man Streams konstruiert, die strukturierte Daten verarbeiten. Andererseits ist es eine große Hilfe, daß Objekte ohne größere Änderungen serialisiert werden können. Es ist lediglich erforderlich, das Serializable-Interface zu implementieren, um ein einfaches Objekt persistent machen zu können.

Dennoch ist das API leistungsfähig genug, auch komplexe Klassen serialisierbar zu machen. Wir wollen in diesem Abschnitt weiterführende Aspekte betrachten, die im Rahmen dieser Einführung noch verständlich sind. Daneben gibt es weitere Möglichkeiten, mit denen das Serialisieren und Deserialisieren von Klassen komplett an die speziellen Anforderungen einer Applikation angepaßt werden kann. Auf diese Details wollen wir hier aber nicht eingehen. Als vertiefende Lektüre empfiehlt sich die »Java Object Serialization Specification«, die seit der Version 1.2 Bestandteil der Online-Dokumentation des JDK ist.

41.2.1 Versionierung

Applikationen, in denen Code und Daten getrennt gehalten werden, haben grundsätzlich mit dem Problem der Inkonsistenz beider Bestandteile zu kämpfen. Wie kann sichergestellt werden, daß die Struktur der zu verarbeitenden Daten tatsächlich den vom Programm erwarteten Strukturen entspricht? Dieses Problem gibt es bei praktisch allen Datenbankanwendungen, und es tritt immer dann verstärkt auf, wenn Code und Datenstruktur getrennt geändert werden. Auch durch das Serialisieren von Objekten haben wir das Problem, denn die Datei mit den serialisierten Objekten enthält nur die Daten, der zugehörige Code kommt dagegen aus dem .class-File.

Das Serialisierungs-API versucht diesem Problem mit einem Versionierungsmechanismus zu begegnen. Dazu enthält das Interface Serializable eine long-Konstante serialVersionUID, in der eine Versionskennung zur Klasse gespeichert wird. Sie wird beim Aufruf von writeObject automatisch berechnet und stellt einen Hashcode über die wichtigsten Eigenschaften der Klasse dar. So gehen beispielsweise Name und Signatur der Klasse, implementierte Interfaces sowie Methoden und Konstruktoren in die Berechnung ein. Selbst triviale Änderungen wie das Umbenennen oder Hinzufügen einer öffentlichen Methode verändern die serialVersionUID.

Die serialVersionUID einer Klasse kann mit Hilfe des Hilfsprogramms serialver ermittelt werden. Dieses einfache Programm wird zusammen mit dem Namen der Klasse in der Kommandozeile aufgerufen und liefert die Versionsnummer als Ausgabe. Alternativ kann es auch mit dem Argument -show aufgerufen werden. Es hat dann eine einfache Oberfläche, in der der Name der Klasse interaktiv eingegeben werden kann (siehe Abbildung 41.1).

 Tip 

Abbildung 41.1: Das Programm serialver

Beim Serialisieren eines Objektes wird auch die serialVersionUID der zugehörigen Klasse mit in die Ausgabedatei geschrieben. Soll das Objekt später deserialisiert werden, so wird die in der Datei gespeicherte serialVersionUID mit der aktuellen serialVersionUID des geladenen .class-Files verglichen. Stimmen beide nicht überein, so gibt es eine Ausnahme des Typs InvalidClassException, und der Deserialisierungsvorgang bricht ab.

Diese Art der Versionierung ist zwar recht sicher, aber auch sehr rigoros. Schon eine kleine Änderung an der Klasse macht die serialisierten Objekte unbrauchbar, weil sie sich nicht mehr deserialisieren lassen. Die in Listing 41.1 vorgestellte Klasse Time hat die serialVersionUID -8717671986526504937L. Wird beispielsweise eine neue Methode public void test() hinzugefügt (die für das Deserialisieren eigentlich völlig bedeutungslos ist), ändert sich die serialVersionUID auf 9202005869290334574L, und weder die Datei test1.ser noch test2.ser lassen sich zukünftig deserialisieren.

Anstatt die serialVersionUID automatisch berechnen zu lassen, kann sie von der zu serialisierenden Klasse auch fest vorgegeben werden. Dazu wird einfach eine Konstante static final long serialVersionUID definiert und mit einem vorgegebenen Wert belegt (der zum Beispiel mit Hilfe von serialver ermittelt wird). In diesem Fall wird die serialVersionUID beim Aufruf von writeObject nicht neu berechnet, sondern es wird der vorgegebene Wert verwendet. Läßt man diese Konstante unverändert, können beliebige Änderungen der Klasse durchgeführt werden, ohne daß readObject beim Deserialisieren mit einer Ausnahme abbricht. Die Time-Klasse aus Listing 41.1 hätte dann folgendes Aussehen:

001 import java.io.*;
002 
003 public class Time
004 implements Serializable
005 {
006   static final long serialVersionUID = -8717671986526504937L;
007 
008   private int hour;
009   private int minute;
010 
011   public Time(int hour, int minute)
012   {
013     this.hour = hour;
014     this.minute = minute;
015   }
016 
017   public String toString()
018   {
019     return hour + ":" + minute;
020   }
021 }
Listing 41.6: Die Uhrzeitklasse mit serialVersionUID

Jetzt muß die Anwendung natürlich selbst darauf achten, daß die durchgeführten Änderungen kompatibel sind, daß also durch das Laden der Daten aus dem älteren Objekt keine Inkonsistenzen verursacht werden. Dabei mögen folgende Regeln als Anhaltspunkte dienen:

Solange die Änderungen kompatibel bleiben, ist also durch eine feste serialVersionUID sichergestellt, daß serialisierte Objekte lesbar und deserialisierbar bleiben. Sind die Änderungen dagegen inkompatibel, sollte die Konstante entsprechend geändert werden, und die serialisierten Daten dürfen nicht mehr verwendet werden (bzw. müssen vor der weiteren Verwendung konvertiert werden).

41.2.2 Nicht-serialisierte Membervariablen

Mitunter besitzt eine Klasse Membervariablen, die nicht serialisiert werden sollen. Typische Beispiele sind Variablen, deren Wert sich während des Programmlaufs dynamisch ergibt, oder solche, die nur der temporären Kommunikation zwischen zwei oder mehr Methoden dienen. Auch Daten, die nur im Kontext der laufenden Anwendung Sinn machen, wie beispielsweise Filehandles, Sockets oder GUI-Ressourcen, sollten nicht serialisiert werden; sie »verfallen« mit dem Ende des Programms.

Membervariablen, die nicht serialisiert werden sollen, können mit dem Attribut transient versehen werden. Dadurch werden sie beim Schreiben des Objekts mit writeObject ignoriert und gelangen nicht in die Ausgabedatei. Beim Deserialisieren werden die transienten Objekte lediglich mit dem typspezifischen Standardwert belegt.

41.2.3 Objektreferenzen

Eine wichtige Eigenschaft des Serialisierungs-APIs im JDK ist die, daß auch Referenzen automatisch gesichert und rekonstruiert werden. Besitzt ein Objekt selbst Strings, Arrays oder andere Objekte als Membervariablen, so werden diese ebenso wie die primitiven Typen serialisiert und deserialisiert. Da eine Objektvariable lediglich einen Verweis auf das im Hauptspeicher allozierte Objekt darstellt, ist es wichtig, daß diese Verweise auch nach dem Serialisieren/Deserialisieren erhalten bleiben. Insbesondere darf ein Objekt auch dann nur einmal angelegt werden, wenn darauf von mehr als einer Variable verwiesen wird. Auch nach dem Deserialisieren darf das Objekt nur einmal vorhanden sein, und die verschiedenen Objektvariablen müssen auf dieses Objekt zeigen.

Der ObjectOutputStream hält zu diesem Zweck eine Hashtabelle, in der alle bereits serialisierten Objekte verzeichnet werden. Bei jedem Aufruf von writeObject wird zunächst in der Tabelle nachgesehen, ob das Objekt bereits serialisiert wurde. Ist das der Fall, wird in der Ausgabedatei lediglich ein Verweis auf das Objekt gespeichert. Andernfalls wird das Objekt serialisiert und in der Hashtabelle eingetragen. Beim Deserialisieren eines Verweises wird dieser durch einen Objektverweis auf das zuvor deserialisierte Objekt ersetzt. Auf diese Weise werden Objekte nur einmal gespeichert, die Objektreferenzen werden konserviert, und das Problem von Endlosschleifen durch zyklische Referenzen ist ebenfalls gelöst.

Das folgende Programm zeigt das Speichern von Verweisen am Beispiel eines Graphen, der Eltern-Kind-Beziehungen darstellt. Zunächst benötigen wir dazu eine Klasse Person, die den Namen und die Eltern einer Person speichern kann. Jeder Elternteil wird dabei durch einen Verweis auf eine weitere Person dargestellt:

001 import java.io.*;
002 
003 public class Person
004 implements Serializable
005 {
006   public String name;
007   public Person mother;
008   public Person father;
009 
010   public Person(String name)
011   {
012     this.name = name;
013   }
014 }
Person.java
Listing 41.7: Die Klasse Person

Der Einfachheit halber wurden alle Membervariablen als public deklariert. Wir wollen nun ein Programm erstellen, das den folgenden Eltern-Kind-Graph aufbaut:

Abbildung 41.2: Eltern-Kind-Graph für Serialisierungsbeispiel

Das Programm soll den Graph dann in eine Datei test3.ser serialisieren und anschließend durch Deserialisieren wieder rekonstruieren. Wir wollen dann überprüfen, ob alle Verweise wiederhergestellt wurden und ob die Objekteindeutigkeit gewahrt wurde.

001 /* Listing4108.java */
002 
003 import java.io.*;
004 import java.util.*;
005 
006 public class Listing4108
007 {
008   public static void main(String[] args)
009   {
010     //Erzeugen der Familie
011     Person opa = new Person("Eugen"); 
012     Person oma = new Person("Therese");
013     Person vater = new Person("Barny");
014     Person mutter = new Person("Wilma");
015     Person kind1 = new Person("Fritzchen");
016     Person kind2 = new Person("Kalli");
017     vater.father = opa;
018     vater.mother = oma;
019     kind1.father = kind2.father = vater;
020     kind1.mother = kind2.mother = mutter; 
021 
022     //Serialisieren der Familie
023     try {
024       FileOutputStream fs = new FileOutputStream("test3.ser");
025       ObjectOutputStream os = new ObjectOutputStream(fs);
026       os.writeObject(kind1);
027       os.writeObject(kind2);
028       os.close();
029     } catch (IOException e) {
030       System.err.println(e.toString());
031     }
032 
033     //Rekonstruieren der Familie
034     kind1 = kind2 = null; 
035     try {
036       FileInputStream fs = new FileInputStream("test3.ser");
037       ObjectInputStream is = new ObjectInputStream(fs);
038       kind1 = (Person)is.readObject();
039       kind2 = (Person)is.readObject();
040       //Überprüfen der Objekte
041       System.out.println(kind1.name); 
042       System.out.println(kind2.name);
043       System.out.println(kind1.father.name);
044       System.out.println(kind1.mother.name);
045       System.out.println(kind2.father.name);
046       System.out.println(kind2.mother.name);
047       System.out.println(kind1.father.father.name);
048       System.out.println(kind1.father.mother.name); 
049       //Name des Vaters ändern
050       kind1.father.name = "Fred"; 
051       //Erneutes Überprüfen der Objekte
052       System.out.println("---"); 
053       System.out.println(kind1.name);
054       System.out.println(kind2.name);
055       System.out.println(kind1.father.name);
056       System.out.println(kind1.mother.name);
057       System.out.println(kind2.father.name);
058       System.out.println(kind2.mother.name);
059       System.out.println(kind1.father.father.name);
060       System.out.println(kind1.father.mother.name); 
061       is.close();
062     } catch (ClassNotFoundException e) {
063       System.err.println(e.toString());
064     } catch (IOException e) {
065       System.err.println(e.toString());
066     }
067   }
068 }
Listing4108.java
Listing 41.8: Serialisieren von Objekten und Referenzen

Das Programm erzeugt in den Zeilen 011 bis 020 zunächst den in Abbildung 41.2 abgebildeten Verwandtschaftsgraph und serialisiert ihn anschließend in die Datei test3.ser. Bemerkenswert ist hier vor allem, daß wir lediglich die beiden Kinder kind1 und kind2 explizit serialisieren. Da alle anderen Objekte über Verweise von den Kindern aus zu erreichen sind, ist es nicht nötig, diese separat mit writeObject zu speichern.

In Zeile 034 setzen wir die beiden Kindvariablen auf null, um zu beweisen, daß sie ausschließlich durch das nachfolgende Deserialisieren korrekt gesetzt werden. Nun werden kind1 und kind2 deserialisiert, und in den Zeilen 041 bis 048 wird der komplette Verwandtschaftsgraph ausgegeben. An der Ausgabe des Programms können wir erkennen, daß tatsächlich alle Objekte rekonstruiert und die Verweise darauf korrekt gesetzt wurden:

Fritzchen
Kalli
Barny
Wilma
Barny
Wilma
Eugen
Therese
---
Fritzchen
Kalli
Fred
Wilma
Fred
Wilma
Eugen
Therese

Der zweite Block von Ausgabeanweisungen (in den Zeilen 052 bis 060) zeigt, daß auch die Objekteindeutigkeit gewahrt wurde. Dazu haben wir nämlich in Zeile 050 den Namen des Vaterobjekts von kind1 auf "Fred" geändert. Wie im zweiten Teil der Ausgabe des Programms zu erkennen ist, wurde damit auch der Name des Vaters des zweiten Kindes auf "Fred" geändert, und wir können sicher sein, daß es sich um ein und dasselbe Objekt handelt.

Obwohl (oder gerade weil) das Serialisieren von Objektgraphen in aller Regel sehr bequem und vollautomatisch abläuft, seien an dieser Stelle einige Warnungen ausgesprochen:

  • Einerseits kann es passieren, daß mehr Objekte als erwartet serialisiert werden. Insbesondere bei komplexen Objektbeziehungen kann es sein, daß an dem zu serialisierenden Objekt indirekt viele weitere Objekte hängen und beim Serialisieren wesentlich mehr Objekte gespeichert werden, als erwartet wurde. Das kostet unnötig Zeit und Speicher.
  • Durch das Zwischenspeichern der bereits serialisierten Objekte in ObjectOutputStream werden viele Verweise auf Objekte gehalten, die sonst möglicherweise für das Programm unerreichbar wären. Da der Garbage Collector diese Objekte nicht freigibt, kann es beim Serialisieren einer großen Anzahl von Objekten zu Speicherproblemen kommen. Mit Hilfe der Methode reset kann der ObjectOutputStream in den Anfangszustand versetzt werden; alle bereits bekannten Objektreferenzen werden »vergessen«. Wird ein bereits serialisiertes Objekt danach noch einmal gespeichert, wird kein Verweis, sondern das Objekt selbst noch einmal geschrieben.
  • Wenn ein bereits serialisiertes Objekt verändert und anschließend erneut serialisiert wird, bleibt die Veränderung beim Deserialisieren unsichtbar, denn in der Ausgabedatei wird lediglich ein Verweis auf das Originalobjekt gespeichert.
 Warnung 

41.2.4 Serialisieren von Collections

Neben selbstgeschriebenen Klassen sind auch viele der Standardklassen des JDK serialisierbar, insbesondere die meisten Collection-Klassen. Um beispielsweise alle Daten eines Vektors oder einer Hashtable persistent zu speichern, genügt ein einfaches Serialisieren nach obigem Muster. Voraussetzung ist allerdings, daß auch die Elemente der Collection serialisierbar sind, andernfalls gibt es eine NotSerializableException. Auch die Wrapperklassen zu den Basistypen (siehe Abschnitt 10.2) sind standardmäßig serialisierbar und können damit problemlos als Objekte in serialisierbaren Collections verwendet werden. Im nächsten Abschnitt stellen wir eine kleine Anwendung für das Serialisieren von Hashtabellen vor.


 Titel   Inhalt   Suchen   Index   DOC  Handbuch der Java-Programmierung, 3. Auflage, Addison Wesley, Version 3.0.1
 <<    <     >    >>   API  © 1998-2003 Guido Krüger, http://www.javabuch.de