13.12 Persistente Objekte und Serialisierung 

Objekte liegen zwar immer nur zur Laufzeit vor, doch auch nach dem Beenden der virtuellen Maschine soll ihre Struktur nicht verloren gehen. Gewünscht ist ein Mechanismus, der die Objektstruktur und Variablenbelegung zu einer bestimmten Zeit sicher (persistent) macht und an anderer Stelle wieder hervorholt und die Objektstruktur und Variablenbelegung restauriert. Im gespeicherten Datenformat müssen alle Informationen wie Objekttyp und Variablentyp enthalten sein, um später das richtige Wiederherstellen zu ermöglichen. Da Objekte oftmals weitere Objekte einschließen, müssen auch diese Unterobjekte gesichert werden. (Schreibe ich eine Liste mit Bestellungen, so ist die Liste ohne die referenzierten Objekte sinnlos.) Genau dieser Mechanismus wird auch dann angewendet, wenn Objekte über das Netzwerk schwirren. [Die Rede ist hier von RMI. ] Die persistenten Objekte sichern also neben ihren eigenen Informationen auch die Unterobjekte – also die von der betrachtenden Stelle aus erreichbaren. Beim Speichern wird rekursiv ein Objektbaum durchlaufen, um eine vollständige Datenstruktur zu erhalten. Der doppelte Zugriff auf ein Objekt wird hier ebenso beachtet wie der Fall, dass zyklische Abhängigkeiten auftreten. Jedes Objekt bekommt dabei ein Handle, sodass es im Datenstrom nur einmal kodiert wird.
Unter Java lassen sich Objekte über verschiedene Ansätze persistent speichern:
- Standardserialisierung. Der Punkt, mit dem wir uns im Folgenden beschäftigen wollen. Die Objektstruktur und Zustände werden in einem binären Format gesichert. Sie wird auch Java Object Serialization (JOS) genannt.
- Serialisierung in XML. JavaBeans – und nur solche – können wir in einem XML-Format sichern. Das wird JavaBeans Persistence (JBP) genannt.
- Datenbanken. Die Daten kommen über JDBC in die Datenbank.
Diese drei Techniken sind mit Standard-Java zu lösen. Die nächsten Implementierungen bauen auf zusätzlichen Frameworks auf:
- JAXB. Abbilden der Objektstruktur auf XML-Dokumente. Die Struktur der XML-Datei ist über Annotationen beschrieben. JAXB ist Teil von Java 6.
- Objekt-relationales Mapping. Das Schreiben und Lesen von Hand über JDBC ist sehr lästig, so dass dieser Schritt automatisiert werden muss. Über eine Beschreibung der Objekt-Daten ist es möglich, die Daten automatisch auf Tabellen einer Datenbank abzubilden. Umgesetzt wird dies zum Beispiel durch Hibernate oder den Standard JPA (Java Persistence API), eine populärere Möglichkeit, Objekte in Datenbanken oder anderen Containern abzulegen und auszulesen.
- Bean-Persistenz durch Enterprise JavaBeans (EJB). Die aufwändigste Lösung. Der EJB-Container schreibt die Zustände der Entity-Beans automatisch in die Datenbank. EJBs sind Teil der Java Enterprise Edition. EJB 3 basiert auf JPA, was Teil der Java EE 5 ist.
13.12.1 Objekte mit der Standard-Serialisierung speichern und lesen 

