☕ Java

Deserialization

Deserialization is the process of reconstructing a Java object from a byte stream previously produced by serialization. It is performed by ObjectInputStream.readObject(), which reads the class descriptor from the stream, loads the corresponding class, allocates a new instance without calling any constructor, and populates the fields from the stream data. The field population bypasses the constructor entirely — invariants established in the constructor are not automatically re-enforced. This constructor-bypass is the source of both the power and the danger of Java deserialization: it can reconstruct complex object graphs in one call, but it can also produce objects in states that the constructor would have rejected. This entry covers the deserialization process step by step, the constructor bypass and its security implications, resolving class versions with serialVersionUID, handling missing and extra fields during version evolution, readObject and readResolve hooks, ObjectInputStream configuration (class loader, filter), and safe deserialization practices.

The Deserialization Process — Constructor Bypass and Field Population

ObjectInputStream.readObject() reconstructs an object through a sequence of steps that deliberately bypass the normal construction path. First, it reads the class descriptor from the stream — the class name and serialVersionUID. It resolves this to a Class object using the stream's ClassLoader (the application class loader by default). If the class cannot be found, ClassNotFoundException is thrown. Second, it compares the stream's serialVersionUID with the class's declared serialVersionUID. If they differ, InvalidClassException is thrown. Third, it allocates a new instance using sun.misc.Unsafe.allocateInstance() (or an equivalent mechanism), which creates an instance without invoking any constructor. Fourth, it populates the instance's fields directly from the stream data — matching fields by name and type between the stream's class descriptor and the current class definition. The constructor bypass means that the deserialized object starts its life in a state that was not validated by any constructor. Fields declared final can still be set by the deserialization mechanism (this is one of the few ways to set final fields outside a constructor). Invariants that the constructor enforces — range checks, null checks, consistency between fields — are not checked unless a readObject() method explicitly re-checks them. An attacker who controls the byte stream can craft an object in any field configuration, including configurations that the constructor would reject. The field-matching process handles version mismatches gracefully. If the stream contains a field that the current class does not have, the field is ignored during deserialization. If the current class has a field that the stream does not contain, the field is set to its default value (null for references, 0 for numeric primitives, false for boolean). This is the basis for compatible version evolution: adding fields (with same serialVersionUID) is safe because old serialized data will leave new fields at their defaults. The deserialization of the superclass chain follows the class hierarchy. For each serializable superclass, the corresponding fields are deserialized. For the first non-Serializable superclass, the no-argument constructor is called (allocating and initializing that part of the object graph normally). This means any initialization in non-Serializable superclass constructors does execute; only serializable class constructors are bypassed.
Java
// ── Basic deserialization ─────────────────────────────────────────────
try (ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(new FileInputStream("person.ser")))) {
    Person person = (Person) ois.readObject();
    System.out.println("Deserialized: " + person);
    // Constructor was NOT called — object allocated and populated directly
}

// ── Reading multiple objects from the same stream ──────────────────────
try (ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(new FileInputStream("people.ser")))) {
    try {
        while (true) {
            Person p = (Person) ois.readObject();
            System.out.println("Read: " + p);
        }
    } catch (EOFException e) {
        System.out.println("All objects read");
    }
}

// ── Constructor bypass: invariants not re-enforced ─────────────────────
public class SafePeriod implements Serializable {
    private static final long serialVersionUID = 1L;
    private final Date start;
    private final Date end;

    public SafePeriod(Date start, Date end) {
        if (start.after(end)) throw new IllegalArgumentException("start > end");
        this.start = new Date(start.getTime());
        this.end   = new Date(end.getTime());
    }

    // WITHOUT readObject: an attacker can craft a stream where start > end
    // despite the constructor preventing it — the constructor is never called
}

// Demonstrate via reflection-less route — crafting a stream manually is complex
// but the point: readObject MUST validate invariants for security-sensitive classes

// ── readObject: re-enforce invariants after deserialization ───────────
public class ValidatedPeriod implements Serializable {
    private static final long serialVersionUID = 1L;
    private final Date start;
    private final Date end;

    public ValidatedPeriod(Date start, Date end) {
        if (start.after(end)) throw new IllegalArgumentException("start > end");
        this.start = new Date(start.getTime());
        this.end   = new Date(end.getTime());
    }

    private void readObject(ObjectInputStream ois)
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();   // populate fields from stream

        // Re-enforce constructor invariants — as if the constructor ran:
        if (start.after(end)) throw new InvalidObjectException("start > end");

        // Re-apply defensive copy (the stream may have set the field to a
        // mutable Date — we need our own copy):
        // Note: cannot reassign final fields here without reflection tricks
        // Use non-final fields or the serialization proxy pattern instead
    }
}

// ── Field matching during version evolution ───────────────────────────
// Version 1 of Person: {name, age, email}
// Version 2 of Person: {name, age, email, phone}  (added field)

// Deserializing v1 data into v2 class:
// - name, age, email: populated from stream
// - phone: set to null (not in stream — gets default for reference type)
// No error — compatible evolution with same serialVersionUID

// Deserializing v2 data into v1 class (with same serialVersionUID):
// - name, age, email: populated from stream
// - phone: in stream but not in class — IGNORED
// No error — extra fields are silently skipped

