JEXP                       JEXP

Effektive Objektserialisierung in Java

Die meiste unserer Zeit verbringen wir in den Objektgraphen unserer Domänenmodelle. Leider müssen wir manchmal den warmen, heimigen Herd zurücklassen und mit der Welt da draussen interagieren. Immer wenn wir den Schoß der JVM verlassen, ergibt sich die Notwendigkeit unsere reichhaltigen und vernetzten Objektgraphen effektiv in ein Format zu konvertieren, das bestimmten Anforderungen gerecht werden muss:

Für die Objektserialisierung und -deserialisierung gibt es eine Menge Möglichkeiten in Java und der JVM. Dieser Artikel soll einige davon beleuchten und feststellen, wie sie in Bezug auf die genannten Eigenschaften abschneiden.

Eine der Unterscheidungskriterien für die Serialisierungsbibliotheken ist deren Herkunft. Von in die JVM integrierten Lösungen wie die JVM-Objektserialisierung, über Java-basierte Bibliotheken wie Gson, XStream und Kryo, bis zu Ansätzen wie Thrift und Corba, die aus Netzwerk bzw. Remote-Procedure-Call-(RPC)-Protokollen stammen ist alles vertreten.

Bei der Entwicklung von Serialisierungstools muss, wie immer in der Softwareentwicklung eine Balance zwischen verschiedenen Faktoren gefunden werden. So stehen sich Ressourceneffizienz und effiziente Komprimierung oder Typinformationsspeicherung und portables Format gegenüber.

Als Beispiel für den Test der verschiedenen Konvertierungsansätze soll ein generiertes Datenset dienen, dass eine Vielzahl verschieden getypter Feldern mit konfigurierbarer Größe enthält. Wir wollen messen, wie schnell die Serialisierung efolgt, wieviel Speicher dabei benötigt wird und wie effizient sie abläuft. Desweiteren wollen wir auch feststellen wie die anderen, nichtfunktionalen Aspekte gehandhabt werden.

Für diesen Test werden definierte Mengen von Objektinstanzen serialisiert und benötigte Zeit und Speicherverbrauch gemessen.

Um effizient, ohne weiteren Speicherverbrauch die Größe (in Bytes) der serialisierten Daten zu messen, wird ein CountingOutputStream benutzt, der die geschriebenen Daten verwirft und nur die aggregierten Bytes zählt.

public class CountingOutputStream extends OutputStream { private int count;

public void write(int b) throws IOException {
    count++;
}
public void write(byte[] b) throws IOException {
    count += b.length;
}
public void write(byte[] b, int off, int len) throws IOException {
    count += len;
}
    public int getCount() {
        return count;
    }
}

Für die jeweilige Serialisierung, erstellen wir ein einfaches Interface Serializer mit einem Lebenszyklus (open, close) und einer serialize Methode. Damit können wir dann die jeweilige Bibliothek kapseln und mit einem parametrisierten Test ausführen.

Serializer Interface:

public interface Serializer {

    void open(OutputStream os) throws Exception;
    void serialize(Object object) throws Exception;
    void close() throws Exception;
}

Implementierung für Kryo:

public class KryoSerializer implements Serializer {

private final Kryo kryo = new Kryo();
private Output output;
public void open(OutputStream os) {
    output = new FastOutput(os);
}
public void serialize(Object object) {
    kryo.writeObject(output, object);
}
    @Override
    public void close() {
        output.close();
    }
}

Test:

