skip to content
everything.io.serialization

serialization

how objects become bytes, and why everyone says don't.

chapter
chapter 10.05
reading time
9 min read

implement Serializable, hand the object to an ObjectOutputStream, and java writes the whole object graph as bytes: class descriptors, field values, and back-references so shared objects are written once. reading it back builds the objects without running their constructors.

that last part is why everyone says don't. use JSON or protobuf for data. if you're stuck with it, serialize records and install a filter.

the picture

you know how saving a game freezes the whole world, every character and every item, and loading it puts everything back exactly as it was? java serialization is that, for objects. "save" walks every reference from the object you hand it. "load" rebuilds the world, including which things pointed at which.

the catch: whoever made the save file decides what world you load. you don't get to ask the characters if they agree with their stats.

in memory
LineabPointx 1y 2two references, one object
handle table (writer and reader both keep one)
  • 0x7e0000class Line
  • 0x7e0001"LPoint;"
  • 0x7e0002the Line object
  • 0x7e0003class Point
  • 0x7e0004the Point object
the stream · 91 bytesmagic + version
aced0005737200044c696e65b82502dd830d08c60200024c0001617400074c506f696e743b4c00016271007e0001787073720005506f696e74a8094b8051f2941502000249000178490001797870000000010000000271007e0004

every stream starts with ac ed (the magic number) and protocol version 5.

step 1/11
step through the real 91 bytes java writes for a Line whose two fields point at one Point. every new thing gets a handle; the second time something shows up, the stream just writes a back-reference to it.

here's the program behind those bytes. note the classic public class Main: the classes have to be top-level (more on why below).

Main.java
import java.io.*;
import java.util.HexFormat;
 
class Point implements Serializable {
    int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
}
 
class Line implements Serializable {
    Point a, b;
    Line(Point a, Point b) { this.a = a; this.b = b; }
}
 
public class Main {
    public static void main(String[] args) throws Exception {
        Point p = new Point(1, 2);
        Line line = new Line(p, p);          // one Point, two references
 
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) {
            out.writeObject(line);
        }
        byte[] raw = bytes.toByteArray();
        System.out.println(raw.length + " bytes");
        System.out.println(HexFormat.ofDelimiter(" ").formatHex(raw));
 
        try (var in = new ObjectInputStream(new ByteArrayInputStream(raw))) {
            Line copy = (Line) in.readObject();
            System.out.println("copy.a == copy.b: " + (copy.a == copy.b));
            System.out.println("copy.a == p: " + (copy.a == p));
        }
    }
}
output, from jdk 25
91 bytes
ac ed 00 05 73 72 00 04 4c 69 6e 65 b8 25 02 dd 83 0d 08 c6 02 00 02 4c 00 01 61 74 00 07 4c 50 6f 69 6e 74 3b 4c 00 01 62 71 00 7e 00 01 78 70 73 72 00 05 50 6f 69 6e 74 a8 09 4b 80 51 f2 94 15 02 00 02 49 00 01 78 49 00 01 79 78 70 00 00 00 01 00 00 00 02 71 00 7e 00 04
copy.a == copy.b: true
copy.a == p: false

this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.

the shape survives (copy.a == copy.b), the identity doesn't (copy.a != p, it's a brand new object). that's a deep copy, which is why "serialize and deserialize" is an old, slow trick for cloning a graph.

what gets written

thingwritten?
instance fields, any visibilityyes, recursively. every referenced object must be Serializable too
transient fieldsno. they come back as 0, false or null
static fieldsno. they belong to the class, not the object
the class's bytecodeno, just its name and a version stamp. the reader needs the class on its classpath
a non-serializable superclassno. its no-arg constructor runs on read, and must exist
Main.java
import java.io.*;
 
class Session implements Serializable {
    String user = "ada";
    transient String password = "hunter2";
    transient int hits = 42;
}
 
public class Main {
    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) {
            out.writeObject(new Session());
        }
        try (var in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            Session s = (Session) in.readObject();
            System.out.println(s.user + " " + s.password + " " + s.hits);
        }
    }
}
output
ada null 0

hits came back as 0, not 42. the field initializers didn't run either, because no constructor ran: the object was allocated and its fields filled straight from the stream.

serialVersionUID

every class descriptor carries a 64-bit version stamp. if the class doesn't declare private static final long serialVersionUID, java computes one by hashing the class's shape: its name, interfaces, fields, constructors and methods. add a method, and the hash changes.

on read, the stamp in the stream must equal the stamp of the class on the reader's classpath. here's what happens when it doesn't (faked by flipping one bit of the stored UID, since a program can't easily hold two versions of one class):

Main.java
import java.io.*;
 
class Point implements Serializable {
    int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
}
 
public class Main {
    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) {
            out.writeObject(new Point(1, 2));
        }
        byte[] raw = bytes.toByteArray();
        raw[19] ^= 1;   // bytes 12 to 19 are Point's serialVersionUID. change one bit
 
        try (var in = new ObjectInputStream(new ByteArrayInputStream(raw))) {
            in.readObject();
        } catch (InvalidClassException e) {
            System.out.println(e.getMessage());
        }
    }
}
output
Point; local class incompatible: stream classdesc serialVersionUID = -6338451986044513003, local class serialVersionUID = -6338451986044513259

