Serialization

Java serialization converts an object graph into a byte stream (and back), the built-in mechanism behind object persistence and network transfer — with sharp security and compatibility edges.

Serialization is the process of converting an object's state into a byte stream that can be stored (to a file, database) or transmitted (over a network) and later reconstructed via deserialization. Java's built-in mechanism is the Serializable marker interface combined with ObjectOutputStream and ObjectInputStream. It is convenient but has well-documented compatibility and security pitfalls, which is why modern Java guidance favors alternative formats (JSON, Protocol Buffers) for most new designs, especially anything touching untrusted input.

What Is Serialization and Why It Matters

An object in memory is a graph of field values, some primitive and some references to other objects. Serialization walks that graph and encodes it as a linear byte sequence that captures enough information to reconstruct equivalent objects later, possibly in a different JVM process, on a different machine, or at a much later time. Common uses historically include caching objects to disk, sending objects between JVMs over RMI, and storing session state.

The Serializable Marker Interface

A class becomes eligible for Java's built-in serialization simply by implementing java.io.Serializable, which declares no methods at all — it is a marker interface that tells the serialization machinery 'this class opts in to being serialized.' Every non-transient, non-static field must itself be serializable (or null at serialization time), and this requirement is transitive through the whole object graph.

import java.io.Serializable;

public class Employee implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
    private int age;
    private transient String cachedSummary; // excluded from serialization

    public Employee(String name, int age) {
        this.name = name;
        this.age = age;
    }
    // getters omitted
}

ObjectOutputStream and ObjectInputStream

ObjectOutputStream wraps an OutputStream and adds writeObject(Object), which serializes an object (and everything it references) to bytes. ObjectInputStream wraps an InputStream and adds readObject(), which reconstructs an object graph from those bytes, returning Object (requiring a cast to the expected type).

readObject() throws the checked ClassNotFoundException because the byte stream only records the serialized class's fully-qualified name; the actual .class must be resolvable on the classpath during deserialization.

import java.io.*;

public class SerializeDemo {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Employee e = new Employee("Alice", 30);

        // Serialize to a file
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("employee.ser"))) {
            oos.writeObject(e);
        }

        // Deserialize back
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("employee.ser"))) {
            Employee restored = (Employee) ois.readObject();
            System.out.println(restored.getName() + ", " + restored.getAge());
        }
    }
}

serialVersionUID and Version Compatibility

Every serialized object's stream includes a serialVersionUID for its class, used during deserialization to verify the receiving JVM's class definition is compatible with the one that wrote the data. If a class does not declare serialVersionUID explicitly, the JVM computes one automatically from the class's structure (fields, methods, etc.) — a computation that is sensitive to seemingly unrelated changes (like adding a method), and that can differ across compilers/JVMs. Declaring serialVersionUID explicitly makes compatibility intentional and predictable instead of accidental.

If the serialVersionUID on the class that reads the data doesn't match the one embedded in the byte stream, readObject() throws InvalidClassException.

Adding new fields to a class is generally backward-compatible (old streams deserialize fine, with new fields getting default values); removing or changing the type of an existing field typically is not.

private static final long serialVersionUID = 1L; // pin it explicitly

transient: Excluding Fields From Serialization

The transient keyword marks a field as excluded from the default serialization mechanism — its value is not written by writeObject() and, on deserialization, the field is left at its type's default value (null, 0, false, etc.), not restored. This is appropriate for fields that are derived/cacheable, non-serializable by nature (a Thread, a Socket, a file handle), or sensitive (like a password that shouldn't be persisted to disk in plaintext).

public class Session implements Serializable {
    private static final long serialVersionUID = 1L;
    private String userId;
    private transient Connection dbConnection; // not serializable, and shouldn't be
}

Custom Serialization: writeObject/readObject and Externalizable

A class can customize the default field-by-field serialization by declaring private writeObject(ObjectOutputStream) and readObject(ObjectInputStream) methods (recognized by the serialization machinery via reflection, not an interface). This is used to serialize transient fields manually, validate invariants on deserialization, or handle fields that need special encoding. For full control, a class can instead implement Externalizable, which requires writeExternal()/readExternal() and takes complete responsibility for the byte format.

