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.
- 0x7e0000class Line
- 0x7e0001"LPoint;"
- 0x7e0002the Line object
- 0x7e0003class Point
- 0x7e0004the Point object
every stream starts with ac ed (the magic number) and protocol version 5.
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).
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));
}
}
}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
| thing | written? |
|---|---|
| instance fields, any visibility | yes, recursively. every referenced object must be Serializable too |
transient fields | no. they come back as 0, false or null |
static fields | no. they belong to the class, not the object |
| the class's bytecode | no, just its name and a version stamp. the reader needs the class on its classpath |
| a non-serializable superclass | no. its no-arg constructor runs on read, and must exist |
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);
}
}
}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):
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());
}
}
}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.
// 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.
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);
}
}
}lo=9 hi=5
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());
}
}
}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.
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:
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());
}
}
}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.
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.
- serialization writes the whole reachable graph: descriptors, field values, back-references for shared objects.
transientandstaticfields 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.