☕ Java

Serialization

Java serialization is the mechanism for converting an object graph into a sequence of bytes that can be stored to a file, transmitted over a network, or persisted in a database. An object is serializable if its class implements the java.io.Serializable marker interface, which has no methods — it serves only as a type token that grants the JVM permission to serialize instances of the class. The serialization process is performed by ObjectOutputStream.writeObject(), which traverses the object graph recursively, encoding each object's class descriptor and field values into a binary stream in a platform-independent format. The format captures the full object graph — if two references point to the same object, it is written once and both references are restored correctly on deserialization. This entry covers the Serializable marker interface and what it commits to, the binary format structure and version negotiation via serialVersionUID, how the serialization engine traverses object graphs and handles cycles and shared references, the customization hooks writeObject and readObject, the serialization proxy pattern for robust versioning, security implications of deserialization, and when to use Java serialization versus alternatives.

The Serializable Contract and serialVersionUID

Implementing Serializable is a declaration of intent: the class commits to a persistent external representation of its state. This commitment has implications that extend beyond the class itself. The serialized form becomes part of the class's public API — changing field names, types, or the class hierarchy can break deserialization of previously-serialized data. The Java ecosystem treats this contract seriously: changing a serializable class without considering its serialized form is a binary compatibility break. The serialVersionUID is a 64-bit long that acts as a version fingerprint for the class. It appears in the serialized stream alongside the class name. On deserialization, the JVM compares the serialVersionUID in the stream with the serialVersionUID of the class in the JVM. If they differ, InvalidClassException is thrown, indicating an incompatible class version. If serialVersionUID is not explicitly declared, the JVM computes one automatically from the class structure — method signatures, field names and types, implemented interfaces, and more. Any change to the class structure changes the computed serialVersionUID, making all previously-serialized instances unreadable. Declaring serialVersionUID explicitly gives the developer control: instances serialized with the old version can still be deserialized with the new version (with whatever field additions or removals have been made), avoiding the automatic incompatibility. The convention for serialVersionUID declaration is: private static final long serialVersionUID = 1L; — start at 1L and increment when making an incompatible change that requires rejecting old serialized data. For compatible changes (adding fields, changing field modifiers), keep the same value. The IDE tools (javac -Xlint:serial, IntelliJ, Eclipse) warn about missing serialVersionUID declarations. The Serializable marker interface triggers the JVM's default serialization: all non-transient, non-static fields of the class and its superclasses (up to the first non-Serializable superclass) are written. Fields of the first non-Serializable superclass are not serialized — they are initialized via that class's no-argument constructor on deserialization. If the first non-Serializable superclass has no accessible no-argument constructor, deserialization throws InvalidClassException.
Java
// ── Basic Serializable class ──────────────────────────────────────────
import java.io.*;

public class Person implements Serializable {
    // ALWAYS declare serialVersionUID explicitly:
    private static final long serialVersionUID = 1L;

    private String name;
    private int    age;
    private String email;

    // All non-static, non-transient fields are serialized by default
    public Person(String name, int age, String email) {
        this.name  = name;
        this.age   = age;
        this.email = email;
    }

    @Override public String toString() {
        return "Person{name=" + name + ", age=" + age + ", email=" + email + "}";
    }
}

// ── Serializing to a file ─────────────────────────────────────────────
Person alice = new Person("Alice", 30, "alice@example.com");
try (ObjectOutputStream oos = new ObjectOutputStream(
        new BufferedOutputStream(new FileOutputStream("person.ser")))) {
    oos.writeObject(alice);   // writes class descriptor + field values
    System.out.println("Serialized: " + alice);
}

// ── Serializing multiple objects to the same stream ───────────────────
List<Person> people = List.of(
    new Person("Alice", 30, "alice@example.com"),
    new Person("Bob",   25, "bob@example.com"),
    new Person("Carol", 35, "carol@example.com")
);

try (ObjectOutputStream oos = new ObjectOutputStream(
        new BufferedOutputStream(new FileOutputStream("people.ser")))) {
    for (Person p : people) {
        oos.writeObject(p);   // each object written sequentially
    }
}

// ── serialVersionUID: explicit control of version compatibility ────────
public class BankAccount implements Serializable {
    private static final long serialVersionUID = 1L;  // version 1

    private String accountNumber;
    private double balance;
    // Adding fields in version 2 with same serialVersionUID = 1L:
    // private String currency = "USD";  // OK: new field gets default on deserialization
    // Changing field type from double to BigDecimal: INCOMPATIBLE — increment to 2L
}