Die Standard-Serialisierung bietet eine einfache Möglichkeit, Objekte persistent zu machen und später wiederherzustellen. Dabei werden die Objektzustände (keine statischen!) in einen Byte-Strom geschrieben (Serialisierung), woraus sie später wieder zu einem Objekt rekonstruiert werden können (Deserialisierung). Im Zentrum stehen zwei Klassen und ihre (De-)Serialisierungs-Methode:
- Serialisierung. Die Klasse ObjectOutputStream und die Methode writeObject(). Während der Serialisierung geht ObjectOutputStream die Zustände und Objektverweise rekursiv ab und schreibt die Zustände Schritt für Schritt in einen Ausgabestrom.
- Deserialisierung. Zum Lesen der serialisierten Objektzustände dient die Klasse ObjectInputStream. Ihre Methode readObject() findet den Typ des serialisierten Objekts und baut daraus zur Laufzeit das Zielobjekt auf.
ObjectOutputStream
An einem Beispiel lässt sich gut erkennen, wie ein ObjectOutputStream einen String und das aktuelle Tagesdatum in einen OutputStream speichert. Um die Daten in eine Datei zu holen, ist der OutputStream ein FileOutputStream für eine Datei datum.ser. Der Dateiname wird meist so gewählt, dass er mit .ser endet.
Listing 13.38 com/tutego/insel/io/ser/SerializeAndDeserializeDate.java, serialize()
OutputStream fos = null; try { fos = new FileOutputStream( filename ); ObjectOutputStream o = new ObjectOutputStream( fos ); o.writeObject( "Today" ); o.writeObject( new Date() ); } catch ( IOException e ) { System.err.println( e ); } finally { try { fos.close(); } catch ( Exception e ) { } }
Aller Anfang bildet wie üblich ein OutputStream, der die Zustände der Objekte und Metainformationen aufnimmt. In unserem Fall ist das der FileOutputStream. Die Verbindung zwischen der Datei und dem Objektstrom durch die Klasse ObjectOutputStream geschieht über den Konstruktor, der einen OutputStream annimmt. ObjectOutputStream implementiert die Schnittstelle ObjectOutput und bietet so beispielsweise die Funktion writeObject() zum Schreiben von Objekten. Damit wird das Serialisieren des String-Objekts (das »Today«) und des anschließenden Datum-Objekts zum Kinderspiel.
class java.io.ObjectOutputStream
extends OutputStream
implements ObjectOutput, ObjectStreamConstants |
- ObjectOutputStream( OutputStream out ) throws IOException Erzeugt einen ObjectOutputStream, der in den angegebenen OutputStream schreibt. Ein Fehler kann von den Methoden aus dem OutputStream kommen.
- final void writeObject( Object obj ) throws IOException Schreibt das Objekt.
- void flush() throws IOException Schreibt noch gepufferte Daten.
- void close() throws IOException Schließt den Datenstrom. Die Methode muss aufgerufen werden, bevor der Datenstrom zur Eingabe verwendet werden soll.
Die Methode writeObject() kann nicht nur bei Ein-/Ausgabefehlern eine IOException auslösen, sondern auch eine NotSerializableException, wenn das Objekt gar nicht serialisierbar ist und eine InvalidClassException, wenn beim Serialisierungen etwas falsch läuft.
Objekte über die Standard-Serialisierung lesen
Aus den Daten im Datenstrom stellt der ObjectInputStream ein neues Objekt her und initialisiert die Zustände wie sie geschrieben wurde. Wenn nötig, restauriert der ObjectInputStream auch Objekte, auf die verwiesen wurde. Die Klasseninformationen müssen zur Laufzeit vorhanden sein, weil bei der Serialisierung nur die Zustände, aber keine .class-Dateien gesichert werden. Während des Lesens findet readObject() also bei unserem Beispiel den String und das Datum. Der ObjectInputStream erwartet die Rohdaten wie üblich über einen Eingabestrom. Kommen die Informationen aus einer Datei, verwenden wir den FileInputStream.
Listing 13.39 com/tutego/insel/io/ser/SerializeAndDeserializeDate.java, deserialize ()
InputStream fis = null; try { fis = new FileInputStream( filename ); ObjectInputStream o = new ObjectInputStream( fis ); String string = (String) o.readObject(); Date date = (Date) o.readObject(); System.out.println( string ); System.out.println( date ); } catch ( IOException e ) { System.err.println( e ); } catch ( ClassNotFoundException e ) { System.err.println( e ); } finally { try { fis.close(); } catch ( Exception e ) { } }
Die explizite Typumwandlung kann natürlich bei einer falschen Zuweisung zu einem Fehler führen. Bei generischen Typen ist diese Typanpassung immer etwas lästig.
class java.io.ObjectInputStream
extends InputStream
implements ObjectInput, ObjectStreamConstants |
- ObjectInputStream( InputStream out ) throws IOException Erzeugt einen ObjectInputStream, der aus einem gegebenen InputStream liest.
- final Object readObject() throws ClassNotFoundException, IOException Liest ein Object und gibt es zurück. Eine ClassNotFoundException wird ausgelöst, wenn das Objekt zu einer Klasse gehört, die nicht auffindbar ist.
- void close() throws IOException Schließt den Eingabestrom.
Die Schnittstellen DataOutput und DataInput
Die Klasse ObjectOutputStream bekommt die Vorgabe für writeObject() aus einer Schnittstelle ObjectOutput, genauso wie ObjectInputStream die Operation readObject() aus ObjectInput implementiert. Bis auf die Standard-Serialisierung haben die Schnittstellen in Java keine weitere Verwendung.
Das Interface ObjectOutput erweitert selbst die Schnittstelle DataOutput um das Schreiben von Primitiven: write(byte[]), write(byte[], int, int), write(int), writeBoolean(boolean), writeByte(int), writeBytes(String), writeChar(int), writeChars(String), writeDouble(double), writeFloat(float), writeInt(int), writeLong(long), writeShort(int) und writeUTF(String). Das ist bei einer eigenen angepassten Serialisierung interessant, wenn wir selbst das Schreiben von Zuständen übernehmen. Umgekehrt schreibt die Schnittstelle DataInput Lesefunktionen vor, die ObjectInput implementiert.
13.12.2 Zwei einfache Anwendungen der Serialisierung 

