Why this matters
- A missing
serialVersionUIDis the classic cause of a deployment that fails to read yesterday's cached data. - Deserialising untrusted bytes is one of the most exploited vulnerability classes in Java history, and knowing why is a genuine security skill.
- Even though JSON has replaced it for most purposes, Java serialization still appears in session replication, caches and older RMI-based systems.
The basic mechanism
Implement the marker interface Serializable and the JVM handles the rest.
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
private final String userId;
private final Instant createdAt;
private transient String authToken; // excluded from the bytes
private static String appName; // static: never serialized
public Session(String userId, Instant createdAt, String authToken) {
this.userId = userId;
this.createdAt = createdAt;
this.authToken = authToken;
}
}
// Writing
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(path))) {
out.writeObject(session);
}
// Reading
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(path))) {
Session restored = (Session) in.readObject();
System.out.println(restored.authToken); // null — it was transient
}
What is and is not written
- Instance fields are written, including private ones, recursively for referenced objects.
transientfields are skipped and come back as the type's default —null,0,false.staticfields belong to the class, not the object, so they are never part of the stream.- Every referenced object must also be
Serializable, or writing fails withNotSerializableException. - The object graph is preserved. If two fields point at the same object, deserialisation restores one shared object, not two copies.
serialVersionUID
This long is the class's structural fingerprint. It is written into the stream and checked on read.
private static final long serialVersionUID = 1L;
If you omit it the compiler computes one from the class's fields, methods and modifiers. That sounds
convenient and is a trap: adding a single field changes the computed value, and every previously
written stream becomes unreadable with InvalidClassException.
How changes behave when you declare it explicitly
- Adding a field is compatible. Old streams deserialise with the new field left at its default.
- Removing a field is compatible. The value in the stream is discarded.
- Renaming a field is not compatible in effect — it reads as a removal plus an addition, so the data is silently lost.
- Changing a field's type is incompatible and throws.
- Changing the class hierarchy is incompatible.
Controlling the process
Two hooks let you customise without replacing the whole mechanism.
public class Account implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private transient char[] password; // never written in plain form
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // the normal non-transient fields
out.writeObject(encrypt(password)); // plus our own handling
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.password = decrypt((byte[]) in.readObject());
validate(); // the validation the constructor would have done
}
private void validate() {
if (id == null || id.isBlank()) {
throw new InvalidObjectException("id is required");
}
}
}
These methods are private and the JVM finds them reflectively. readObject is the right place to
restore transient state and to run the validation that the skipped constructor would have
performed.
A stricter option for small immutable classes is readResolve, which lets you replace the
deserialised instance entirely:
private Object readResolve() {
return new Account(id, password); // route through the real constructor
}
This also preserves the singleton property, which plain deserialisation otherwise breaks by producing a second instance.
Externalizable
Externalizable hands you complete control and complete responsibility.
| Aspect | Serializable | Externalizable |
|---|---|---|
| Methods to implement | None — it is a marker | writeExternal and readExternal |
| What gets written | Automatic, all non-transient fields | Exactly what you write |
| Superclass fields | Handled automatically | Your responsibility |
| Constructor on read | Not called | Public no-arg constructor is required and called |
| Stream size | Larger — includes metadata | Smaller, if you are careful |
| Worth it when | Almost always | Only for a measured, hot serialisation path |
Methods to implement
SerializableNone — it is a markerExternalizablewriteExternal and readExternalWhat gets written
SerializableAutomatic, all non-transient fieldsExternalizableExactly what you writeSuperclass fields
SerializableHandled automaticallyExternalizableYour responsibilityConstructor on read
SerializableNot calledExternalizablePublic no-arg constructor is required and calledStream size
SerializableLarger — includes metadataExternalizableSmaller, if you are carefulWorth it when
SerializableAlmost alwaysExternalizableOnly for a measured, hot serialisation path
Externalizable trades automation for size and speed, and makes versioning entirely manual.
The security problem
readObject can construct arbitrary objects and run code paths during deserialisation. If an
attacker controls the bytes, they can assemble a chain of objects whose deserialisation side effects
execute commands — a deserialisation gadget chain. Several widely used libraries have shipped
classes usable in such chains.
Practical defences
- Never deserialise data from an untrusted source. This is the only complete defence.
- Use a serialization filter when you must. Java 9 added
ObjectInputFilter, and-Djdk.serialFilterapplies one JVM-wide. - Prefer a data format over a code format: JSON, Protocol Buffers or Avro describe data without the ability to instantiate arbitrary classes.
- Validate inside
readObjectand throwInvalidObjectExceptionon anything unexpected.
// Allow only your own classes, with bounded depth, and reject everything else
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.prepzone.model.*;java.base/*;!*");
ObjectInputStream in = new ObjectInputStream(source);
in.setObjectInputFilter(filter);
Common misreadings
- "
transientfields are encrypted." They are simply absent, and return asnullor0. - "
staticfields are serialized." They belong to the class and are never written. - "Deserialisation calls the constructor." It does not, which is why validation must be repeated in
readObject. - "
serialVersionUIDis optional." Technically yes, practically no. Omitting it means the compiler changes your compatibility contract whenever the class is edited. - "Serialization is a good deep-copy tool." It works but is slow, needs everything
Serializable, and silently dropstransientfields. - "Serialization preserves singletons." It creates a second instance unless you add
readResolve.
Quick recall
Everything you need if you only revisit this box.
Serializableis a marker; the JVM writes all non-transient, non-staticinstance fields and preserves shared references.- Deserialisation skips the constructor, so validation belongs in
readObject— or route through a real constructor withreadResolve. - Declare
serialVersionUIDexplicitly on every class. Adding and removing fields stays compatible; renaming or retyping does not. writeObjectandreadObjectare private hooks the JVM finds reflectively; calldefaultWriteObject/defaultReadObjectfirst.Externalizablegives full control and requires a public no-arg constructor, but makes versioning manual.- Never deserialise untrusted bytes. Use
ObjectInputFilterif you must, and prefer JSON or a schema format across trust boundaries.
Test yourself
Answer these before moving on — recall is what makes it stick.