// ── Automatic serialVersionUID computation (risky): ───────────────────
public class NoVersionUID implements Serializable {
    // No explicit serialVersionUID — JVM computes from:
    // class name, interface names, field names+types, method signatures
    private String data;   // Adding any method changes the computed UID — breaks deserialization
}

// ── IDE warning: -Xlint:serial catches missing declarations ───────────
// javac -Xlint:serial MyClass.java
// warning: [serial] serializable class Person has no definition of serialVersionUID

Object Graph Traversal, Cycles, and writeObject/readObject

The serialization engine traverses the object graph starting from the root object passed to writeObject(). For each object, it writes a class descriptor (class name, serialVersionUID, field descriptors) followed by the field values. If a field is a reference to another object, that object is serialized recursively. If a field is null, a null reference token is written. If an object has already been serialized in this stream, a back-reference handle is written instead of re-serializing — this correctly handles shared references and circular references without infinite recursion. The shared-reference mechanism preserves object identity across serialization. If two fields reference the same String object, after deserialization they reference the same deserialized String object. If an object graph contains a cycle (object A references object B which references object A), it is serialized correctly: A is written first with a handle, B is written with a reference to A's handle — no infinite loop. The writeObject and readObject customization hooks allow a class to supplement or replace the default serialization. A class declares private void writeObject(ObjectOutputStream oos) throws IOException to customize writing. The method typically calls oos.defaultWriteObject() to write all non-transient fields, then writes additional data using the oos write methods. The corresponding private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException customizes reading, typically calling ois.defaultReadObject() first, then reading the additional data written by writeObject. writeObject/readObject are used for: serializing transient fields that can be re-derived (compress or encrypt data before writing), enforcing invariants after deserialization (normalize a deserialized date, validate constraints), and adding version-compatible extra data (write additional fields in a new version, handle their absence gracefully when reading old data). The ObjectStreamField[] serialPersistentFields declaration provides an alternative to transient for precisely controlling which fields are included in the default serialized form.
Java
// ── Object graph with shared references ──────────────────────────────
public class Department implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private List<Person> members = new ArrayList<>();

    public void addMember(Person p) { members.add(p); }
}

Person alice = new Person("Alice", 30, "alice@example.com");
Department dept = new Department("Engineering");
dept.addMember(alice);
dept.addMember(alice);   // same object referenced twice

try (ObjectOutputStream oos = new ObjectOutputStream(
        new BufferedOutputStream(new FileOutputStream("dept.ser")))) {
    oos.writeObject(dept);  // alice serialized ONCE — second reference is a handle
}

// After deserialization: both list entries reference the same Person object ✓

// ── Circular references are handled correctly ─────────────────────────
public class Node implements Serializable {
    private static final long serialVersionUID = 1L;
    String value;
    Node   next;
    Node   prev;  // bidirectional list — creates cycles
}

Node n1 = new Node(); n1.value = "A";
Node n2 = new Node(); n2.value = "B";
n1.next = n2; n2.prev = n1;   // cycle: n1 → n2 → n1

try (ObjectOutputStream oos = new ObjectOutputStream(new ByteArrayOutputStream())) {
    oos.writeObject(n1);   // no StackOverflowError — cycles handled via handle table
    System.out.println("Circular graph serialized successfully");
}

// ── writeObject / readObject customization ────────────────────────────
public class SecureRecord implements Serializable {
    private static final long serialVersionUID = 1L;

    private String username;
    private transient String password;  // transient: excluded from default serialization
    private transient String derivedKey; // computed field — not serialized

    public SecureRecord(String username, String password) {
        this.username   = username;
        this.password   = password;
        this.derivedKey = deriveKey(password);
    }

    // Custom serialization: encrypt password before writing
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();   // writes 'username' (non-transient fields)
        String encrypted = encrypt(password);
        oos.writeObject(encrypted); // write encrypted password as extra data
    }

    // Custom deserialization: decrypt password after reading
    private void readObject(ObjectInputStream ois)
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();    // reads 'username'
        String encrypted = (String) ois.readObject();
        this.password   = decrypt(encrypted);
        this.derivedKey = deriveKey(password);  // re-derive computed field
    }

    private String encrypt(String s) { return "ENC:" + s; }  // placeholder
    private String decrypt(String s) { return s.substring(4); }
    private String deriveKey(String pw) { return "KEY:" + pw.hashCode(); }
}

// ── serialPersistentFields: explicit field list ────────────────────────
public class LegacyClass implements Serializable {
    private static final long serialVersionUID = 1L;

    // Explicit serialized field list — only 'id' and 'name' are serialized:
    private static final ObjectStreamField[] serialPersistentFields = {
        new ObjectStreamField("id",   Integer.TYPE),
        new ObjectStreamField("name", String.class)
    };