Objekte über das Netzwerk schicken
Es ist natürlich wieder feines objektorientiertes Design, dass es der Methode writeObject() egal ist, wohin das Objekt geschoben wird. Dazu wird ja einfach dem Konstruktor von Object-OutputStream ein OutputStream übergeben, und writeObject() delegiert dann das Senden der entsprechenden Einträge an die passenden Methoden der Output-Klasse. Im oberen Beispiel haben wir ein FileOutputStream benutzt. Es gibt aber noch eine ganze Menge anderer Klassen, die OutputStream erweitern. So können die Objekte auch in einer Datenbank abgelegt beziehungsweise über das Netzwerk verschickt werden. Wie dies funktioniert, zeigen die nächsten Zeilen:
Socket s = new Socket( host, port ); OutputStream os = s.getOutputStream(); ObjectOutputStream oos = new ObjectOutputStream( os ); oos.writeObject( object ); oos.flush();
Über s.getOutputStream() gelangen wir an den Datenstrom. Dann sieht alles wie gewohnt aus. Da wir allerdings auf der Empfängerseite noch ein Protokoll ausmachen müssen, verfolgen wir diesen Weg der Objektversendung nicht weiter und verlassen uns vielmehr auf eine Technik, die sich RMI nennt.
Objekte in ein Byte-Feld schreiben
Die Klassen ObjectOutputStream und ByteArrayOutputStream sind zusammen zwei gute Partner, wenn es darum geht, eine Repräsentation eines Objekts im Speicher zu erzeugen und die geschätzte Größe eines Objekts herauszufinden.
Object o = ...; ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream( baos ); oos.writeObject( o ); oos.close(); byte[] array = baos.toByteArray();
Nun steht das Objekt im Byte-Feld. Wollten wir die Größe erfragen, müssten wir das Attribut length des Felds auslesen.
13.12.3 Die Schnittstelle Serializable 

Bisher nahmen wir immer an, dass eine Klasse weiß, wie sie geschrieben wird. Das funktioniert wie selbstverständlich bei vielen vorhandenen Klassen, und so müssen wir uns bei writeObject(new Date()) keine Gedanken darüber machen, wie die Bibliothek das Datum schreibt und auch wieder liest.
Damit Objekte serialisiert werden können, müssen die Klassen die Schnittstelle Serializable implementieren. Diese Schnittstelle enthält keine Methoden und ist nur eine Markierungsschnittstelle (engl. marker interface). Implementiert eine Klasse diese Schnittstelle nicht, folgt beim Serialisierungsversuch eine NotSerializableException. Eine Klasse wie java.util. Date implementiert somit Serializable, Thread jedoch nicht. Der Serialisierer lässt damit alle Klassen »durch«, die instanceof Serializable sind. Daraus folgt, dass alle Unterklassen einer Klasse, die serialisierbar ist, auch ihrerseits serialisierbar sind. So implementiert java.lang.Number – die Basisklasse der Wrapper-Klassen – die Schnittstelle Serializable, und die konkreten Wrapper-Klassen wie Integer, BigDecimal sind somit ebenfalls serialisierbar.
|
Das Datenvolumen kann natürlich groß werden, wenn schlanke, nicht-statische innere Serializable-Klassen in einer äußeren Serializable-Klassen liegen, die sehr viele Eigenschaften besitzt. |
Person als Beispiel für eine serialisierbare Klasse
Wir wollen im Folgenden eine Klasse Person serialisierbar machen. Dazu benötigen wir das folgende Gerüst:
Listing 13.40 com/tutego/insel/io/ser/Person.java
package com.tutego.insel.io.ser;
import java.io.Serializable;
import java.util.Date;
public class Person implements Serializable
{
static int BMI_OVERWEIGHT = 25;
String name;
Date birthday;
double bodyHeight;
}
Erzeugen wir ein Person-Objekt p, und rufen writeObject(p) auf, so schiebt der ObjectOutputStream die Variablen-Belegungen (hier name, birthday und bodyHeight) in den Daten-strom.
Statische Variablen wie BMI_OVERWEIGHT werden mit dem Standard-Serialisierungsmechanismus nicht gesichert. Bevor durch Deserialisierung ein Objekt einer Klasse erzeugt wird, muss schon die Klasse geladen sein, was bedeutet, dass statische Variablen schon initialisiert sind. Wenn zwei Objekte wieder deserialisiert werden, könnte es andernfalls vorkommen, dass beide unterschiedliche Werte aufweisen. Was sollte dann passieren?
Nicht serialisierbare Objekte
Nicht alle Objekte sind serialisierbar. Zu den nicht serialisierbaren Klassen gehören zum Beispiel Thread und Socket und viele weitere Klassen aus dem java.io-Paket. Das liegt daran, dass nicht klar ist, wie zum Beispiel ein Wiederaufbau aussehen sollte. Wenn ein Thread etwa eine Datei zum Lesen geöffnet hat, wie soll der Zustand serialisiert werden, so dass er beim Deserialisieren auf einem anderen Rechner sofort wieder laufen und dort weitermachen kann, wo er mit dem Lesen aufgehört hat?
Ob Objekte als Träger sensibler Daten serialisierbar sein sollen, ist gut zu überlegen. Denn bei der Serialisierung der Zustände – es werden auch private Attribute serialisiert, an die zunächst nicht so einfach heranzukommen ist – öffnet sich die Kapselung. Aus dem Datenstrom lassen sich die internen Belegungen ablesen und auch manipulieren.
|
13.12.4 Nicht serialisierbare Attribute aussparen 