@RunWith(Parameterized.class) public class SerializerTest { private final static int RUNS = 1000; public static final int SIZE = 1000; private final CountingOutputStream buffer = new CountingOutputStream(); private Serializer serializer;

@Parameterized.Parameters
public static Iterable<Object[]> parameters() {
    return Arrays.<Object[]>asList(
            new Object[]{new NullSerializer()},
            new Object[]{new ByteBufferMappingSerializer()},
            new Object[]{new JavaSerializer()},
            new Object[]{new KryoSerializer()},
            new Object[]{new JacksonSerializer()},
            new Object[]{new JacksonSmileSerializer()},
            new Object[]{new GsonSerializer()},
            new Object[]{new XStreamSerializer()}
    );
}
public SerializerTest(Serializer serializer) {
    this.serializer = serializer;
}
@Test
public void testWriteJavaSerialization() throws Exception {
    serializeObjects(SIZE, serializer);
}
...
private long serializeObjects(int size, Serializer serializer) throws Exception {
    serializer.open(buffer);
    long time = System.currentTimeMillis();
    for (int i=0;i< RUNS;i++) {
        Root objects = Root.create(size);
        serializer.serialize(objects);
    }
    serializer.close();
    time = System.currentTimeMillis() - time;
    String name = serializer.getClass().getSimpleName();
    System.out.println(name +" writing "+ RUNS +" objects of size "+ size + " to a stream of size " + buffer.getCount() +" took " + time+" ms.");
    return time;
}

}

Zum Vergleich sind angetreten:

Hier zuerst einmal eine Übersicht der Ergebnisse und eine kurze Bewertung. Eine detailliertere Diskussion der verschiedenen Ansätze folgt im Anschluss.

Mit 1000 Durchläufen und einer Objekt-Größe von 1000 Einheiten.

name result-size time (ms) NullSerializer 0 40 ByteBufferMappingSerializer 21032000 72 JacksonSmileSerializer 24104000 361 KryoSerializer 24106000 368 JavaSerializer 27067471 621 JacksonSerializer 45127000 852 GsonSerializer 45127000 3574 XStreamSerializer 187326032 5319

Dabei wird der NullSerializer knapp von handgeschriebenen Serialisierer gefolgt, der auch relativ sparsam mit dem Speicher umgeht. An dritter Stelle steht Kryo der nur halb so lange benötigt wie die Java-Objektserialisierung.

Weit abgeschlagen benötigen die portablen, nicht binären Serialisierer (Gson, Jackson, XStream) mindestens zehnmal so lange und doppelt bzw. zehnmal soviel Platz für die Format-Auszeichnungen (JSON-Syntax,XML-Elemente).

Während der Suche nach weitergehenden Informationen, bin ich auf das Projekt von Eishay Smith [Eishay] gestossen, der sich intensiv mit dem Vergleich von verschiedenen Serialisierern auf der JVM auseinandergesetzt hat und ähnliche Ergebnisse wie unsere Tests liefert. Die Tests, sowie Ergebnisse sind auf GitHub verfügbar.

Im folgenden sollen einige der Ansätze näher betrachtet werden:

Betrachtung Serialisierer

Java-Objektserialisierung

Die integrierte Java-Serialisierung funktioniert nur mit Objekten, die java.io.Serializable implementieren und deren Feld-typen ebenso transitiv nur aus primitiven Werten oder deklarierten Typen bestehen, die Serializable implementieren.

Felder werden direkt auf JVM-Level gelesen und geschrieben. Alternativ kann man mittels writeObject und readObject die Serialisierung selbst steuern, dass machen viele JDK-Klassen, um eine effizientere Serialisierung der Daten und nicht de Struktur zu erlauben. Um Objekte nach bzw. vor dem (De-)Serialisieren zu ersetzen gibt es writeReplace und readResolve. Das wird zum Beispiel genutzt, um sicherzustellen, dass Singletons (wie Enums) nur eine Instanz in der ganzen JVM haben.

Die Deserialisierung nutzt einen Mechanismus zur Objekterzeugung, der nicht über den Konstruktor der Klasse funktioniert, sondern die Instanz direkt erzeugt. Damit wird Initialisierungscode im Constructor nicht ausgeführt.

Die Java-Serialisierung serialisiert jede Objektinstanz nur einmal. Bei weiteren Auftreten des Objektes wird nur eine Referenz gespeichert. Das ist für Objektgraphen mit Rekursion sinnvoll, da damit auch Zyklen behandelt werden. Leider führt es bei großen Objekt(graphen) zu massiver Speichernutzung da alle geschriebenen Objekte in eine Map gehalten werden müssen. Das ist auch kritisch, wenn Objekte dadurch vom Aufräumen durch den Garbage Collector verschont bleiben.