so the rule: if you serialize a class, declare the UID yourself (serialver in the JDK prints the current computed one). then compatible changes (adding a field, which old streams just leave at its default) keep working, and you only bump it when you mean to break old data.

predict the output
java
// after the round trip in the first example:
Line copy = (Line) in.readObject();
IO.println(copy.a == copy.b);

why everyone says don't

because deserialization is a constructor you don't control. your class's constructor checks its invariants, but readObject never calls it. whoever wrote the bytes picks every field value.

don'ta class: the check never runs
Main.java
import java.io.*;
 
class Range implements Serializable {
    final int lo, hi;
    Range(int lo, int hi) {
        if (lo > hi) throw new IllegalArgumentException(lo + " > " + hi);
        this.lo = lo;
        this.hi = hi;
    }
}
 
public class Main {
    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) {
            out.writeObject(new Range(1, 5));
        }
        byte[] raw = bytes.toByteArray();
        raw[raw.length - 1] = 9;   // fields go in name order: hi, lo
 
        try (var in = new ObjectInputStream(new ByteArrayInputStream(raw))) {
            Range r = (Range) in.readObject();
            System.out.println("lo=" + r.lo + " hi=" + r.hi);
        }
    }
}
output
lo=9 hi=5
doa record: the canonical constructor runs
Main.java
import java.io.*;
 
record Range(int lo, int hi) implements Serializable {
    Range {
        if (lo > hi) throw new IllegalArgumentException(lo + " > " + hi);
    }
}
 
public class Main {
    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) {
            out.writeObject(new Range(1, 5));
        }
        byte[] raw = bytes.toByteArray();
        raw[raw.length - 1] = 9;
 
        try (var in = new ObjectInputStream(new ByteArrayInputStream(raw))) {
            System.out.println(in.readObject());
        } catch (InvalidObjectException e) {
            System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}
output
InvalidObjectException: 9 > 5

a Range with lo=9, hi=5 exists, and its constructor swears that's impossible. that's the mild version. the bad version is the gadget chain: classes already on your classpath whose readObject, hashCode or equals do something powerful. the attacker doesn't send code, just a graph of your objects, wired so that deserializing it calls one method that calls another, until something ends up running a command.

hover a stage.

hover a stage. this is the shape of the real 2015 attacks: the payload is only data, but deserializing it runs code that was already sitting in a library jar on the server.

in 2015 this went from theory to every-big-app-server: a chain through Apache Commons Collections, which sat on the classpath of WebLogic, WebSphere, JBoss and Jenkins, turned any endpoint that deserialized untrusted input into remote code execution. Oracle's own chief architect has since called serialization a mistake and estimated it was involved in at least a third of java's vulnerabilities.

if you must: filters

java 9+ JEP 290 added deserialization filters: an allowlist (or blocklist, plus limits on depth, array size and object count) checked before any class is loaded or instantiated. JEP 415 java 17+ made them per-context with a filter factory. allowlist exactly the classes you expect and reject everything else:

Main.java
import java.io.*;
import java.util.HashMap;
 
record Range(int lo, int hi) implements Serializable {}
 
public class Main {
    static Object read(byte[] raw) throws Exception {
        try (var in = new ObjectInputStream(new ByteArrayInputStream(raw))) {
            in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("Range;!*"));
            return in.readObject();
        }
    }
 
    static byte[] write(Object o) throws IOException {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) { out.writeObject(o); }
        return bytes.toByteArray();
    }
 
    public static void main(String[] args) throws Exception {
        System.out.println(read(write(new Range(1, 5))));
        try {
            read(write(new HashMap<>()));
        } catch (InvalidClassException e) {
            System.out.println(e.getMessage());
        }
    }
}
output
Range[lo=1, hi=5]
filter status: REJECTED

Range;!* reads as "allow Range, reject everything else". the same pattern can be set JVM-wide with -Djdk.serialFilter=..., without touching code. the HashMap never got created, so its readObject never ran.

one level deeper:what records change, exactly+

a record's serialized form is just its component values, by name. on read, the stream's values are matched to components by name and passed to the canonical constructor, so the compact constructor's checks run (that's the InvalidObjectException above). writeObject, readObject and serialPersistentFields are ignored for records, there's no field-by-field reflection into the object, and a record's serialVersionUID is 0L unless you declare one, with the matching check waived. it's a much smaller, safer surface. it doesn't make deserializing untrusted data safe on its own, because the stream can still name other classes: you still want a filter.

keylines
  • serialization writes the whole reachable graph: descriptors, field values, back-references for shared objects.
  • transient and static fields aren't written. constructors don't run on read (records are the exception).
  • declare serialVersionUID, or any change to the class can orphan old data.
  • deserializing untrusted bytes runs code from your own classpath. that's the gadget-chain problem.
  • if you can't avoid it: records, plus an allowlist filter. for new formats, use JSON or protobuf.