Es gibt eine Reihe von Objekttypen, die sich nicht serialisieren lassen – technisch gesprochen implementieren diese Klassen die Schnittstelle Serializable nicht. Doch gibt es überhaupt Objekte, die nicht persistent gemacht werden sollen? Eine Antwort wäre: Sicherheit! Ein Objekt, das etwa Passwörter speichert, soll nicht einfach geschrieben werden. Da reicht es nicht, dass die Attribute privat sind, denn auch sie werden geschrieben. Der andere Punkt ist die Tatsache, dass sich nicht alle Zustände beim Deserialisieren wiederherstellen lassen. Was ist, wenn ein FileInputStream serialisiert wird? Soll dann bei der Deserialisierung eine Datei geöffnet werden? Was ist, wenn die Datei nicht vorhanden ist? Was ist mit einem Socket oder einem ServerSocket? Da all diese Fragen ungeklärt sind, ist es am einfachsten, diese Klasse nicht die Schnittstelle Serializable implementieren zu lassen.
In diesem Fall haben wir jedoch spätestens dann ein Problem, wenn ein Objekt geschrieben wird, das intern auf ein nicht serialisierbares Objekt – etwa auf einen Thread – verweist.
Die Serialisierung der folgenden Klasse führt zu einem Laufzeitfehler:
Listing 13.41 com/tutego/insel/io/ser/NotTransientNotSerializable.java
class NotTransientNotSerializable implements Serializable { Thread t = new Thread(); // transient Thread t = new Thread(); String s = "Fremde sind Freunde, die man nur noch nicht kennen gelernt hat."; }
Der Fehler wird eine NotSerializableException sein:
Exception in thread "main" java.io.NotSerializableException: java.lang.Thread at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1151) at java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1504) at java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1469) at java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1387) at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1145) at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:326) at com.tutego.insel.io.ser.SerializeTransient.main(SerializeTransient.java:19)
Die Begründung dafür ist einfach: Ein Thread lässt sich nicht serialisieren.
Wollten wir ein Objekt vom Typ NotTransientNotSerializable ohne Thread serialisieren, müssen wir dem Serialisierungsmechanismus mitteilen: »Nimm so weit alle Objekte, aber nicht den Thread!«
Um Elemente bei der Serialisierung auszusparen, bietet Java zwei Möglichkeiten:
- ein spezielles Schlüsselwort: transient
- Das Feld private final ObjectStreamField[] serialPersistentFields = {...} zählt alle serialisierbaren Eigenschaften auf.
Statische Eigenschaften würden auch nicht serialisiert, aber das ist hier nicht unser Ziel.
Das Schlüsselwort transient
Dazu gibt es in Java ein spezielles Schlüsselwort: transient, das alle Attribute markiert, die nicht persistent sein sollen. Damit lassen wir die nicht serialisierbaren Kandidaten außen vor und speichern alles ab, was sich speichern lässt.
transient Thread t; |
Die Variable serialPersistentFields
Erkennt der Serialisierer in der Klasse eine private statische Feld-Variable serialPersistentFields, wird er die ObjectStreamField-Einträge des Feldes beachten und nur die dort aufgezählten Elemente serialisieren, egal, was transient markiert ist.
private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[] { new ObjectStreamField( "s", String.class ), new ObjectStreamField( "date", Date.class ) }; |
13.12.5 Das Abspeichern selbst in die Hand nehmen 