Joshua Bloch diskutiert in Effective Java [Bloch] und Heinz Kabutz [JavaSpecialists] in seinem Newsletter viele Aspekte der Java-Serialisierung.

Kryo

Kryo ist eine Serialisierungsbiliothek für Objektgraphen die mit absolutem Fokus auf Geschwindigkeit und Effizient entwickelt wurde. Die API für den Anwender ist sehr einfach gehalten. Ziele für die Serialisierung können Dateien, Datenbanken, Netzwerk oder Puffer im Speicher sein.

Ausserdem kann man mit Kryo auch Objekte duplizieren, sowohl tiefe als auch flache Kopien werden mittels direkter Duplikation von Objekt zu Objekt durchgeführt, ohne zuerst eine Serialisierung / Deserialisierung durchzuführen.

Die Output/Input-Klassen von Kryo puffern automatisch in einen Byte-Puffer (z.B. Byte-Array). Falls nötig wird dieser dann in den darunterliegenden Stream geschrieben.

Seit neuestem bietet Kryo IO-Implementierungen an, die auf der Java "Unsafe"-SPI beruhen, damit kann direkt mit Heap und Off-Heap-Buffer sowie Dateien, die in den Speicher gemappt wurden gearbeitet werden.

Die konkrete Umsetzung der Serialisierung in Kryo ist den individuellen "Serializer"n vorbehalten, sie bestimmen, wie ein bestimmter Objekttyp effizient gelesen und geschrieben wird. Kryo bietet dann das Framework drumherum, dass sich um den Aufruf der Serializer und I/O kümmert sowie die API nach aussen bereitstellt.

Wie nicht anders zu erwarten sieht der Serializer so aus:

public abstract class Serializer<T> { public abstract void write (Kryo kryo, Output output, T object);

public abstract T read (Kryo kryo, Input input, Class<T> type);
	....
}

Für zirkuläre Abhängigkeiten zu darüberliegenden Objekten, muss man sich ggf. gecachte Kopien des schon geladenen Objektes mit kryo.reference(Object) holen.

MessagePack

MessagePack wurde schon in JavaSpektrum 2/13 (Effiziente Kommunikation) vorgestellt. Es stellt ein kompaktes binäres Format für typische Objekthierarchien zur Verfügung, die sonst z.b. nach JSON gemappt würden. Dabei wird besonderer Wert auf die kompakte Repräsentation häufig vorkommender Werte gelegt, wie kleine Zahlen, boolesche Werte, leere Strings, Collections oder Maps.

Jackson

Jackson ist der De-Facto Standard für die Serialisierung nach JSON, es ist mächtig und vielseitig und flexibel anpassbar aber trotzdem schnell. Jackson unterstützt JSON-Streaming und darauf aufbauend Java-Objektserialisierung sowie ein JSON-Tree (AST) Modell.

Smile [JacksonSmile] ist ein Binärformat, dass trotzdem noch sehr nah an der JSON-Struktur bleibt, aber deutliche Vorteile in der Serialisierungsgeschwindingkeit und Kompaktheit des Ergebnisses liefert. Dem Jackson ObjectMapper kann einfach als Ersatz für die JSONFactory die SmileFactory übergeben werden und dann wird dieses Format genutzt. Ähnlich wie bei MessagePack werden für häufig vorkommende "JSON"-Fragmente platzsparende Encodings benutzt.

Gson

Gson ist eine beliebte JSON-Serialisierungs-Bibliothek von Google. Sie unterstützt die Serialisierung beliebig komplexer Java-Objekte, auch mit tiefen Vererbungshierarchien und Nutzung von Generics. Eigene Anpassungen der Serialisierung sind möglich. Für die Serialisierung werden die Felder der Objekte direkt gelesen und keine Getter oder Setter. Gson ist intern sehr einfach aufgebaut.

GSon benutzt die Metainformationen des Zieltyps (auch Hierarchien und Referenzen) beim Deserialisieren. Damit werden ungenutzte JSON-Teilbäume gar nicht erst deserialisiert.

