You are currently viewing Serialize an Object Graph: Understanding Java Serialization

Serialize an Object Graph: Understanding Java Serialization

This entry is part 21 of 21 in the series Key Java APIs

Introduction

Java object serialization often appears deceptively simple: implement Serializable, write an object to a stream, and read it back later. In reality, serialization is governed by a precise and sometimes unforgiving rule: Java serializes an object graph, not a single object.

Many production bugs related to serialization stem from a misunderstanding of this rule. Unexpected data sizes, NotSerializableException, broken invariants, or compatibility issues across versions are usually symptoms of an incorrectly modeled object graph.

This article explains how Java serialization works from a graph perspective, clarifying what gets serialized, what does not, and why. For a broader overview of Java I/O concepts, see Java IO API.

“Serialization does not save objects; it walks the object graph.”

1. Serialization and Deserialization

When working with Java objects, it is important to understand that objects normally live only in memory.
Once the program stops, those objects disappear.

Serialization and deserialization are mechanisms that allow Java objects to leave memory and come back later.

1.1 Serialization

Serialization is the process of converting a Java object into a sequence of bytes.
These bytes can then be:

  • written to a file,
  • sent over the network,
  • or stored for later use.

From a beginner’s perspective, you can think of serialization as “saving an object”.

try (ObjectOutputStream out =
         new ObjectOutputStream(new FileOutputStream("user.ser"))) {

    User user = new User("Alice", 30);
    out.writeObject(user);
}

What happens here:

  1. The User object exists in memory,
  2. writeObject() transforms it into bytes,
  3. Java writes the bytes to a file (user.ser).

Serialization copies the state; it does not remove or transform the original object.

1.2 Deserialization

Deserialization performs the inverse operation: it reconstructs a Java object from its serialized form.

try (ObjectInputStream in =
         new ObjectInputStream(new FileInputStream("user.ser"))) {

    User restoredUser = (User) in.readObject();
}

What happens here:

  1. Java reads the bytes from the file
  2. readObject() recreates the User object
  3. The object becomes usable again in memory

The deserialized object is a new instance, whose state matches the serialized one.

2. Serialize an Object Graph: How Java Traverses Objects?

An object graph is the set of objects reachable from a given root object through references.

class Order implements Serializable {
    Customer customer;
}

class Customer implements Serializable {
    Address address;
}

When you serialize an Order instance:

out.writeObject(order);

Java will:

  1. Serialize Order
  2. Follow the customer reference
  3. Serialize Customer
  4. Follow the address reference
  5. Serialize Address

This traversal continues until the entire reachable graph is processed.

“Reachability, not intention, determines what is serialized.”

3. The Serializable Marker Interface

Serializable is a marker interface: it has no methods. Its presence signals to the JVM that instances of the class may be serialized.

class User implements Serializable {
    String name;
}

However, implementing Serializable does not serialize only that class.

Rules:

  • Every reachable object must be serializable,
  • Otherwise, serialization fails with NotSerializableException.

This requirement propagates transitively across the object graph.

Consider the following objects:

class Session implements Serializable {
    User user;
    Socket socket; // not serializable
}

class User implements Serializable {
    String name;
    //...
}

class Socket{
    String address;
    //...
}

Attempting to serialize Session will fail:

out.writeObject(session);

Output:

java.io.NotSerializableException: Socket

Why? Because:

  • Java reached socket
  • socket is not serializable
  • Traversal cannot continue

4. Transient and Static Fields

Java provides explicit escape hatches to control graph traversal.

4.1 transient Fields

A transient field:

  • is ignored during serialization,
  • breaks the traversal at that point,
  • is restored with a default value during deserialization.
class Account implements Serializable {
    String id;
    private transient String secret;
}

After deserialization:

secret == null; // default value

Use transient for:

  • sensitive data,
  • caches
  • derived values,
  • non-serializable dependencies.

4.2 static Fields

Static fields belong to the class, not to instances. They are never serialized.

class Config implements Serializable {
    static String ENV = "prod";
    String value;
}

Only value is serialized.

“Serialization captures state, not class-level configuration.”

5. Cycles and Shared References

Object graphs may contain:

  • cycles (A → B → A),
  • shared references (A → B, C → B).

Both situations are common in real-world object models and must be handled correctly during serialization.