Die Java-Bibliothek realisiert intern ein Serialisierungs-Protokoll, was beschreibt, wie die Abbildung auf einen Bytestrom aussieht. Dieses »Object Serialization Stream Protocol« beschreibt Sun unter http://java.sun.com/javase/6/docs/platform/serialization/spec/protocol.html etwas genauer, aber Details sind normalerweise nicht nötig.
Es kann aber passieren, dass die Standard-Serialisierung nicht erwünscht ist, wenn zum Beispiel beim Zurücklesen weitere Objekte erzeugt werden sollen oder wenn beim Schreiben eine bessere Abbildung durch Kompression möglich ist. Für diesen Fall müssen spezielle Methoden implementiert werden. Beide müssen die nachstehenden Signaturen aufweisen:
private synchronized void writeObject( java.io.ObjectOutputStream s ) throws IOException
und
private synchronized void readObject( java.io.ObjectInputStream s ) throws IOException, ClassNotFoundException
Die Methode writeObject() ist für das Schreiben verantwortlich. Ist der Rumpf leer, gelangen keine Informationen in den Strom, und das Objekt wird folglich nicht gesichert. readObject() wird während der Deserialisierung aufgerufen. Ist dieser Rumpf leer, werden keine Zustände rekonstruiert.
Mit diesen Funktionen können wir also die Serialisierung selbst in die Hand nehmen und die Attribute so speichern, wie wir es für sinnvoll halten; eine Kompatibilität lässt sich erzwingen. Eine kleine Versionsnummer im Datenstrom könnte eine Verzweigung provozieren, in der die Daten der Version 1 oder andere Daten der Version 2 gelesen werden. Auch können auf diese Weise statische Attribute in den Datenstrom gelangen.
Beim Lesen können komplette Objekte wieder aufgebaut werden, und es lassen sich zum Beispiel nicht transiente Objekte wiederbeleben. Stellen wir uns einen Thread vor, dessen Zustände beim Schreiben persistent gemacht werden, und beim Lesen wird ein Thread-Objekt wieder erzeugt und zum Leben erweckt.
Oberklassen serialisieren sich gleich mit
Wird eine Klasse serialisiert, so werden automatisch die Informationen der Oberklasse mit serialisiert. Hierbei gilt, dass wie beim Konstruktor erst die Attribute der Oberklasse in den Datenstrom geschrieben werden und anschließend die Attribute der Unterklasse. Insbesondere bedeutet dies, dass die Unterklasse nicht noch einmal die Attribute der Oberklasse speichern sollte. Das folgende Programm zeigt den Effekt:
Listing 13.42 com/tutego/insel/io/ser/WriteTop.java
import java.io.*; class Base implements Serializable { private void writeObject( ObjectOutputStream oos ) { System.err.println( "Base" ); } } public class WriteTop extends Base { public static void main( String[] args ) throws IOException { ObjectOutputStream oos = new ObjectOutputStream( System.out ); oos.writeObject( new WriteTop() ); } private void writeObject( ObjectOutputStream oos ) { System.err.println( "Top" ); } }
In der Ausgabe von Eclipse sind die Ausgaben »Base« und »Top« in einer anderen Farbe dargestellt.
Doch noch den Standardserialisierer nutzen
Die Funktionen readObject()/writeObject() sind Alles-oder-nichts-Funktionen. Erkennt der Serialisierer, dass die Schnittstelle Serializable implementiert wird, fragt er die Klasse, ob sie die Methoden implementiert. Wenn nicht, beginnt bei der Serialisierung der Serialisierungsmechanismus eigenständig die Attribute auszulesen und in den Datenstrom zu schreiben. Gibt es die readObject()/writeObject()-Methoden, so wird der Serialisierer diese aufrufen und nicht selbst die Objekte nach den Werten fragen oder die Objekte mit Werten füllen.
Doch die Arbeit des Serialisierers ist eine große Hilfe. Falls viele Attribute zu speichern sind, fällt viel lästige Arbeit beim Programmieren an, da für jedes zu speichernde Attribut eine eigene write-Funktion und beim Lesen eine entsprechende read-Funktion benötigt werden. Aus diesem Dilemma gibt es einen Ausweg, weil der Serialisierer in den readObject/writeObject()-Methoden auch nachträglich dazu verpflichtet werden kann, die nicht transienten Attribute zu lesen oder zu schreiben. Die privaten Funktionen readObject() und writeObject() bekommen als Argument ein ObjectInputStream und ein ObjectOutputStream, die über die entsprechende Funktion verfügen.
Die Klasse ObjectOutputStream erweitert java.io.OutputStream unter anderem um die Methode defaultWriteObject(). Sie speichert die Attribute einer Klasse.
class java.io.ObjectOutputStream
extends OutputStream
implements ObjectOutput, ObjectStreamConstants |
- public final void defaultWriteObject() throws IOException Schreibt alle nicht statischen und nicht transienten Attribute in den Datenstrom. Die Methode kann nur innerhalb einer privaten writeObject()-Funktion aufgerufen werden; andernfalls erhalten wir eine NotActiveException.
Das Gleiche gilt für die Funktion defaultReadObject() in der Klasse ObjectInputStream.
Unsere nächste Klasse SpecialWomen deklariert zwei Attribute: name und alter. Da manche Frauen über ihr Alter nicht sprechen wollen, soll alter nicht serialisiert werden; es ist transient. Wir implementieren eigene readObject()/writeObject()-Funktionen, die den Standardserialisierer bemühen. In readObject() wird die Frau dann immer 30 bleiben.
Listing 13.43 com/tutego/insel/io/ser/SpecialWomen.java
package com.tutego.insel.io.ser; import java.io.*; public class SpecialWomen implements Serializable { private static final long serialVersionUID = 2584203323009771108L; String name = "Tatjana"; transient int age = 30; private void writeObject( ObjectOutputStream oos ) throws IOException { oos.defaultWriteObject(); // Write name but not age } private void readObject( ObjectInputStream ois ) throws IOException { try { ois.defaultReadObject(); // Read name but there is no age age = 30; } catch ( ClassNotFoundException e ) { throw new IOException( "No class found. HELP!!" ); } } }
|
Der andere macht’s: writeReplace() und readResolve()
Eine Klasse muss die Serialisierung nicht selbst übernehmen, sondern kann die Arbeit abgeben. Dazu muss zum Schreiben eine Methode writeReplace() implementiert werden, die eine Referenz auf ein Objekt liefert, das das Schreiben übernimmt. Anregungen können Leser bei Sun unter http://java.sun.com/javase/6/docs/platform/serialization/spec/output.html#5324 sowie unter http://www.galileocomputing.de/openbook/java2/kap_12.htm#t24 und http://www.jguru.com/faq/view.jsp?EID=44039 entnehmen.
13.12.6 Tiefe Objektkopien 

Implementieren Klassen die Markierungsschnittstelle Serializable und überschreiben sie die clone()-Methode von Object, so können sie eine Kopie der Werte liefern. Die üblichen Implementierungen liefert aber nur flache Kopien. Dies bedeutet, dass Referenzen auf Objekte, die von dem zu klonenden Objekt ausgehen, beibehalten und diese Objekte nicht extra kopiert werden. Als Beispiel kann die Datenstruktur List genügen, das Map-Objekte enthält. Ein Klon dieser Liste ist lediglich eine zweites Liste, dessen Elemente auf die gleichen Maps zeigen.
Möchten wir das Verhalten ändern und eine tiefe Kopie anfertigen, so haben wir dank eines kleinen Tricks damit keine Mühe: Wir könnten das zu klonende Objekt einfach serialisieren und dann wieder auspacken. Die zu klonenden Objekte müssen dann neben Cloneable noch das Serializable-Interface implementieren.
Listing 13.44 com/tutego/insel/io/ser/Dolly.java, deepCopy()
public static Object deepCopy( Object o ) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); new ObjectOutputStream( baos ).writeObject( o ); ByteArrayInputStream bais = new ByteArrayInputStream( baos.toByteArray() ); return new ObjectInputStream( bais ).readObject(); }
Das Einzige, was wir zum Gelingen der Methode deepCopy() beitragen müssen, ist, das Objekt in einem Byte-Feld zu serialisieren, es wieder auszulesen und zu einem Objekt zu konvertieren. Den Einsatz eines ByteArrayOutputStream haben wir schon beobachtet, als wir die Länge eines Objekts herausfinden wollten. Nun fügen wir das Feld einfach wieder zu einem ByteArrayInputStream hinzu, aus dessen Daten dann ObjectInputStream das Objekt rekreieren kann.
Überzeugen wir uns anhand eines kleinen Programms, dass die tiefe Kopie tatsächlich etwas anderes als ein clone() ist.
Listing 13.45 Dolly.java, main()
Map<String,String> map = new HashMap<String,String>(); map.put( "Cul de Paris", "hinten unter dem Kleid getragenes Gestell oder Polster" ); LinkedList<Map<String,String>> l1 = new LinkedList<Map<String,String>>(); l1.add( map ); @SuppressWarnings("unchecked") List<Map<String, String>> l2 = (List<Map<String, String>>) l1.clone(); @SuppressWarnings("unchecked") List<Map<String,String>> l3 = (List<Map<String,String>>) deepCopy( l1 ); map.clear(); System.out.println( l1 ); // [{}] System.out.println( l2 ); // [{}] System.out.println( l3 ); // [{Cul de Paris=hinten unter dem Kleid ...}]
Zunächst erstellen wir eine Map, die wir anschließend in eine Liste packen. Die Map enthält ein Pärchen. Kopiert clone() die Liste, so wird sie zwar selbst kopiert, aber nicht die referenzierten Map-Objekte – erst die tiefe Kopie kopiert die Map mit. Das sehen wir dann, wenn wir den Eintrag aus der Map löschen. Dann ergibt l1 genauso wie l2 eine leere Liste, da l2 nur die Verweise auf die Map gespeichert hat, die dann aber geleert ist. Anders ist dies bei l3, der tiefen Kopie: Hier ist das Paar noch vorhanden.
13.12.7 Versionenverwaltung und die SUID 