private void writeObject(ObjectOutputStream out) throws IOException {
    out.defaultWriteObject();       // writes normal non-transient fields
    out.writeInt(computeChecksum()); // plus custom extra data
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    int checksum = in.readInt();
    if (checksum != computeChecksum()) throw new InvalidObjectException("checksum mismatch");
}

Inheritance and Serialization

If a superclass does not implement Serializable, its fields are not serialized, and on deserialization the superclass's no-argument constructor is invoked to initialize them (which throws if no such constructor exists) — subclass fields are handled normally as long as the subclass itself implements Serializable. This is a common source of subtle bugs when only part of a class hierarchy opts into Serializable.

Security Concerns: Deserialization of Untrusted Data

Deserialization is one of the most consequential sources of Java security vulnerabilities. readObject() reconstructs objects — including invoking constructors indirectly and running any custom readObject() logic — driven by attacker-controlled bytes, which historically enabled remote code execution via 'gadget chains' (sequences of legitimate classes on the classpath whose side effects, when chained through deserialization, achieve something malicious). This is why deserializing data from an untrusted or unauthenticated source using ObjectInputStream is explicitly discouraged.

Never call readObject() (directly or via ObjectInputStream) on data from an untrusted source — a network client, a file uploaded by a user, etc. — without strict mitigations. Options include an ObjectInputFilter (Java 9+) that allowlists acceptable classes, or avoiding native Java serialization for that boundary entirely in favor of a data-only format.

Modern Alternatives to Native Serialization

For most new systems, especially ones crossing trust or process boundaries, alternatives to java.io.Serializable are preferred: JSON (via libraries like Jackson or Gson) is human-readable, language-agnostic, and does not execute arbitrary constructors during parsing; Protocol Buffers and Avro offer compact, schema-driven, cross-language binary formats with strong versioning support; the java.io.Externalizable interface, when native Java serialization is unavoidable, at least gives full explicit control over the byte format rather than relying on reflection-driven defaults.

Rule of thumb: native Java serialization is reasonable for short-lived, same-trust-boundary use (e.g. passing objects between two of your own JVMs you fully control) but is a poor default for persistence, public APIs, or anything reachable from untrusted input.

Practical Example: A Simple Persisted Object

ArrayList (and most standard collection classes) already implement Serializable, so serializing a graph containing them works with no extra effort, as long as the elements are also serializable.

import java.io.*;
import java.util.ArrayList;
import java.util.List;

public class SerializeList {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Carol"));

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("names.ser"))) {
            oos.writeObject(names);
        }

        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("names.ser"))) {
            @SuppressWarnings("unchecked")
            List<String> restored = (List<String>) ois.readObject();
            System.out.println(restored);
        }
    }
}

Mental Model

Think of ObjectOutputStream as a full snapshot camera for a live object graph: it flattens references into a linear byte log good enough to rebuild equivalent objects later, while transient fields are the parts you deliberately keep out of frame. Deserialization is essentially replaying that log to reconstruct the graph — which is exactly why feeding it a hostile log is dangerous.

Key Takeaways

Serializable is a marker interface; the actual encoding is handled by ObjectOutputStream/ObjectInputStream using reflection over the class's fields.

Always declare serialVersionUID explicitly to control version-compatibility checks predictably instead of relying on the JVM's auto-computed value.

transient fields are skipped during serialization and reset to their default value on deserialization.

Custom writeObject/readObject methods, or implementing Externalizable, allow full control over the serialized format when default field-by-field serialization isn't enough.

Never deserialize untrusted data with plain ObjectInputStream — it is a well-known remote-code-execution vector; use ObjectInputFilter or avoid native serialization at that boundary.

For most modern systems, JSON, Protocol Buffers, or similar schema-driven formats are safer and more portable choices than native Java serialization.

