RU | EN | DE

Was ist das und wofür?

Serialisierung – die Umwandlung eines Objekts in einen Byte-Stream (zum Schreiben in eine Datei/Übertragung über das Netzwerk/Caching). Deserialisierung – die umgekehrte Wiederherstellung eines Objekts aus Bytes.

Wo findet man es:

  • verteilte Systeme (RPC, REST, Kafka), Sitzungen (HttpSession), Caches (Redis, Hazelcast), Warteschlangen (JMS), Snapshot des Zustands.

Basisserialisierung: Serializable

Minimales Beispiel

import java.io.*;
public class User implements Serializable {
  private static final long serialVersionUID = 1L; // fixiert die Klassengeneration
  
  private String username;
  private transient String password; // nicht serialisiert
 
  public User(String username, String password) {
  this.username = username;
  this.password = password;
  }
}
// Schreiben
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.bin"))) {
  out.writeObject(new User("alice", "p@ss"));
}
// Lesen
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.bin"))) {
  User u = (User) in.readObject();
}

Wichtige Punkte

  • Der Marker-Interface Serializable (ohne Methoden) erlaubt den Standardmechanismus.
  • serialVersionUID – die Klassengeneration. Wenn nicht angegeben, generiert die JVM automatisch → bei geringfügigen Änderungen der Struktur können InvalidClassException auftreten.
    Manuelle Fixierung von serialVersionUID stabilisiert die Kompatibilität.
  • transient kennzeichnet Felder, die nicht in den Stream aufgenommen werden sollten (Passwörter, Caches, Abhängigkeiten, lazy-Proxys usw.).

Anpassung des Prozesses

1. writeObject/readObject

Ermöglicht die Kontrolle über Serialisierung/Deserialisierung.

private void writeObject(ObjectOutputStream out) throws IOException {
  out.defaultWriteObject(); // Standardlogik
  // zusätzlich verschlüsseln und schreiben
  String enc = password == null ? null : encrypt(password);
  out.writeObject(enc);
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
  in.defaultReadObject(); // Standardlogik
  String enc = (String) in.readObject();
  this.password = enc == null ? null : decrypt(enc);
}

2. readResolve / writeReplace

  • readResolve() – gibt das ersetzenende Objekt nach der Deserialisierung zurück (nützlich für Singleton/Caches).
  • writeReplace() – ersetzt das Objekt vor der Serialisierung (Pattern Serialization Proxy).
// Singleton
public class AppConfig implements Serializable {
  private static final AppConfig INSTANCE = new AppConfig();
  private AppConfig() {}
  public static AppConfig getInstance() { return INSTANCE; }
  private Object readResolve() { return INSTANCE; } // behalten der Singleton-Garantie
}

3. serialPersistentFields

Definiert das „logische“ Set serialisierbarer Felder, unabhängig von den tatsächlichen.

private static final ObjectStreamField[] serialPersistentFields = {
  new ObjectStreamField("username", String.class)
};

Volle Kontrolle: Externalizable

Wenn eine vollständige manuelle Serialisierung benötigt wird:

import java.io.*;
 
public class Point implements Externalizable {
  private int x, y;
  public Point() {} // obligatorischer öffentlicher Konstruktor ohne Argumente
 
  public Point(int x, int y) { this.x = x; this.y = y; }
 
  @Override public void writeExternal(ObjectOutput out) throws IOException {
  out.writeInt(x); out.writeInt(y);
  }
  @Override public void readExternal(ObjectInput in) throws IOException {
  this.x = in.readInt(); this.y = in.readInt();
  }
}

Vorteile: Kontrolle über Format, Geschwindigkeit, Kompatibilität. Nachteile: mehr Code, höheres Risiko von Fehlern.

Sicherheit: Filterung und Beschränkungen (JEP 290)

Problem: Java-Serialisierung kann zu Sicherheitslücken (Gadget-Chains, RCE) bei der Deserialisierung unvertrauenswürdiger Daten führen.

Lösungen:

  • ObjectInputFilter (JDK 9+): weiße/schwarze Listen von Klassen, Grenzwert des Graphen.
import java.io.*;
import java.util.Objects;
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
  "maxdepth=50;maxbytes=1048576;!*;java.base/*;com.myapp.*"
);
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("in.bin"))) {
  in.setObjectInputFilter(filter);
  Object obj = in.readObject();
}
  • Serialisieren Sie niemals Daten aus externen Quellen mit der Standard-Java-Serialisierung.
  • Für Netzprotokolle – alternative Formate (siehe unten).