Die erste Version einer Klassenbibliothek ist in der Regel nicht vollständig und nicht beendet. Es kann gut sein, dass Attribute und Methoden nachträglich in die Klasse eingefügt, gelöscht oder modifiziert werden. Das bedeutet aber auch, dass die Serialisierung zu einem Problem werden kann. Denn ändert sich der Typ einer Variable oder kommen Variablen hinzu, dann ist eine gespeicherte Objektserialisierung nicht mehr gültig.
Bei der Serialisierung wird in Java nicht nur der Objektinhalt geschrieben, sondern zusätzlich eine eindeutige Kennung der Klasse, die UID. Die UID ist ein Hashcode aus Namen, Attributen, Parametern, Sichtbarkeit und so weiter. Sie wird als long wie ein Attribut gespeichert. Ändert sich der Aufbau einer Klasse, ändert sich der Hashcode und damit die UID. Klassen mit unterschiedlicher UID sind nicht kompatibel. Erkennt der Lesemechanismus in einem Datenstrom eine UID, die nicht zur Klasse passt, wird eine InvalidClassException ausgelöst. Das bedeutet, dass schon ein einfaches Hinzufügen von Attributen zu einem Fehler führt.
Wir wollen uns dies einmal anhand einer einfachen Klasse ansehen. Wir entwickeln eine Klasse SerMe mit einem einfachen Ganzzahlattribut. Später fügen wir eine Fließkommazahl hinzu.
Listing 13.46 com/tutego/insel/io/ser/InvalidSer.java, SerMe
class SerMe implements Serializable { int i; // double d; // float i; }
Dann benötigen wir noch das Hauptprogramm. Wir bilden ein Exemplar von SerMe und schreiben es in eine Datei. Ohne Änderungen können wir es direkt wieder deserialisieren. Ändern wir jedoch die Klassendeklaration, führt dies zu einem Fehler.
Listing 13.47 com/tutego/insel/io/ser/InvalidSer.java, main()
String filename = "c:/test.ser"; // Teil 1: Schreiben // ObjectOutputStream oo = new ObjectOutputStream( // new FileOutputStream( filename ) ); // oo.writeObject( new SerMe() ); // oo.close(); // Teil 2: Klasse SerMe ändern und zu lesen versuchen ObjectInputStream oi = new ObjectInputStream( new FileInputStream( filename ) ); SerMe o = (SerMe) oi.readObject(); oi.close();
Fügen wir der Klasse SerMe das Attribut double d hinzu oder ändern wir den Typ der Ganzzahlvariable auf float, folgt eine lange Fehlerliste:
java.io.InvalidClassException: SerMe; Local class not compatible: stream classdesc serialVersionUID=9027745268614067035 local class serialVersionUID=-3271853622578609637 at java.io.ObjectStreamClass.validateLocalClass(ObjectStreamClass.java:523) at java.io.ObjectStreamClass.setClass(ObjectStreamClass.java:567) at ujava.io.ObjectInputStream.inputClassDescriptor(ObjectInputStream.java:936) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:366) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:236) at java.io.ObjectInputStream.inputObject(ObjectInputStream.java:1186) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:386) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:236) at InvalidSer.main(InvalidSer.java:28)
Die eigene SUID
Dem oberen Fehlerauszug entnehmen wir, dass der Serialisierungsmechanismus die SUID selbst berechnet. Das Attribut ist als statische, finale Variable mit dem Namen serialVersionUID in der Klasse abgelegt. Ändern sich die Klassenattribute, ist es günstig, eine eigene SUID einzutragen, denn der Mechanismus zum Deserialisieren kann dann etwas gutmütiger mit den Daten umgehen. Beim Einlesen gibt es nämlich Informationen, die nicht hinderlich sind. Wir sprechen in diesem Zusammenhang auch von stream-kompatibel. Dazu gehören zwei Bereiche: neue Felder und fehlende Felder.
- Neue Felder. Befinden sich in der neuen Klasse Attribute, die im Datenstrom nicht benannt sind, werden diese Attribute mit 0 oder null initialisiert.
- Fehlende Felder. Befinden sich im Datenstrom Attribute, die in der neuen Klasse nicht vorkommen, werden sie einfach ignoriert.
Die SUID lässt sich mit einem kleinen Dienstprogramm serialver berechnen. Auf diese Weise erreichen wir eine Stream-kompatible Serialisierung.
$ serialver SerMe SerMe: static final long serialVersionUID = 9027745268614067035L; |
Diese letzte Zeile können wir in unsere Klasse SerMe kopieren. Nehmen wir jetzt noch eine Fließkommazahl d hinzu, dann wird die InvalidClassException nicht mehr auftreten, da mit der Hinzunahme eines Attributs die Stream-Kompatibilität gewährleistet ist.
class SerMe implements Serializable { static final long serialVersionUID = 9027745268614067035L; int i; double d; }
13.12.8 Wie die ArrayList serialisiert 