    private int    id;
    private String name;
    private int    internalCache;   // excluded — acts like transient
}

Security, Serialization Proxy, and Alternatives

Java deserialization is a well-known attack vector. Deserializing data from an untrusted source can execute arbitrary code if the classpath contains vulnerable classes (gadget chains). The attack works by crafting a byte stream that, when deserialized, constructs objects whose methods (readObject, finalize, etc.) chain together to execute arbitrary code. Libraries like Apache Commons Collections, Spring Framework, and others have had deserialization gadget chains that allowed remote code execution. The attack requires no custom code on the server — only that the vulnerable library is on the classpath. Mitigations: never deserialize data from untrusted sources with the default ObjectInputStream. Use ObjectInputStream.setObjectInputFilter() (Java 9+) or the system-wide jdk.serialFilter property to whitelist acceptable class names. The filter receives each class name as it is encountered during deserialization and can accept, reject, or defer the decision. The simplest filter: accept only classes from a known list; reject everything else. An alternative: use a completely different serialization format (JSON, Protocol Buffers, Avro, MessagePack) for network communication and persistent storage, reserving Java serialization only for JVM-internal use. The serialization proxy pattern is the safest and most robust approach for classes that must be Serializable. Instead of serializing the actual object, a writeReplace() method returns a private static inner SerializationProxy object (a simple record-like class with the minimum state needed to reconstruct the original). The proxy implements Serializable and its readResolve() method reconstructs the original object from the proxy's state, going through the class's normal constructor. This ensures invariants are always enforced on deserialization (the constructor validates the data), makes the serialized form independent of internal representation, and prevents all known deserialization gadget attacks on the serializable class itself because no instance of the class is ever created directly by the deserialization machinery. Modern alternatives to Java serialization: JSON (Jackson, Gson) for human-readable, language-interoperable data; Protocol Buffers (Protobuf) for compact, schema-versioned binary data; Apache Avro for Hadoop-ecosystem data; MessagePack for compact binary with JSON semantics; CBOR (Concise Binary Object Representation) for binary JSON. These alternatives avoid the security risks of Java serialization and provide better schema evolution support.
Java
// ── Security: ObjectInputFilter to whitelist classes ──────────────────
import java.io.ObjectInputFilter;

// System-wide filter (JVM startup property):
// -Djdk.serialFilter=com.example.**;java.util.*;!*
// Accepts com.example and java.util classes; rejects everything else

// Programmatic filter on a specific stream:
try (ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(new FileInputStream("data.ser")))) {

    ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "com.example.Person;com.example.Department;" +  // whitelist
        "java.util.ArrayList;java.lang.String;"        +
        "!*"   // reject everything not explicitly whitelisted
    );
    ois.setObjectInputFilter(filter);

    Object obj = ois.readObject();  // filter checked for every class in the graph
}

// ── Serialization proxy pattern ────────────────────────────────────────
public final class Period implements Serializable {
    private static final long serialVersionUID = 1L;

    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        // Constructor enforces invariant:
        if (start.after(end)) throw new IllegalArgumentException("start after end");
        this.start = new Date(start.getTime());  // defensive copy
        this.end   = new Date(end.getTime());
    }

    // writeReplace: instead of serializing 'this', serialize the proxy
    private Object writeReplace() {
        return new SerializationProxy(this);
    }

    // readObject: prevent direct deserialization of Period instances
    private void readObject(ObjectInputStream ois) throws InvalidObjectException {
        throw new InvalidObjectException("Use serialization proxy");
    }

    // Private static proxy class — minimal, correct state
    private static class SerializationProxy implements Serializable {
        private static final long serialVersionUID = 1L;
        private final Date start;
        private final Date end;

        SerializationProxy(Period p) {
            this.start = p.start;
            this.end   = p.end;
        }

        // readResolve: reconstruct Period through its public constructor
        private Object readResolve() {
            return new Period(start, end);  // invariant enforced — no way to bypass
        }
    }

    public Date start() { return new Date(start.getTime()); }
    public Date end()   { return new Date(end.getTime()); }
}

// ── Modern alternative: Jackson JSON serialization ─────────────────────
import com.fasterxml.jackson.databind.ObjectMapper;

ObjectMapper mapper = new ObjectMapper();

// Serialize to JSON byte array:
byte[] json = mapper.writeValueAsBytes(alice);
System.out.println(new String(json));
// {"name":"Alice","age":30,"email":"alice@example.com"}