Kompatibilität von Versionen (Versionierung)

Was bricht die Kompatibilität:

  • Entfernung/Änderung des Feldtyps;
  • Änderung der Hierarchie (Eltern);
  • Umbenennung der Klasse/des Pakets;
  • Kein geeigneter Konstruktor (für Externalizable).

Strategien:

  • serialVersionUID fixieren;
  • für neue Felder – Standardwerte in readObject geben;
  • Serialization Proxy verwenden: serialisiert ein einfaches „DTO-Proxy“, nicht das komplexe Objekt selbst;
  • nach Möglichkeit – auf Protokolle mit expliziter Schema (Protobuf/Avro) umsteigen.

Performance und Fallstricke

  • Langsamer und lauter für GC als binäre spezialisierte Formate (Protobuf/Kryo).
  • Schlecht über Sprachen hinweg tragbar (Java-spezifisch).
  • Zyklische Objektgraphen werden unterstützt, können aber die Größe erhöhen.
  • JPA-Entitäten mit LAZY-Feldern: Proxy-Objekte von Bibliotheken werden oft nicht serialisiertNotSerializableException. Für DTOs – Mapping (MapStruct) statt direkter Serialisierung.
  • transient-Felder nach Deserialisierung werden null/0/false sein – vergessen Sie nicht, sie korrekt zu initialisieren.

Alternativen (empfohlen in der Produktion)

1. JSON (häufig Jackson)

Vorteile: lesbar, plattformübergreifend, einfach zu debuggen. Nachteile: Volumen, Geschwindigkeit geringer als bei binären Formaten.

// Jackson
import com.fasterxml.jackson.databind.ObjectMapper;
ObjectMapper om = new ObjectMapper();
String json = om.writeValueAsString(new User("alice", "p@ss")); // Passwort kann @JsonIgnore sein
User u = om.readValue(json, User.class);

Annotationen: @JsonProperty, @JsonIgnore, @JsonCreator, @JsonTypeInfo (Polymorphismus), @JsonInclude.

2. Protocol Buffers (Google Protobuf)

Vorteile: schnell, kompakt, strikte Schema mit Versionierung, ausgezeichnete plattformübergreifende Unterstützung. Nachteile: binär (nicht lesbar), erfordert Kompilierung .proto.

// user.proto
syntax = "proto3";
package demo;
message User {
  string username = 1;
  string email = 2;
}

Generieren wir Java-Klassen (protoc), dann:

UserProto.User u = UserProto.User.newBuilder()
  .setUsername("alice").setEmail("a@x.io").build();
byte[] bytes = u.toByteArray();
UserProto.User restored = UserProto.User.parseFrom(bytes);

3. Avro

Vorteile: dynamisches/evolutionäres Schema (oft in Kafka), Kompression, binäres Format, gut für Ereignisprotokollierung. Nachteile: etwas schwierigerer Start als JSON.

4. Kryo

Vorteile: sehr schnell, kompakt, kann beliebige Graphen serialisieren. Nachteile: empfindlich gegenüber Klasseneränderungen, erfordert Kontrolle über Registrierungen, eher in-JVM/Caches/Spark, als öffentliche API.

Best Practices (Zusammenfassung)

  1. Vermeiden Sie die Standard-Java-Serialisierung in öffentlichen Protokollen und bei der Arbeit mit unvertrauenswürdigen Daten.
  2. serialVersionUID fixieren.
  3. Als transient markieren oder DTOs ohne Geheimnisse verwenden.
  4. Für feine Logik – writeObject/readObject, readResolve/writeReplace, Serialization Proxy.
  5. Für Sicherheit – ObjectInputFilter (JEP 290), Grenzen für Tiefe/Bytes/Klassen.
  6. Für intersprachliche Kommunikation und stabile Versionierung – Protobuf/Avro; für menschenlesbare Daten – JSON/Jackson.
  7. Bei JPA/LAZY – serialisieren Sie DTOs, nicht Entitäten.
  8. In verteilten Caches/Warteschlangen – verwenden Sie unterstützte Serialisierer (Kafka Serde, Redis Codecs, Spring MessageConverter).