Kompression

Wenn die entstehende Datenmenge eine größere Rolle spielt, können verschiedene Methoden zur Kompression genutzt werden. Diese erreicht in den meisten Fällen eine Komprimierung der Daten um einen Faktor von 2-100 je nach Informationsdichte. Das ist auf bandbreitenbegrenzten Verbindungen wichtig, und verringert auf diesen auch die Gesamtübertragungszeit, da weniger Daten übertragen werden müssen. Die Latenz kann durch den initialen Kompressionsaufwand etwas höher werden.

Besonders für Übetragungen auf Hochgeschwindigkeitsverbindungen (bis zum lokalen Prozessorbus) ist es immer dann interessant, wenn die Übertragungszeit deutlich größer ist als der Aufwand für die De-/Kompression.

GZipOutputStream

Die einfachste Lösung ist, den Ausgabestrom in einen der Filter aus dem JDK zu kapseln, dem GZipOutputStream. Dieser komprimiert die Informationen transparent beim Schreiben (und kann auch für http-Anfrageergebnisse eingesetzt werden).

Die Ergebnisse damit sind in der folgenden Tabelle dargestellt, es wird deutlich, dass die Zeit je nach Ausgangsdatenstruktur und Menge zwischen 1,5 bis 5x anwächst, aber die Datenkompression ist ziemlich deutlich, sie macht 2-3 Größenordnungen aus.

Komprimiert Name Ergebnis Zeit (ms)

ja ByteBufferMappingSerializer 42141 195 ByteBufferMappingSerializer 21032000 34

ja JavaSerializer 67652 1896 JavaSerializer 27067471 436

ja KryoSerializer 58838 460 KryoSerializer 24106000 175

ja JacksonSmileSerializer 58632 426 JacksonSmileSerializer 24104000 114

ja JacksonSerializer 201826 1059 JacksonSerializer 45127000 531

ja GsonSerializer 201826 3220 GsonSerializer 45127000 2378

ja XStreamSerializer 904265 6428 XStreamSerializer 187326032 4379

Kompression Numerischer Werte

Delta-Kompression

Ein paar interessante Anmerkungen / Überlegungen zur Kompression, besonders von aufeinanderfolgenden Zahlen und numerischen Id’s. Oft sind Felder mit numerischen Informationen aus einem ähnlichen Zahlenraum, oder aufeinander bezogen.

Daher kann man die vorhandenen Informationen transponieren, indem man Differenzen bildet. Entweder immer die Differenz zu einem fixen Element der Liste (Anfang, Mitte, Ende, Durchschnitt), oder zum direkten Vorgänger. Damit können aus long-Werten mit einem Platzbedarf von 8 Byte (64 bit) sehr kleine Zahlen werden, die potentiell in wenige Bytes passen.

Falls das Inkrement unserer Ausgangswerte gleich ist (z.b. meist Abstand 1) dann ergibt sich aus der Differenzbildung zum Vorgänger eine Anzahl von Blöcken, in denen sich dieselbe Zahl immer wiederholt. Das erlaubt uns, diese mit Lauflängenkompression zu verkürzen. So wird aus einem Block mit 250 mal der Ziffer 1, ein Konstrukt <Marker> <Zähler> <Wert>, z.b. FF FA 01 (255 250 001 dezimal). Beim Zähler kann man einen Maximalwert festlegen, der entweder 1 bis 4 Bytes umfassen kann, aber Platz für den Marker lassen muss.

Somit können große Mengen aufeinanderfolgender Zahlen mit ähnlichem Abstand effizient kodiert werden.

VLQ-Kompression

Für die Kodierung von Werten mit großem Wertebereich aber genügend kleinen Werten, gibt es aus einen interessanten Komprimierungsansatz aus dem Midi-Format [VLQ-Compression]. Dabei wird von jedem Wert nur die effektive Zahl mit der minimalen Anzahl Bytes kodiert.

