PrepZone Logo
PrepZone

Serialization

Turning objects into bytes, the role of serialVersionUID and transient, and the security caveat.

Why this matters

  • A missing serialVersionUID is the classic cause of a deployment that fails to read yesterday's cached data.
  • Deserialising untrusted bytes is one of the most exploited vulnerability classes in Java history, and knowing why is a genuine security skill.
  • Even though JSON has replaced it for most purposes, Java serialization still appears in session replication, caches and older RMI-based systems.
Binary data
InputStream / OutputStreamImages, PDFs, audio
BufferedInputStreamReads in blocks
Text data
Reader / WriterHandles character encoding
BufferedReaderGives you readLine()
Pick the family by data type, then wrap a buffer around it. Buffering is what turns thousands of tiny system calls into a handful of big ones.

The basic mechanism

Implement the marker interface Serializable and the JVM handles the rest.

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

    private final String userId;
    private final Instant createdAt;
    private transient String authToken;     // excluded from the bytes
    private static String appName;          // static: never serialized

    public Session(String userId, Instant createdAt, String authToken) {
        this.userId = userId;
        this.createdAt = createdAt;
        this.authToken = authToken;
    }
}
Java
// Writing
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(path))) {
    out.writeObject(session);
}

// Reading
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(path))) {
    Session restored = (Session) in.readObject();
    System.out.println(restored.authToken);     // null — it was transient
}

What is and is not written

  • Instance fields are written, including private ones, recursively for referenced objects.
  • transient fields are skipped and come back as the type's default — null, 0, false.
  • static fields belong to the class, not the object, so they are never part of the stream.
  • Every referenced object must also be Serializable, or writing fails with NotSerializableException.
  • The object graph is preserved. If two fields point at the same object, deserialisation restores one shared object, not two copies.

serialVersionUID

This long is the class's structural fingerprint. It is written into the stream and checked on read.

Java
private static final long serialVersionUID = 1L;

If you omit it the compiler computes one from the class's fields, methods and modifiers. That sounds convenient and is a trap: adding a single field changes the computed value, and every previously written stream becomes unreadable with InvalidClassException.

How changes behave when you declare it explicitly

  • Adding a field is compatible. Old streams deserialise with the new field left at its default.
  • Removing a field is compatible. The value in the stream is discarded.
  • Renaming a field is not compatible in effect — it reads as a removal plus an addition, so the data is silently lost.
  • Changing a field's type is incompatible and throws.
  • Changing the class hierarchy is incompatible.

Controlling the process

Two hooks let you customise without replacing the whole mechanism.

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

    private String id;
    private transient char[] password;          // never written in plain form

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject();                // the normal non-transient fields
        out.writeObject(encrypt(password));      // plus our own handling
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        this.password = decrypt((byte[]) in.readObject());
        validate();                              // the validation the constructor would have done
    }

    private void validate() {
        if (id == null || id.isBlank()) {
            throw new InvalidObjectException("id is required");
        }
    }
}

These methods are private and the JVM finds them reflectively. readObject is the right place to restore transient state and to run the validation that the skipped constructor would have performed.

A stricter option for small immutable classes is readResolve, which lets you replace the deserialised instance entirely:

Java
private Object readResolve() {
    return new Account(id, password);     // route through the real constructor
}

This also preserves the singleton property, which plain deserialisation otherwise breaks by producing a second instance.

Externalizable

Externalizable hands you complete control and complete responsibility.

AspectSerializableExternalizable
Methods to implementNone — it is a markerwriteExternal and readExternal
What gets writtenAutomatic, all non-transient fieldsExactly what you write
Superclass fieldsHandled automaticallyYour responsibility
Constructor on readNot calledPublic no-arg constructor is required and called
Stream sizeLarger — includes metadataSmaller, if you are careful
Worth it whenAlmost alwaysOnly for a measured, hot serialisation path
  • Methods to implement

    SerializableNone — it is a marker
    ExternalizablewriteExternal and readExternal
  • What gets written

    SerializableAutomatic, all non-transient fields
    ExternalizableExactly what you write
  • Superclass fields

    SerializableHandled automatically
    ExternalizableYour responsibility
  • Constructor on read

    SerializableNot called
    ExternalizablePublic no-arg constructor is required and called
  • Stream size

    SerializableLarger — includes metadata
    ExternalizableSmaller, if you are careful
  • Worth it when

    SerializableAlmost always
    ExternalizableOnly for a measured, hot serialisation path

Externalizable trades automation for size and speed, and makes versioning entirely manual.

The security problem

readObject can construct arbitrary objects and run code paths during deserialisation. If an attacker controls the bytes, they can assemble a chain of objects whose deserialisation side effects execute commands — a deserialisation gadget chain. Several widely used libraries have shipped classes usable in such chains.

Practical defences

  • Never deserialise data from an untrusted source. This is the only complete defence.
  • Use a serialization filter when you must. Java 9 added ObjectInputFilter, and -Djdk.serialFilter applies one JVM-wide.
  • Prefer a data format over a code format: JSON, Protocol Buffers or Avro describe data without the ability to instantiate arbitrary classes.
  • Validate inside readObject and throw InvalidObjectException on anything unexpected.
Java
// Allow only your own classes, with bounded depth, and reject everything else
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "com.prepzone.model.*;java.base/*;!*");
ObjectInputStream in = new ObjectInputStream(source);
in.setObjectInputFilter(filter);

Common misreadings

  • "transient fields are encrypted." They are simply absent, and return as null or 0.
  • "static fields are serialized." They belong to the class and are never written.
  • "Deserialisation calls the constructor." It does not, which is why validation must be repeated in readObject.
  • "serialVersionUID is optional." Technically yes, practically no. Omitting it means the compiler changes your compatibility contract whenever the class is edited.
  • "Serialization is a good deep-copy tool." It works but is slow, needs everything Serializable, and silently drops transient fields.
  • "Serialization preserves singletons." It creates a second instance unless you add readResolve.

Quick recall

Everything you need if you only revisit this box.

  • Serializable is a marker; the JVM writes all non-transient, non-static instance fields and preserves shared references.
  • Deserialisation skips the constructor, so validation belongs in readObject — or route through a real constructor with readResolve.
  • Declare serialVersionUID explicitly on every class. Adding and removing fields stays compatible; renaming or retyping does not.
  • writeObject and readObject are private hooks the JVM finds reflectively; call defaultWriteObject/defaultReadObject first.
  • Externalizable gives full control and requires a public no-arg constructor, but makes versioning manual.
  • Never deserialise untrusted bytes. Use ObjectInputFilter if you must, and prefer JSON or a schema format across trust boundaries.

Test yourself

Answer these before moving on — recall is what makes it stick.