readObject, readResolve, and ObjectInputStream Configuration

The readObject hook and the readResolve hook serve different purposes. readObject (private void readObject(ObjectInputStream ois)) runs during deserialization to customize field population. It is the mirror of writeObject. The readObject method must call ois.defaultReadObject() to populate the standard fields, then may read any extra data that writeObject() wrote, in the exact same order. readObject should also re-enforce invariants and re-derive transient fields that were not serialized. readResolve (private Object readResolve()) runs after readObject (or after default deserialization) and allows the deserialized object to replace itself with a different object. The return value of readResolve() is what readObject() in the calling code receives. This is used for the singleton pattern (return the singleton instance rather than the newly-deserialized instance), for enum-like types (return the canonical constant), and for the serialization proxy pattern (the proxy's readResolve returns the final target object). ObjectInputStream configuration: the default class loader for resolving class names is the thread's context class loader, then the class loader of the nearest bootstrapping class. In application servers and OSGi containers where multiple class loaders are active, this may not find the correct class. ObjectInputStream can be subclassed to override resolveClass(ObjectStreamClass desc) and provide the correct ClassLoader for the environment. ObjectInputFilter (Java 9+) provides a hook for accepting or rejecting classes during deserialization. The filter is called once per unique class in the stream, plus for each array element count, stream depth, and byte count. Filters can be set per-stream (ois.setObjectInputFilter()) or globally (via ObjectInputFilter.Config.setSerialFilter() or the jdk.serialFilter system property). Filters should whitelist only known-safe classes rather than blacklisting known-bad ones — the whitelist approach is robust against unknown gadget chains.
Java
// ── readObject: custom deserialization with extra data ────────────────
public class VersionedData implements Serializable {
    private static final long serialVersionUID = 1L;

    private String  name;
    private int     value;
    private transient String derived;    // not serialized
    private transient long   timestamp;  // not serialized in v1, serialized in v2

    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();   // write name, value
        oos.writeLong(System.currentTimeMillis());  // write extra timestamp (v2 addition)
    }

    private void readObject(ObjectInputStream ois)
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();    // read name, value

        // Read extra data written by writeObject (if available):
        // ObjectInputStream.available() is unreliable — use try/catch for version compat:
        try {
            this.timestamp = ois.readLong();   // v2 data
        } catch (EOFException e) {
            this.timestamp = 0L;   // v1 data had no timestamp — use default
        }

        // Re-derive transient computed fields:
        this.derived = name.toUpperCase() + "_" + value;

        // Re-enforce invariants:
        if (value < 0) throw new InvalidObjectException("value cannot be negative");
    }
}

// ── readResolve: singleton and enum-like patterns ─────────────────────
public final class Weekday implements Serializable {
    private static final long serialVersionUID = 1L;

    public static final Weekday MONDAY    = new Weekday("MONDAY");
    public static final Weekday TUESDAY   = new Weekday("TUESDAY");
    public static final Weekday WEDNESDAY = new Weekday("WEDNESDAY");
    // ... etc.

    private final String name;
    private Weekday(String name) { this.name = name; }

    // readResolve: return the canonical constant instead of the new instance
    private Object readResolve() {
        return switch (name) {
            case "MONDAY"    -> MONDAY;
            case "TUESDAY"   -> TUESDAY;
            case "WEDNESDAY" -> WEDNESDAY;
            default -> throw new InvalidObjectException("Unknown weekday: " + name);
        };
    }
}

// ── Custom class loader via ObjectInputStream subclass ────────────────
public class ContextClassLoaderOIS extends ObjectInputStream {
    private final ClassLoader classLoader;

    public ContextClassLoaderOIS(InputStream in, ClassLoader cl)
            throws IOException {
        super(in);
        this.classLoader = cl;
    }

    @Override
    protected Class<?> resolveClass(ObjectStreamClass desc)
            throws IOException, ClassNotFoundException {
        try {
            return Class.forName(desc.getName(), false, classLoader);
        } catch (ClassNotFoundException e) {
            return super.resolveClass(desc);  // fall back to default resolution
        }
    }
}

// ── ObjectInputFilter: whitelist-based security ───────────────────────
// Per-stream filter:
try (ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(new FileInputStream("data.ser")))) {

    ois.setObjectInputFilter(filterInfo -> {
        Class<?> clazz = filterInfo.serialClass();
        if (clazz == null) return ObjectInputFilter.Status.UNDECIDED;

        // Whitelist: accept only these classes
        if (clazz == Person.class     ||
            clazz == Department.class  ||
            clazz == java.util.ArrayList.class ||
            clazz == java.lang.String.class) {
            return ObjectInputFilter.Status.ALLOWED;
        }
        // Reject everything else:
        System.err.println("Rejected class: " + clazz.getName());
        return ObjectInputFilter.Status.REJECTED;
    });

    Object obj = ois.readObject();  // filter applied to every class in graph
}

// Global JVM filter (set once at startup):
// System property: -Djdk.serialFilter=com.example.*;java.util.*;java.lang.String;!*
// Programmatic:
ObjectInputFilter.Config.setSerialFilter(
    ObjectInputFilter.Config.createFilter("com.example.*;java.util.*;java.lang.String;!*")
);

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.