// Deserialize from JSON:
Person restored = mapper.readValue(json, Person.class);
// No security risk from untrusted sources (no code execution via gadget chains)
// Schema evolution: add fields freely — missing fields get defaults, extra fields ignored

// ── writeReplace / readResolve for singleton pattern ─────────────────
public class Config implements Serializable {
    private static final long serialVersionUID = 1L;
    private static final Config INSTANCE = new Config();

    private Config() {}

    public static Config getInstance() { return INSTANCE; }

    // Preserve singleton property across serialization:
    private Object readResolve() {
        return INSTANCE;   // replace deserialized instance with the singleton
    }
}

Config c1 = Config.getInstance();
byte[] bytes = serialize(c1);       // serialize
Config c2 = (Config) deserialize(bytes); // deserialize
System.out.println(c1 == c2);      // true — readResolve ensures singleton

Related Topics in Java I/O

FileWriter
FileWriter is a convenience class for writing characters to a file, extending OutputStreamWriter with a FileOutputStream underneath. It encodes Java characters into bytes using the platform's default charset (or an explicit charset since Java 11) and writes them to a named file or File object. FileWriter supports two modes: overwrite (the default, which truncates the file to zero length on opening) and append (which positions the write pointer at the end of the existing file content). Like FileReader, FileWriter is unbuffered — each write() call propagates immediately to the underlying FileOutputStream, triggering system calls. In practice, FileWriter is almost always wrapped in a BufferedWriter to batch writes into efficient OS calls. The charset trap is identical to FileReader: pre-Java-11 constructors use the platform default charset silently, which causes portability problems; Java 11 constructors accept an explicit Charset. This entry covers all constructor variants with their charset and append semantics, the write methods and their character vs string behavior, newLine() in BufferedWriter, the flush/close contract, and the preferred modern alternatives.
BufferedReader
BufferedReader wraps any Reader with an in-memory character buffer, dramatically reducing system calls for character-by-character or line-by-line reading. Its defining method is readLine(), which reads a complete line of text terminated by \n, \r, or \r\n and returns it without the terminator, or returns null at end-of-file. Beyond buffering, BufferedReader also exposes a lines() method (Java 8+) that returns a lazy Stream<String> of lines, enabling the full Stream API for file processing without loading the entire file into memory. BufferedReader supports mark/reset with a caller-specified readAheadLimit. It is obtained either by wrapping a Reader (new BufferedReader(new FileReader(...))) or directly from Files.newBufferedReader(path, charset), which is the preferred idiom in modern Java. This entry covers construction and buffer sizing, all read methods and their contracts, readLine() edge cases (empty lines, last line without terminator), the lines() stream and its relationship to reader lifecycle, mark/reset semantics with readAheadLimit, and the use of BufferedReader as a base for protocol parsing.
BufferedWriter
BufferedWriter wraps any Writer with an in-memory character buffer, reducing system calls by accumulating characters until the buffer fills, flush() is called, or close() is called. It adds two capabilities not present in Writer: newLine(), which writes the platform-specific line separator, and an optimized write(String, int, int) that avoids creating a char[] copy by writing directly from the String. BufferedWriter is the standard output partner to BufferedReader — together they provide efficient line-by-line text file processing. It is constructed either by wrapping a Writer (new BufferedWriter(new FileWriter(...))) or via Files.newBufferedWriter(path, charset, options), the modern idiomatic alternative. Like all buffered streams, correct usage requires try-with-resources to guarantee that buffered data is flushed and the file is closed even when exceptions occur. This entry covers construction and buffer sizing, all write methods and their interaction with the buffer, newLine() and its platform behavior, flush semantics including when explicit flush is necessary, the difference between close() and flush(), and performance patterns for high-throughput text writing.
PrintWriter
PrintWriter is a character-based output class that wraps any Writer or OutputStream and adds convenience methods for printing formatted representations of all Java primitive types, strings, and objects. Its defining characteristic is that none of its print(), println(), and printf() methods throw checked IOException — errors are silently swallowed and can only be detected after the fact by calling checkError(). This makes PrintWriter easy to use interactively and in situations where I/O failure is genuinely unrecoverable (writing to System.out, generating diagnostic output), but makes it dangerous for critical data writing where exceptions must be caught and handled. PrintWriter can auto-flush on println(), printf(), and format() calls when constructed with autoFlush=true, which is useful for interactive console output and network protocol streams. Its printf() and format() methods delegate to java.util.Formatter, enabling C-style formatted output with full locale awareness. This entry covers all constructor variants and their autoFlush and buffering behavior, every print/println/printf method, the checkError() error detection model, the difference between PrintWriter and PrintStream, charset handling, and when PrintWriter is the right choice versus BufferedWriter.