5.1. Cyclic references

class Node implements Serializable {
    Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a; // cycle

If Java naively followed references, this structure would cause infinite recursion.
Instead, the serialization mechanism tracks already-serialized objects and writes references to them when encountered again.

After deserialization, object identity is preserved:

a.next.next == a // true after deserialization

5.2. Shared references

class Customer implements Serializable {
    String name;
    Address address;
    //...
}

class Address implements Serializable {
    String city;
    //...
}
Address sharedAddress = new Address();// shared reference
sharedAddress.city = "Paris";

Customer c1 = new Customer("Alice", shared);
Customer c2 = new Customer("Bob", shared);

Here, both Customer instances reference the same Address object.

Java serialization preserves this relationship. After deserialization:

c1.address == c2.address // true

6. serialVersionUID and Versioning

Each serializable class has a serialVersionUID, which identifies its serialization version.

class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    String name;
}

If not explicitly declared, the JVM computes one based on:

Warning: “Implicit serialVersionUIDs are time bombs.”

Always declare it explicitly to control compatibility across versions and avoid InvalidClassException.

7. Custom Serialization Hooks

By default, Java serialization is fully automatic: the JVM writes and restores all non-transient, non-static fields by walking the object graph. In most cases, this default behavior is sufficient, and you should not modify it.

However, Java provides two optional serialization hooks that allow a class to intervene in this process when necessary:

  • writeObject(ObjectOutputStream out)
  • readObject(ObjectInputStream in)

If these methods are present with the correct signature, the serialization mechanism will call them instead of the default implementation.

private void writeObject(ObjectOutputStream out) throws IOException {
    out.defaultWriteObject();
}

private void readObject(ObjectInputStream in)
        throws IOException, ClassNotFoundException {
    in.defaultReadObject();
}

In the above example, the calls to defaultWriteObject() and defaultReadObject() explicitly delegate back to the standard behavior.

These hooks exist for specific, advanced use cases, such as:

  • validating object invariants after deserialization (constructors are not called),
  • reconstructing derived or transient fields,
  • transforming or protecting sensitive data,
  • maintaining compatibility when a class evolves over time.:

Used carefully, they allow a serialized class to remain correct, secure, and compatible across versions.

A working example: rebuilding a transient field

Consider a class with a derived field that should not be serialized, but must be restored after deserialization.

class User implements Serializable {

    private static final long serialVersionUID = 1L;

    private String firstName;
    private String lastName;

    // Derived value: should not be serialized
    private transient String displayName;

    User(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.displayName = firstName + " " + lastName;
    }

    private void writeObject(ObjectOutputStream out) throws IOException {
        // Perform default serialization of non-transient fields
        out.defaultWriteObject();
    }

    private void readObject(ObjectInputStream in)
            throws IOException, ClassNotFoundException {

        // Restore non-transient fields
        in.defaultReadObject();

        // Rebuild derived state
        this.displayName = firstName + " " + lastName;
    }

    @Override
    public String toString() {
        return displayName;
    }
}

What happens during serialization

  1. Java calls writeObject() automatically
  2. defaultWriteObject() serializes firstName and lastName
  3. Java ignores displayName because it is transient

What happens during deserialization

  1. Java calls readObject() automatically
  2. defaultReadObject() restores firstName and lastName
  3. Java recomputes displayName to restore object consistency

Without this hook, displayName would be null after deserialization.

8. Common Serialization Pitfalls

Frequent mistakes include:

  • serializing large object graphs unintentionally,
  • forgetting transient on non-serializable fields,
  • breaking compatibility across versions,
  • assuming serialization is secure.

“Serialization preserves structure, not intent.”

class Service implements Serializable {
    Logger logger; // should be transient
    //...
}

Conclusion

Java serialization operates on object graphs, not isolated objects. Understanding reachability, transience, and versioning is essential for safe and predictable use.

While serialization is powerful, you must approach it with care and restraint. A correct mental model prevents subtle bugs and long-term maintenance issues.

You can find the complete code of this article here on GitHub.

Key Java APIs

Java IO API: What You Need To Know

Noel Kamphoa

Experienced software engineer with expertise in Telecom, Payroll, and Banking. Now Senior Software Engineer at Societe Generale Paris.