Am Beispiel einer java.util.ArrayList lässt sich sehr schön beobachten, wie sich die Funktionen writeObject() und readObject() nutzen lassen. Eine ArrayList beinhaltet eine Reihe von Elementen. Zur Speicherung nutzt die Datenstruktur ein internes Feld. Das Feld kann größer als die Anzahl der Elemente sein, damit bei jedem add() das Feld nicht immer neu vergrößert werden muss. Nehmen wir an, die ArrayList würde eine Standardserialisierung nutzen. Was passiert nun? Es könnte das Problem entstehen, dass bei nur einem Objektverweis in der Liste und einer internen Feldgröße von 1000 Elementen leider 999 null-Verweise gespeichert würden. Das wäre aber Verschwendung! Besser ist es, eine angepasste Serialisierung zu verwenden.
Listing 13.48 java.util.ArrayList.java, Ausschnitt
private void writeObject( ObjectOutputStream s ) throws IOException { int expectedModCount = modCount; s.defaultWriteObject(); s.writeInt( elementData.length ); for ( int i = 0; i < size; i++ ) s.writeObject( elementData[ i ] ); if ( modCount != expectedModCount ) throw new ConcurrentModificationException(); } private void readObject( ObjectInputStream s ) throws IOException, ClassNotFoundException { s.defaultReadObject(); int arrayLength = s.readInt(); Object[] a = elementData = (E[]) new Object[ arrayLength ]; for ( int i = 0; i < size; i++ ) a[ i ] = s.readObject(); }
13.12.9 Probleme mit der Serialisierung 

Der klassische Weg von einem Objekt zu einer persistenten Speicherung führt über den Serialisierungsmechanismus von Java über die Klassen ObjectOutputStream und ObjectInputStream. Die Serialisierung in Binärdaten ist aber nicht ohne Nachteile. Schwierig ist beispielsweise die Weiterverarbeitung von Nicht-Java-Programmen oder die nachträgliche Änderung ohne Einlesen und Wiederaufbauen der Objektverbunde. Wünschenswert ist daher eine Textrepräsentation. Diese hat nicht die oben genannten Nachteile.
Ein weiteres Problem ist die Skalierbarkeit. Die Standard-Serialisierung arbeitet nach dem Prinzip: Alles, was vom Basisknoten aus erreichbar ist, gelangt serialisiert in den Datenstrom. Ist der Objektgraph sehr groß, steigt die Zeit für die Serialisierung und das Datenvolumen an. Verglichen mit anderen Persistenz-Konzepten, ist es nicht möglich, nur die Änderungen zu schreiben. Wenn sich zum Beispiel in einer sehr großen Adressliste die Hausnummer einer Person ändert, muss die gesamte Adressliste neu geschrieben werden – das nagt an der Performance.
Auch parallele Änderungen können ein Problem sein, da die Serialisierung über kein transaktionales Konzept verfügt. Während der Serialisierung sind die Objekte und Datenstrukturen nicht gesperrt, und ein anderer Thread kann derweil alles Mögliche modifizieren. Der Entwickler muss sich selbst auferlegen, während des Schreibens keine Änderungen vorzunehmen, damit der Schreibzugriff isoliert ist. Auch wenn es während des Schreibens ein Problem (etwa eine Ausnahme) gibt, kommt ein halbfertiger Datenstrom beim Client an.
13.12.10 Serialisieren in XML-Dateien 