public class Order implements Serializable { private List<Item> items; // no explicit serialVersionUID } public class Order implements Serializable { private static final long serialVersionUID = 1L; private List<Item> items; }

Without an explicit serialVersionUID, the JVM computes one from class structure, so an unrelated code change can silently break deserialization of previously stored objects.

ObjectInputStream ois = new ObjectInputStream(untrustedSocketInputStream); Object obj = ois.readObject(); // deserializing attacker-controlled bytes

// Prefer a data-only format (e.g. JSON) for untrusted input, // or apply an ObjectInputFilter allowlisting only expected classes before readObject().

Deserializing untrusted bytes with plain ObjectInputStream can trigger gadget-chain remote code execution; untrusted input needs an allowlist filter or a non-executable format entirely.

public class Cache implements Serializable { private Connection dbConnection; // not serializable -> NotSerializableException } public class Cache implements Serializable { private transient Connection dbConnection; // excluded; re-established after deserialization }

A non-serializable field (sockets, connections, threads, streams) must be marked transient, or serialization throws NotSerializableException at runtime.

Employee restored = (Employee) ois.readObject(); // assumes the class on disk matches today's class unconditionally

// Declare serialVersionUID and test deserializing old data whenever the class changes, // or provide custom readObject() logic to handle older formats gracefully.

Changing a serializable class's fields without considering existing serialized data can make old data unreadable or silently populate fields incorrectly.

What does implementing Serializable actually do, mechanically?

Nothing by itself — it is a marker interface with no methods. It signals to ObjectOutputStream/ObjectInputStream (which use reflection) that the class is eligible to be serialized/deserialized.

Why should you always declare serialVersionUID explicitly?

Without it, the JVM computes a value from the class's structure, which can change unexpectedly with unrelated code edits and break compatibility with previously serialized data; an explicit value keeps compatibility intentional.

What happens to a transient field during serialization and deserialization?

It is skipped when writing (not included in the byte stream) and left at its type's default value (null/0/false) when the object is reconstructed on deserialization.

Why is deserializing data from an untrusted source dangerous?

readObject() can indirectly invoke constructors and custom logic driven by the byte stream's contents, which attackers can exploit via 'gadget chains' on the classpath to achieve remote code execution.

What happens if a class references a field whose type is not Serializable and the field is not marked transient?

Serialization throws NotSerializableException at runtime when it reaches that field.

How does implementing Externalizable differ from implementing Serializable?

Externalizable requires the class to implement writeExternal()/readExternal() and takes full manual control of the byte format, rather than relying on the JVM's reflective default field-by-field encoding.

Why do many modern systems prefer JSON or Protocol Buffers over native Java serialization?

They are language-agnostic, human-readable (JSON) or compact and schema-versioned (protobuf/Avro), and parsing them does not execute arbitrary class logic, avoiding the security risks of native deserialization.

Write a Serializable class Point with x and y fields, an explicit serialVersionUID, and a program that serializes an instance to a file and deserializes it back.

import java.io.*; class Point implements Serializable { private static final long serialVersionUID = 1L; private final int x, y; Point(int x, int y) { this.x = x; this.y = y; } @Override public String toString() { return "(" + x + ", " + y + ")"; } } public class PointSerializeDemo { public static void main(String[] args) throws IOException, ClassNotFoundException { Point p = new Point(3, 4); try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("point.ser"))) { oos.writeObject(p); } try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("point.ser"))) { Point restored = (Point) ois.readObject(); System.out.println(restored); } } }

The class implements Serializable with an explicit serialVersionUID; writeObject()/readObject() on ObjectOutputStream/ObjectInputStream handle the field-by-field encoding automatically since int fields are primitives.

What does this print after round-tripping through serialization?

default,0 — name is a regular field and round-trips normally, but cacheHits is transient so it is not written, and comes back as int's default value, 0, regardless of what it held before serialization.

Write a class that overrides writeObject/readObject to also serialize a transient field manually (rather than skip it entirely).

import java.io.*; class SecureNote implements Serializable { private static final long serialVersionUID = 1L; private String title; private transient String secret; // handled manually, not skipped SecureNote(String title, String secret) { this.title = title; this.secret = secret; } private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); out.writeObject(secret == null ? "": new StringBuilder(secret).reverse().toString()); // toy obfuscation } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); String obfuscated = (String) in.readObject(); this.secret = new StringBuilder(obfuscated).reverse().toString(); } @Override public String toString() { return title + ":" + secret; } } defaultWriteObject()/defaultReadObject() handle the normal non-transient fields, and the custom code around them explicitly writes and reads the transient field using its own logic, letting the class control exactly how that field is persisted (here, trivially obfuscated as a demonstration).

class Config implements Serializable {
    private static final long serialVersionUID = 1L;
    String name = "default";
    transient int cacheHits = 99;
}
// serialize a Config, then deserialize it
System.out.println(restored.name + "," + restored.cacheHits);