Die Zahl wird in 7-bit Blöcke zerlegt, das höchstwertige 8. Bit jedes Bytes (MSB) zeigt an, ob ein weiterer Block folgt, z.B. wenn es gesetzt ist, folgt noch ein weiterer Block. Hier ein Beipiel: Wir haben einen Long-Wert, der eigenlich 8 Bytes benötigt, aber nur die Zahl 4711 (0x1267) enthält.

Wenn man diese Zahl in 7-bit Blöcke (Base-128, also Division durch 128) aufteilt, erhält man zwei Bytes: höherwertig 36 und niederwertig 103, jetzt muss beim allen Bytes bis auf das letzte nur noch das erste Bit gesetzt werden und wir haben unsere Kodierung: 36+128 103 (A4 67).

Original - 8 Bytes 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 0010 0110 0111 = 0x12 67

Split: _01 00100 _110 0111 = 0x24 67

MSB gesetzt _1_01 00100 _0_110 0111 = 0xA4 67

http://upload.wikimedia.org/wikipedia/en/thumb/c/c6/Uintvar_coding.svg/600px-Uintvar_coding.svg.png Abbildung der Kodierung von 106903 mittels VLQ (Quelle Wikipedia)

Mit so einer Komprimierung kann man z.B. den Platzbedarf beim Kodieren von einer Million Long-Werte die normalerweise 8 Millionen Bytes belegen würden, bei einer Verteilung von: 10% - Longs (8 Bytes), 50% - Integers (4 Bytes) und 40% - Bytes (1 Byte) auf ca. 3.5 Millionen Bytes reduzieren.

Einige der genannten Komprimierungen sind schnell genug, dass sie auch zur Laufzeit eingesetzt werden können. So nutzt das vor einer Weile vorgestellte MapDB die Delta-Kompression. Und auch in Neo4j setzen wir ähnliche Mechanismen ein.

Ausblick

Kurz vor Redaktionsschluss für diesen Artikel hat mir Martin Thompson (u.a. LMAX-Disruptor) verraten, dass er bald eine sehr effiziente Java-Serialisierungs-Bibliothek vorstellen wird, die eine Performance aufweist, die mit C-Implementierungen vergleichbar ist. Er wurde von der FIX High Performance Working Group beauftragt, eine Referenzimplementierung für die SBE (Simple Binary Encoding) Spezifikation zu entwickeln.

Das ganze basiert auf einem Compiler der dann Codebestandteile für die Bibliotheken für Java und C++ generiert. Er hat einen Geschwindigkeitsvorteil von 20-50x gegenüber Protocol-Buffers angekündigt.

Es bleibt also spannend.

Referenzen:

Kryo https://github.com/EsotericSoftware/kryo Gson: https://code.google.com/p/google-gson/ Gson-Design-Document: [EisHay] JVM-Serializers Wiki: https://github.com/eishay/jvm-serializers/wiki [VLQ-Compression]: http://en.wikipedia.org/wiki/Variable-length_quantity [SimpleBinaryEncoding] http://www.fixtradingcommunity.org/pg/discussions/topicpost/168327/ [JavaSpecialists] http://www.javaspecialists.eu/archive/archive.jsp Heinz Kabutz hat in seinem Newsletter und Training diverse Themen mit Bezug auf Objektserialisierung behandelt [SerializationComparisonHazelcast] http://blog.hazelcast.com/blog/2013/06/13/comparing-hazelcast-3-serialization-methods-with-kryo-serialization/ [Bloch] Effective Java 2nd Edition [JacksonSmile] http://wiki.fasterxml.com/JacksonForSmile

------------ snip -----------------

BSON http://bsonspec.org/ ProtoBuf: http://www.javacodegeeks.com/2012/06/google-protocol-buffers-in-java.html https://developers.google.com/protocol-buffers/docs/javatutorial https://code.google.com/p/protobuf-java-format/ - Provide serialization and de-serialization of different formats based on Google’s protobuf Message. Enables overriding the default (byte array) output to text based formats such as XML, JSON and HTML.

Last updated 2013-11-22 16:24:06 CET
Impressum - Twitter - GitHub - StackOverflow - LinkedIn