Command Palette

Search for a command to run...

PHASE 12Intermediate ~20 min· topic 4 of 5

Topic 12.4

Serialization and Why to Avoid It

In one line

Java serialization turns an object graph into bytes (ObjectOutputStream) and rebuilds it later (ObjectInputStream). It's built in and easy, but it's fragile across versions and reading untrusted bytes is a classic security hole, so modern code prefers explicit formats like JSON and uses filters when it must deserialize.

Think of it like this

Packing a toy robot into a box to post to a friend. You take it apart, write a parts list, put every piece in the box, and your friend rebuilds it from the list. That's serialization (packing into bytes) and deserialization (rebuilding). It works when both sides have the same instructions. But if a stranger posts you a box, rebuilding whatever is inside without checking is how you end up assembling something dangerous.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Serialization
Turning an object, and everything it refers to, into a sequence of bytes that can be stored or sent.
Deserialization
Rebuilding objects from those bytes.
Marker interface
An interface with no methods. Implementing it only tags the class, like Serializable.
Object graph
An object plus all the objects it points to, and the objects they point to, and so on.
transient
A keyword that tells serialization to skip a field.
serialVersionUID
A version number for a serializable class. Bytes written with a different number are rejected.
Gadget chain
A sequence of existing classes an attacker combines during deserialization to make the program do something harmful.
ObjectInputFilter
A rule set (Java 9) that decides which classes, depths and sizes are allowed while deserializing.

Step by step

01Opting in with Serializable

A class is serializable only if it (or a superclass) implements Serializable. Every field's type must be serializable too, or marked transient; otherwise writing fails with NotSerializableException naming the culprit class.

Inner (non-static) classes hold a hidden reference to their outer object (Topic 5.13), which then gets serialized as well, often by surprise. Make serializable nested classes static.

User.javawhole filejava
class User implements Serializable {
    private static final long serialVersionUID = 1L;
    String name;
    transient String password;   // skipped
    int logins;
}

02Writing and reading

Wrap any byte stream: a FileOutputStream to save to disk, a socket stream to send over the network, or a ByteArrayOutputStream to keep the bytes in memory. writeObject walks the graph and writes each object once; shared references and cycles are preserved with back-references.

readObject returns Object, so you cast. The result is a new object: equal in content (except transient fields), never the same instance.

Writing and readingdiagram
Rendering diagram…

03Versioning with serialVersionUID

When reading, the stream's serialVersionUID must equal the local class's. Declare it explicitly and you control compatibility: adding a field keeps old bytes readable (the new field gets its default), while changing a field's type or removing a needed field may break things.

Without an explicit value, harmless changes such as adding a method change the computed UID, and yesterday's cached sessions fail with InvalidClassException.

04Records are rebuilt through their constructor

For a normal class, deserialization sets fields directly, so constructor checks are skipped. For a record, the stream supplies the component values and Java calls the canonical constructor, so the compact constructor's validation runs on every read.

That makes records the safest way to serialize simple data in Java, if you serialize at all.

05Why untrusted bytes are dangerous

During readObject, Java creates objects of whatever classes the bytes name, as long as they're on the classpath, and runs their custom readObject/readResolve methods. Attackers look for combinations of library classes whose deserialization logic can be steered into harmful actions, or into consuming huge amounts of memory (a tiny payload describing deeply nested sets can take minutes to read).

You can't fix this by checking the object after readObject returns: the damage happens during reading. The defences are to not read untrusted data at all, or to filter before objects are created.

06Filters: allow only what you expect

ObjectInputFilter (Java 9) checks each class, array size, graph depth and reference count as the stream is read. A pattern like "com.myapp.dto.*;java.lang.*;!*" allows your DTOs and basic types and rejects everything else. Set it per stream with setObjectInputFilter, or for the whole JVM with -Djdk.serialFilter=....

Java 17 (JEP 415) added context-specific filter factories, so libraries and frameworks can combine filters. Rejected classes cause InvalidClassException: filter status: REJECTED.

07What to use instead

For data that leaves the process (files, network, queues), use an explicit format: JSON (Jackson, Gson), Protocol Buffers or Avro. They map only the fields you choose, work across languages, and evolve more predictably.

For copying objects in memory, write a copy constructor or a copy() method instead of serialize-then-deserialize tricks.

Try it yourself

  1. 1

    Remove transient

    In the round-trip example, delete the word transient. Predict the output, then run it: the password now survives the trip, which is exactly what you don't want in a session cache or log.

  2. 2

    Allow the class

    In the filter example, change the pattern to "java.util.ArrayList;java.lang.String;!*". Run it: now it prints accepted, because every class in the stream is allowed.

Code & diagrams

A round trip in memory, with a transient field New tab
Sign in to run this example in your browser.

Expected output

name: asha, logins: 7, password: null
same object? false
Records re-run their validation when read Java 16+ New tab
Sign in to run this example in your browser.

