Serialization in Java

Serialization turns an object graph into bytes and back. It is easy to switch on, hard to maintain, and a genuine security risk.

What serialization is

Serialization converts an object, and everything it references, into a byte sequence that can be stored or transmitted. Deserialization rebuilds the objects from those bytes.

import java.io.Serializable;

public class Note implements Serializable {

    private static final long serialVersionUID = 1L;

    private String title;
    private int views;
    private transient String cachedSummary;   // not written

    public Note(String title, int views) {
        this.title = title;
        this.views = views;
    }
}

Serializable is a marker interface: it declares no methods and simply grants permission.

Writing and reading

Note note = new Note("Java streams", 610);

try (ObjectOutputStream out = new ObjectOutputStream(
        Files.newOutputStream(Path.of("note.ser")))) {
    out.writeObject(note);
}

try (ObjectInputStream in = new ObjectInputStream(
        Files.newInputStream(Path.of("note.ser")))) {
    Note restored = (Note) in.readObject();
}

What is and is not written

IncludedExcluded
Instance fieldstransient fields
Referenced objects, recursivelystatic fields
The class name and field metadataMethods and constructors

A transient field is restored to its default value: null, zero or false.

Every referenced object must be serializable

public class Note implements Serializable {
    private Author author;          // Author must also be Serializable
}

// Otherwise: NotSerializableException at write time, naming the offending class

serialVersionUID

private static final long serialVersionUID = 1L;

This identifier ties the bytes to a class version. If it is not declared, the compiler computes one from the class structure, and almost any change, adding a field, changing a modifier, produces a different value. Old data then fails to load with InvalidClassException.

Always declare serialVersionUID explicitly on a serializable class. Doing so means you decide when compatibility is broken rather than the compiler deciding by accident.

Compatible and incompatible changes

ChangeCompatible
Adding a fieldYes, it reads back as the default
Removing a fieldYes, the stored value is ignored
Changing a field typeNo
Renaming a fieldNo, it behaves as remove plus add
Renaming or moving the classNo
Adding or removing methodsYes, methods are not serialized

Custom serialization

public class Credentials implements Serializable {

    private static final long serialVersionUID = 1L;

    private String username;
    private transient char[] password;

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

    private void readObject(ObjectInputStream in)
            throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        this.password = decrypt((byte[]) in.readObject());
    }
}

These two private methods are found by reflection and let a class control its own format. Note that readObject is effectively a hidden constructor: it bypasses the real one, so every validation must be repeated there.

The security problem

// Never do this with untrusted bytes
Object value = new ObjectInputStream(untrustedInput).readObject();

Deserialization constructs arbitrary objects and runs their readObject methods before any of your code inspects them. With the right classes on the classpath, a crafted byte stream can trigger a chain of calls that executes commands. This class of vulnerability has affected many well known systems.

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "com.example.notes.*;java.base/*;!*");     // allow list, reject everything else

ObjectInputStream in = new ObjectInputStream(input);
in.setObjectInputFilter(filter);

Java 9 added serialization filters as a mitigation. They help, but the real answer is not to deserialize untrusted data at all.

What to use instead

NeedBetter option
Data exchange between systemsJSON or another explicit text format
Compact binary messagesA schema based binary format
Storing application dataA database
ConfigurationProperties, YAML or JSON
Deep copying an objectA copy constructor
// Explicit and safe: you decide the format, and nothing is constructed by surprise
public record NoteDto(String title, int views) {

    public String toCsv() {
        return title.replace(",", " ") + "," + views;
    }

    public static NoteDto fromCsv(String line) {
        String[] parts = line.split(",", 2);
        return new NoteDto(parts[0], Integer.parseInt(parts[1]));
    }
}

Where it still appears

  • Distributed caches and session replication in older frameworks.
  • Legacy remote method invocation.
  • Existing files written years ago that still have to be read.

You will meet it, which is why it is worth understanding. Choosing it for something new needs a strong justification.

Common mistakes

  • Omitting serialVersionUID and breaking compatibility on the next edit.
  • Forgetting that a referenced class must also be serializable.
  • Serializing secrets, since the bytes are readable.
  • Expecting static or transient fields to be restored.
  • Skipping validation in readObject, so an invalid object appears without a constructor ever running.
  • Deserializing data from an untrusted source.

Best practices

  • Prefer an explicit format such as JSON for anything crossing a boundary.
  • If you must serialize, declare serialVersionUID and treat the format as a published contract.
  • Mark derived, cached and sensitive fields transient.
  • Validate inside readObject exactly as a constructor would.
  • Apply an allow list filter whenever the input is not fully under your control.
  • Never deserialize untrusted bytes.

Practice

  1. Serialize an object, add a field, and observe what happens with and without serialVersionUID.
  2. Why is a transient field null after deserialization?
  3. Explain why readObject can produce an object the constructor would have rejected.
  4. Replace a serialized file with a CSV or JSON representation and list the advantages.
  5. Describe, in general terms, why deserializing untrusted input is dangerous.

Conclusion

Serialization is convenient and expensive to live with: the class structure becomes a wire format, and untrusted input becomes a security risk. Understand it because you will meet it, and choose an explicit format for anything new.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Introduction to Java

Java is a statically typed, object oriented language that compiles to bytecode and runs on a virtual machine, which is what makes it portable.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.