Eine Abbildung in XML hat viele Vorteile, unter anderem die, dass auch andere Programmiersprachen leicht an die Daten kommen. Mittlerweile finden sich viele Bibliotheken, die Objektgraphen in XML abbilden.
- XStream (http://xstream.codehaus.org/)
- Commons Betwixt (http://jakarta.apache.org/commons/betwixt/)
- Java Architecture for XML Binding: JAXB (http://java.sun.com/xml/jaxb/)
- XMLBeans (http://xmlbeans.apache.org/)
- Castor (http://www.castor.org/)
- Simple (http://simple.sourceforge.net/)
13.12.11 JavaBeans Persistence 

Um mit der JavaBeans Persistence Objekte in XML zu schreiben und von dort zu laden, werden statt der Klassen ObjectOutputStream und ObjectInputStream die Klassen XMLEncoder und XMLDecoder eingesetzt.
Die folgende Klasse ist unserem Programm SerializeAndDeserialize nachempfunden. Ersetzen müssen wir lediglich die Object-Streams. Die Klassen XMLEncoder und XMLDecoder liegen auch nicht in java.io, sondern unter dem Paket java.beans. Interessanterweise muss die Ausnahme ClassNotFoundException nicht mehr aufgefangen werden.
Listing 13.49 com/tutego/insel/io/ser/SerializeAndDeserializeXML.java
package com.tutego.insel.io.ser; import java.io.*; import java.util.Date; import java.beans.*; public class SerializeAndDeserializeXML { public static void main( String[] args ) { String filename = "datum.ser.xml"; // Serialize XMLEncoder enc = null; try { enc = new XMLEncoder( new FileOutputStream(filename) ); enc.writeObject( "Today" ); enc.writeObject( new Date() ); } catch ( IOException e ) { e.printStackTrace(); } finally { if ( enc != null ) enc.close(); } // Deserialize() XMLDecoder dec = null; try { dec = new XMLDecoder( new FileInputStream(filename) ); String string = (String) dec.readObject(); Date date = (Date) dec.readObject(); System.out.println( string ); System.out.println( date ); } catch ( IOException e ) { e.printStackTrace(); } finally { if ( dnc != null ) dec.close(); } } }
Und so sehen wir nach dem Ablauf des Programms in der Datei datum.ser.xml Folgendes:
<?xml version="1.0" encoding="UTF-8"?> <java version="1.6.0" class="java.beans.XMLDecoder"> <string>Today</string> <object class="java.util.Date"> <long>1155907123953</long> </object> </java>
Bei eigenen Objekten gilt es immer zu bedenken, dass der XML-Serialisierer von Sun nur JavaBeans schreibt. Eigene Klassen müssen daher immer public sein, einen Standardkonstruktor besitzen und ihre serialisierbaren Eigenschaften über getXXX()/setXXX()-Methoden bereitstellen; sie müssen jedoch die Markierungsschnittstelle Serializable nicht implementieren.
PersistenceDelegate
Dem XMLEncoder lässt sich über setPersistenceDelegate(Class, PersistenceDelegate) für einen speziellen Klassentyp ein java.beans.PersistenceDelegate mitgeben, der den Zustand eines Objekts speichert. Das ist immer dann praktisch, wenn der Standard-Mechanismus Eigenschaften nicht mitnimmt oder Klassen so nicht abbilden kann, weil sie zum Beispiel keinen Standard-Konstruktor deklarieren. Für eigene Delegates ist die Unterklasse DefaultPersistenceDelegate recht praktisch. Sie ist auch hilfreich, um bestimmte Typen auch erst gar nicht zu schreiben:
XMLEncoder e = new java.beans.XMLEncoder( out ); e.setPersistenceDelegate( NonSer.class, new DefaultPersistenceDelegate() );
13.12.12 XStream 

XStream [Wer im Internet nach X-Stream sucht, findet auch pinkfarbene Inhalte. ] ist eine quelloffene Software unter der BSD-Lizenz, mit der sich serialisierbare Objekte in XML umwandeln lassen. Damit ähnelt XStream eher der Standard-Serialisierung als der JavaBeans Persistence. Nachdem die unter http://xstream.codehaus.org/download.html geladene Bibliothek xstream-x.y.jar sowie der schnelle XML-Parser xpp3_min-x.y.jar auf der gleichen Seite eingebunden sind, ist ein Beispielprogramm schnell formuliert:
Point p = new Point( 120, 32 ); XStream xstream = new XStream(); String xml = xstream.toXML( p ); System.out.println( xml ); Point q = (Point) xstream.fromXML( xml );
Alle Ausnahmen von XStream sind Unterklassen von RuntimeException und müssen daher nicht explizit aufgefangen werden. Der String hinter xml enthält:
<java.awt.Point> <x>120</x> <y>32</y> </java.awt.Point>
Ein XML-Prolog fehlt.