Expected output

read back: Range[lo=1, hi=5]
a record is rebuilt through its constructor, so the check runs on every read
A deserialization filter rejects unexpected classes Java 9+ New tab

The filter allows String and rejects every other class (!*), so the ArrayList is refused before any object is created.

Sign in to run this example in your browser.

Expected output

rejected: filter status: REJECTED
Saving to a file (run on your computer)java
try (var out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("user.ser")))) {
    out.writeObject(user);
}
try (var in = new ObjectInputStream(new BufferedInputStream(new FileInputStream("user.ser")))) {
    in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.example.User;java.lang.*;!*"));
    User back = (User) in.readObject();
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Serialize a class whose field isn't serializable

Add a field Thread worker = new Thread(); to User and write it.

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.io.NotSerializableException: java.lang.Thread
at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1200)
...

Myth vs fact

Myth

Deserialization calls my constructor, so my checks protect me.

Fact

For normal classes it doesn't: fields are filled in directly. Only records go through their constructor.

Myth

I can validate the object after readObject returns.

Fact

Malicious payloads do their damage while objects are being created. Filter before reading, or don't read untrusted bytes.

Myth

serialVersionUID is optional.

Fact

Without it, harmless changes to the class change the computed version and old data stops loading.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The JVM finds a no-arg constructor of the first non-serializable superclass and uses a generated constructor accessor to allocate the subclass instance without running the serializable classes' constructors; ObjectStreamClass caches per-class reflection data for this.

  • ▸

    writeReplace/readResolve let a class substitute another object in the stream. The serialization proxy pattern (Effective Java) uses them to serialize a simple proxy and rebuild through public constructors, recovering invariants.

  • ▸

    JEP 290 (Java 9) added ObjectInputFilter and jdk.serialFilter; JEP 415 (Java 17) added filter factories (jdk.serialFilterFactory) for context-specific filters. Libraries like Apache Commons Collections were patched after the 2015 gadget-chain disclosures, but defence in depth still means filtering.

  • ▸

    Serialization is also a covert way to break singletons and immutability (a deserialized copy is a second instance with any field values the bytes chose). Enums are safe: they serialize by name and readObject returns the existing constant.

Remember this

  1. 1

    A class opts in by implementing the marker interface java.io.Serializable (it has no methods). ObjectOutputStream.writeObject(obj) writes the object, its class description and every object it refers to (the whole object graph), and ObjectInputStream.readObject() creates new objects from those bytes without calling the class's constructors (only the first non-serializable superclass's no-arg constructor runs).

  2. 2

    **transient fields are skipped: after reading they hold their default (null, 0, false). Use it for passwords, caches and anything that can't or shouldn't travel. static** fields are never serialized: they belong to the class, not the object.

  3. 3

    **serialVersionUID** is the class's version stamp. If you don't declare one, the JVM computes it from the class's structure, so adding a single method can change it and old bytes then fail with InvalidClassException: ... local class incompatible. Declare private static final long serialVersionUID = 1L; on every serializable class you store.

  4. 4

    Records (Java 16) serialize differently and better: they're rebuilt through their canonical constructor, so validation in a compact constructor runs again on every read. A normal class bypasses its constructors, which lets forged bytes create objects that break the class's rules.

  5. 5

    Security: deserializing untrusted data is dangerous. Attackers can send bytes that build unexpected objects whose readObject methods, chained together ("gadget chains"), run code or exhaust memory on your server. This has caused many real breaches. Defence: never deserialize data from untrusted sources; prefer JSON, Protocol Buffers or similar formats where you control what gets created; and when you must, install an **ObjectInputFilter** (Java 9, JEP 290) that allows only the classes you expect and limits depth and size.

  6. 6

    Even its designers call built-in serialization a mistake to avoid in new code: it's tied to Java, sensitive to refactoring, breaks encapsulation (private fields travel as-is) and adds security risk. You'll still meet it in legacy systems, RMI, some caches and session replication, so you need to understand it.

Explain it without notes

01

What happens, step by step, when you call writeObject and readObject?

02

Why is deserializing untrusted data dangerous, and how do you defend against it?

03

What do transient and serialVersionUID do?

Practice

01

Serialize a list of two records to a byte array and read it back, printing the list.

02

Show that a static field is not serialized: change it after writing, then read the object back.

Trade-offs

  • ↔

    Built-in serialization needs no code and handles cycles, but it's Java-only, fragile across refactors and a security risk with untrusted input.

  • ↔

    JSON or Protobuf need mapping code or schemas, but you control exactly what crosses the boundary and other languages can read it.

Done when you can

  • Done when you can write and read an object with ObjectOutputStream and ObjectInputStream.

  • Done when you can explain transient, static fields and serialVersionUID.

  • Done when you can explain why deserializing untrusted bytes is dangerous and set up an ObjectInputFilter.

  • Done when you can say why records are safer to serialize than ordinary classes.