Externalizable and Custom Serialization in Java
A singleton that goes through serialization stops being a singleton: after readObject() there are two objects in memory, and == returns false. An object whose constructor checks its arguments can be read from a tampered file in an invalid state, because the constructor is not called during deserialization. Custom serialization fixes both problems.
Custom serialization in Java means controlling how an object is written to a stream and how it is restored. Java gives you special methods that the JVM calls on its own: writeObject() and readObject() change how fields are written and read, writeReplace() and readResolve() substitute the object itself, and the Externalizable interface hands the data format entirely over to you.
This lesson builds on serialization basics and serialVersionUID, which cover Serializable, transient and class version compatibility. Those topics are not repeated here.
1. Which methods control serialization
A serializable class can declare several methods that ObjectOutputStream and ObjectInputStream find through reflection and call instead of, or together with, the standard logic. None of them is declared in the Serializable interface: the JVM looks them up by name and signature.
| Method | When it is called | What it is for |
|---|---|---|
private void writeObject(ObjectOutputStream) | When the object is written, for each class in the hierarchy | Add or change data: computed values, transient fields |
private void readObject(ObjectInputStream) | When the object is read, for each class in the hierarchy | Restore transient fields, check invariants, make defensive copies |
Object writeReplace() | Before writing; the returned object is written instead | Write a stand-in instead of the object (Serialization Proxy) |
Object readResolve() | After reading; the caller receives the returned object | Return an existing instance (singleton) or build the object through a constructor |
public void writeExternal(ObjectOutput)public void readExternal(ObjectInput) | Only for Externalizable, instead of all the standard logic | A fully custom data format |
All of these methods except writeExternal() and readExternal() are worth marking with the @Serial annotation (Java 14+): the IDE and the compiler with -Xlint:serial will warn you about a wrong signature. Without the annotation, a typo in the name or an extra modifier means the JVM simply does not find the method and silently falls back to standard serialization.
2. writeObject() and readObject(): the rules
The writeObject() and readObject() methods work only when these conditions are met:
- Exact signature:
private void writeObject(ObjectOutputStream out) throws IOExceptionandprivate void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException. The JVM will not call apublicorstaticmethod. - Standard fields first. Make
out.defaultWriteObject()the first call, andin.defaultReadObject()accordingly. Even if all fields are transient, this call leaves room for fields that future versions of the class may add. - Same order.
readObject()reads the extra data in the same order and with the same types thatwriteObject()wrote it. Otherwise you getOptionalDataException,EOFExceptionor corrupted values. - Each class handles itself. In a hierarchy the JVM calls
writeObject()of every serializable class in turn, from the superclass to the subclass, and each one writes only its own fields. There is no need (and no way) to call "super.writeObject()": the method isprivate.
public class Account implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String owner;
private transient List<String> history = new ArrayList<>(); // the log is not saved
@Serial
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // owner
out.writeInt(history.size()); // only the size of the log
}
@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
int lost = in.readInt();
history = new ArrayList<>(); // the transient field arrived as null - create it again
history.add("restored, entries lost: " + lost);
}
} Look at the last lines of readObject(): a transient field is null after reading, and if you don't recreate it, the very first history.add() call fails. How to use readObject() to set a value for a new field when reading old files is covered in the serialVersionUID lesson.
3. Checking invariants in readObject()
During deserialization the constructor of a serializable class is not called: the JVM creates the object and fills its fields with values from the stream. So none of the checks in the constructor run when an object is read, and the stream can be tampered with. Let's check it with a range class whose start must not be greater than its end:
public class Range implements Serializable {
private final int from;
private final int to;
public Range(int from, int to) {
if (from > to) {
throw new IllegalArgumentException("from > to");
}
this.from = from;
this.to = to;
}
@Override
public String toString() {
return "Range[from=" + from + ", to=" + to + "]";
}
} Write new Range(1, 5) to a byte array and change one byte. The last 8 bytes of the stream are two int values: from, then to. The low byte of from is at position length - 5:
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
out.writeObject(new Range(1, 5));
}
byte[] data = bytes.toByteArray();
data[data.length - 5] = 10; // from: 1 -> 10
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data))) {
System.out.println(in.readObject());
} Range[from=10, to=5] An object that the constructor would never allow has been read from the stream without complaint. The fix is to repeat the check in readObject() and throw InvalidObjectException:
@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
if (from > to) {
throw new InvalidObjectException("from > to: " + from + " > " + to);
}
} java.io.InvalidObjectException: from > to: 10 > 5 If the class holds mutable objects (Date, arrays, collections), readObject() should also make defensive copies: a tampered stream can contain a reference to an object that an attacker keeps and modifies later. The rule is simple: readObject() is one more public constructor, and it needs the same protection.
4. readResolve() and the singleton
The readResolve() method is called after the object has been read, and its result replaces the object that was read. The classic use is keeping a singleton unique:
public class Settings implements Serializable {
public static final Settings INSTANCE = new Settings();
private Settings() {
}
}
// Settings.INSTANCE was written and read back
System.out.println(restored == Settings.INSTANCE); false Deserialization created a second instance, bypassing the private constructor. Add readResolve():
@Serial
private Object readResolve() {
return INSTANCE; // the copy that was read will be garbage collected
} true Two remarks. If the singleton has fields with state, declare them transient: otherwise a tampered stream can "steal" a reference to the temporary object before readResolve() runs. And the most reliable singleton is an enum: enums are serialized by constant name, and the JVM guarantees uniqueness without any readResolve().
public enum AppSettings {
INSTANCE;
// singleton fields and methods
} 5. writeReplace() and the Serialization Proxy pattern
The writeReplace() method runs before writing: what goes into the stream is not the object itself but whatever the method returns. This pair of methods is the basis of the Serialization Proxy pattern. The class writes a simple nested proxy class instead of itself, and on reading the proxy's readResolve() creates the real object through the constructor — with all its checks.
public final class Period implements Serializable {
private final int from;
private final int to;
public Period(int from, int to) {
if (from > to) {
throw new IllegalArgumentException("from > to");
}
this.from = from;
this.to = to;
}
@Serial
private Object writeReplace() {
return new Proxy(this); // Proxy is written to the stream, not Period
}
@Serial
private void readObject(ObjectInputStream in) throws InvalidObjectException {
throw new InvalidObjectException("Proxy required"); // a forged Period in the stream is rejected
}
private static class Proxy implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final int from;
private final int to;
Proxy(Period p) {
this.from = p.from;
this.to = p.to;
}
@Serial
private Object readResolve() {
return new Period(from, to); // the object is built through the constructor
}
}
} A normal read returns Period[from=1, to=5]. If you tamper with the stream as in section 3, the constructor check fires during reading: java.lang.IllegalArgumentException: from > to. Note that an exception thrown from readResolve() is not wrapped and reaches the calling code as is.
Pros of the pattern: fields can stay final, invariants are checked in one place, and the internal structure of the class can change without touching the proxy format. Cons: reading is a bit slower, and the pattern does not work for extendable classes or for object graphs with circular references. That is why the class is declared final.
6. The Externalizable interface
Externalizable is a subinterface of Serializable with two mandatory methods: writeExternal(ObjectOutput) and readExternal(ObjectInput). The stream contains only the class name and what you write yourself; the JVM does not save field descriptions or superclass data.
public class Point implements Externalizable {
@Serial
private static final long serialVersionUID = 1L;
private static final int VERSION = 1;
private int x;
private int y;
private int z;
public Point() { // required: public and no-arg
}
public Point(int x, int y, int z) {
this.x = x;
this.y = y;
this.z = z;
}
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeByte(VERSION); // we track the format version ourselves
out.writeInt(x);
out.writeInt(y);
out.writeInt(z);
}
@Override
public void readExternal(ObjectInput in) throws IOException {
int version = in.readByte();
if (version != VERSION) {
throw new InvalidObjectException("Unknown version: " + version);
}
x = in.readInt();
y = in.readInt();
z = in.readInt();
}
} How reading works: the JVM calls the public no-arg constructor and then readExternal(). That leads to these rules and limitations:
- No
publicno-arg constructor —java.io.InvalidClassException: Point; no valid constructor. - Fields cannot be
final: they are assigned after the constructor, inreadExternal(). - Nobody saves the superclass state for you: you have to write and read it in the same methods.
transienthas no effect: only what you write explicitly is saved.readExternal()is a public method. Any code can call it and overwrite the fields of a ready object, so you cannot build an immutable class withExternalizable.
What you gain is compactness and speed. One object with three int fields takes 50 bytes in the stream with Serializable and 42 bytes with Externalizable (including the version byte). The difference is the field description (names and types) that standard serialization writes for every class. In a collection of thousands of objects of the same class, the description is written only once, so the size gain is smaller than it looks; the main advantage is that no reflection is used for reading and writing.
7. Serializable or Externalizable: which to choose
| Criterion | Serializable | Serializable + writeObject/readObject | Externalizable |
|---|---|---|---|
| What is written | All non-static, non-transient fields | Standard fields + your data | Only what you write |
| Constructor on reading | Not called | Not called | public no-arg |
final fields | Allowed | Allowed | Not allowed |
| Superclass fields | Automatic (if the superclass is Serializable) | Automatic | Manual |
| Version compatibility | By serialVersionUID and field names | Same + your code | Fully manual |
| Amount of code | None | Small | Every field by hand |
| When to choose | By default | You need to restore transient fields or check invariants | Format and speed matter more than convenience |
In practice Externalizable is rare: when the data format matters that much, teams usually choose JSON, Protobuf or Kryo instead of built-in Java serialization.
8. Record serialization
Records (Java 16+) are serialized in their own way. The stream contains the component values, and on reading the JVM calls the canonical constructor. So the checks in the constructor also run for data from the stream. The tampering from section 3 does not work on a record:
public record Range(int from, int to) implements Serializable {
public Range {
if (from > to) {
throw new IllegalArgumentException("from > to");
}
}
} java.io.InvalidObjectException: from > to
Caused by: java.lang.IllegalArgumentException: from > to The JVM wraps the constructor exception in an InvalidObjectException, and the original one is available through getCause(). Other specifics:
- The
writeObject(),readObject()andreadObjectNoData()methods of a record are ignored: the JVM does not call them even if they are declared. writeReplace()andreadResolve()work as they do for ordinary classes.- If a record implements
Externalizable, the code compiles, butwriteExternal()andreadExternal()are ignored too: the record is still serialized by its components. - The
serialVersionUIDmatching check is waived for records.
That is why, for data carriers that need to be serialized, a record is the safest form of built-in serialization: checks in one place and no hand-written serialization code.
9. Why built-in serialization is dangerous
Built-in Java serialization is considered one of the main sources of vulnerabilities in Java applications. The reasons:
- The stream decides which classes to create.
readObject()creates objects of any serializable class on the classpath, not only the ones you expect. The cast(Order) in.readObject()happens only after all the objects have been read. - Foreign code runs during reading. The
readObject()andreadResolve()methods of library classes are called automatically. An attacker builds a chain of them (a gadget chain) that ends with a call to arbitrary code. In 2015 such chains built on Apache Commons Collections classes allowed remote code execution on WebLogic, JBoss, Jenkins and other servers. - Denial of service. A small stream can describe a huge array or deeply nested collections: reading it exhausts memory or CPU.
- Constructors are bypassed. As shown in section 3, invariants checked by the constructor do not apply on reading.
The main rule: do not deserialize data from untrusted sources — from users, from the network, from files that someone else could have replaced. Use JSON or Protobuf to exchange data. If you cannot drop built-in serialization (a legacy protocol, RMI, a cache), restrict the list of classes with a filter.
10. ObjectInputFilter: patterns and limits
ObjectInputFilter (Java 9, JEP 290) checks every class and the size of the data before the object is created. A filter is defined as a string of patterns separated by semicolons; patterns are checked from left to right, and the first match decides the class's fate.
| Pattern | Meaning |
|---|---|
com.example.Order | Allow a specific class |
com.example.* | Allow all classes of a package (without subpackages) |
com.example.** | Allow a package and all its subpackages |
java.base/* | Allow all classes of a module |
!com.example.Evil | Reject a class (the ! prefix works with any pattern) |
!* | Reject everything else — goes last |
maxdepth=10 | Maximum nesting depth of objects |
maxrefs=1000 | Maximum number of object references in the stream |
maxbytes=100000 | Maximum stream size in bytes |
maxarray=10000 | Maximum array length |
The safe approach is an allowlist: list the classes you need and end with the !* pattern. Let's try to read an ArrayList of Order objects:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"Order;java.util.ArrayList;!*");
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("orders.ser"))) {
in.setObjectInputFilter(filter);
List<?> orders = (List<?>) in.readObject();
} java.io.InvalidClassException: filter status: REJECTED Surprise: both Order and ArrayList are allowed. The catch is that ArrayList keeps its elements in an internal Object[] array, and for an array the filter checks the element type — java.lang.Object. The working filter is "Order;java.util.ArrayList;java.lang.Object;!*". With it the list is read, and if any other class appears in the stream, reading fails with filter status: REJECTED again. This is typical for allowlists: test your filter on real data.
Limits protect against denial of service. The filter "maxdepth=2;Order;java.util.ArrayList;java.lang.Object;!*" rejects a list of lists of orders: the nesting depth of objects in such a stream is greater than two.
A filter can be set not only for a single stream:
- For the whole JVM — with the system property
-Djdk.serialFilter="Order;java.util.ArrayList;java.lang.Object;!*"or in theconf/security/java.securityfile. No code changes are needed. - A filter factory (Java 17, JEP 415) —
ObjectInputFilter.Config.setSerialFilterFactory()or the-Djdk.serialFilterFactoryproperty: it lets you choose a filter for each stream depending on the context, for example a different one for different libraries.
Frequently asked questions
What is the difference between Serializable and Externalizable?
Serializable is a marker interface: the JVM writes all non-static, non-transient fields itself and does not call the constructor on reading. Externalizable requires you to implement writeExternal() and readExternal(): only what you write is saved, and on reading the public no-arg constructor is called, so the fields cannot be final.
Why are writeObject() and readObject() private if the JVM calls them?
The JVM finds them through reflection by name and signature, so the access modifier does not stop it. private keeps the method from being inherited or overridden: each class in the hierarchy serializes only its own fields. The JVM will not call a method with a different modifier.
How do I protect a singleton from serialization?
Add a readResolve() method that returns the existing instance, and declare fields with state transient. Even more reliable is a singleton implemented as an enum: the JVM itself guarantees that enum constants stay unique on deserialization.
Are writeObject() and readObject() called for a record?
No, the JVM ignores them for records. A record is serialized by its component values and created through the canonical constructor on reading, so the constructor checks also apply to data from the stream. writeReplace() and readResolve() do work for records.
Is ObjectInputFilter enough to safely deserialize data from the network?
A filter with an allowlist and limits greatly reduces the risk but does not remove it: allowed classes can contain vulnerable code too. The most reliable option is not to accept serialized Java objects from untrusted sources and to use JSON or Protobuf.
Practice
Reinforce this lesson with hands-on tasks, each with a level and a time estimate.
All module tasks →
Comments