JAVA / FILES, INPUT/OUTPUT AND SERIALIZATION
Serializing objects and the versioning trap
Serialize and restore Java objects with ObjectOutputStream and ObjectInputStream, and use serialVersionUID so older files still load after a class changes.
What you will learn
- Round-trip an object through ObjectOutputStream and ObjectInputStream
- Pin the class fingerprint with private static final long serialVersionUID
- Predict restored values: transient and newly added fields come back as defaults
- Rebuild caches and invariants in private void readObject, since no constructor runs
Understanding Serializing objects and the versioning trap
ObjectOutputStream.writeObject does not copy your object as a blob; it walks the object graph and, for every class it meets, writes a descriptor - the class's binary name, its serialVersionUID, and the name and type of each non-static, non-transient field - followed by the field values. Reading is the mirror image with one surprise: ObjectInputStream allocates the instance without calling any constructor, then pushes values into fields reflectively, pairing stream fields with class fields by name. The mental model that keeps this straight is a tiny database dump: the descriptor is the schema, the values are the row, and your compiled class is the schema the reader expects to see.
Because the class on the reading side must be able to interpret that row, serialVersionUID acts as the version marker of the schema. If you never declare one, ObjectStreamClass computes it by hashing the class's shape - name, modifiers, interfaces, field signatures, method signatures - so adding a helper method or widening a field from private to public changes the number, and yesterday's files fail with InvalidClassException before a single field is read. Declaring private static final long serialVersionUID = 1L freezes the number and moves the compatibility decision from the compiler to you.
Once the two numbers agree, stream and class are reconciled field by field. A field the class gained is missing from the stream and stays null/0/false; a field the class dropped is skipped; renaming a field is a delete plus an add, so the old value disappears with no error at all; changing a field's declared primitive type is rejected. And since no constructor runs, the defaults and validation you wrote there never execute, so any invariant - a computed cache, a non-null requirement - must be restored in a private readObject that begins with in.defaultReadObject(). Raise the uid only when you genuinely want old data refused, and treat that as a migration rather than a fix.
placeholder
import java.io.*;
public class Main {
public static void main(String[] args) throws Exception {
Account original = new Account("ada", 1200, "9431");
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(buffer)) {
out.writeObject(original);
}
Account restored;
try (ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(buffer.toByteArray()))) {
restored = (Account) in.readObject();
}
System.out.println("restored = " + restored);
System.out.println("same instance = " + (restored == original));
System.out.println("pin after load = " + restored.pin);
System.out.println("constructor calls = " + Account.constructorCalls);
System.out.println("uid used = " + ObjectStreamClass.lookup(Account.class).getSerialVersionUID());
}
}
class Account implements Serializable {
private static final long serialVersionUID = 1L;
static int constructorCalls = 0;
private final String owner;
private final int balance;
transient String pin;
Account(String owner, int balance, String pin) {
constructorCalls++;
this.owner = owner;
this.balance = balance;
this.pin = pin;
}
@Override
public String toString() {
return owner + " has " + balance;
}
}
serialVersionUID is a promise that this class can still interpret the stream's field layout: it gates loading entirely, and once it passes, fields are matched by name rather than by position.
Worked examples
Reading a stream written by an older build
Overwrites the serialVersionUID stored inside the stream to reproduce exactly how a version mismatch fails.
import java.io.*;
public class Main {
public static void main(String[] args) throws Exception {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(buffer)) {
out.writeObject(new Note("ship it"));
}
byte[] data = buffer.toByteArray();
// Layout: 4 header bytes, TC_OBJECT, TC_CLASSDESC, 2-byte name
// length, the class name, then the 8-byte serialVersionUID.
int uidAt = 8 + Note.class.getName().length();
long fakeUid = 99L;
for (int i = 0; i < 8; i++) {
data[uidAt + i] = (byte) (fakeUid >>> (56 - 8 * i));
}
System.out.println("stream now claims uid = " + fakeUid);
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data))) {
System.out.println(in.readObject());
} catch (InvalidClassException e) {
System.out.println("refused class = " + e.classname);
System.out.println("local uid = " + ObjectStreamClass.lookup(Note.class).getSerialVersionUID());
}
}
}
class Note implements Serializable {
private static final long serialVersionUID = 7L;
private final String text;
Note(String text) {
this.text = text;
}
@Override
public String toString() {
return "Note(" + text + ")";
}
}
Example explained
Line 1The eight bytes after the class name are the uid the writing build had, so patching them is equivalent to loading a file produced by a differently compiled Note.
Line 2The uid is compared while the class descriptor is being resolved, before any field data is touched, which is why the text "ship it" is never even parsed.
Line 3InvalidClassException.classname names the local class the stream could not bind to; the toString line inside the try is never reached.
Line 4Declaring serialVersionUID = 7L is what makes the local number predictable - without the declaration it would be derived from Note's shape and would change with any edit.
A transient cache and the readObject that repairs it
Shows a derived field lost by serialization and the hook that rebuilds it from the fields that did survive.
import java.io.*;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("in memory: " + new Naive(3, 4).describe());
System.out.println("naive load: " + roundTrip(new Naive(3, 4)).describe());
System.out.println("fixed load: " + roundTrip(new Fixed(3, 4)).describe());
}
static Totals roundTrip(Totals value) throws Exception {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(buffer)) {
out.writeObject(value);
}
try (ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(buffer.toByteArray()))) {
return (Totals) in.readObject();
}
}
}
interface Totals extends Serializable {
String describe();
}
class Naive implements Totals {
private static final long serialVersionUID = 1L;
private final int a;
private final int b;
private transient int cached;
Naive(int a, int b) {
this.a = a;
this.b = b;
this.cached = a + b;
}
public String describe() {
return "cached=" + cached + " sum=" + (a + b);
}
}
class Fixed implements Totals {
private static final long serialVersionUID = 1L;
private final int a;
private final int b;
private transient int cached;
Fixed(int a, int b) {
this.a = a;
this.b = b;
this.cached = a + b;
}
public String describe() {
return "cached=" + cached + " sum=" + (a + b);
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
cached = a + b;
}
}
Example explained
Line 1cached is transient, so it never reaches the stream; the restored Naive holds int's default 0 and no constructor ever runs to recompute it.
Line 2sum=7 on the same line proves a and b were restored, so only the field you excluded was lost.
Line 3Fixed's readObject calls in.defaultReadObject() first to populate a and b, then rebuilds the cache from them - doing it in the other order would compute 0.
Line 4The hook only works with the exact signature private void readObject(ObjectInputStream): make it public, rename it, or change the parameter and serialization silently ignores it.
One non-serializable field aborts the whole write
Shows NotSerializableException naming the offending field type and the half-written stream it leaves behind.
import java.io.*;
public class Main {
public static void main(String[] args) throws Exception {
Session session = new Session("ada");
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(buffer)) {
out.writeObject(session);
System.out.println("write succeeded");
} catch (NotSerializableException e) {
System.out.println("cannot serialize a field of type " + e.getMessage());
}
System.out.println("bytes already in the buffer = " + (buffer.size() > 0));
System.out.println("object in memory is fine = " + (session.logger != null));
}
}
class Session implements Serializable {
private static final long serialVersionUID = 1L;
private final String user;
final Printer logger = new Printer();
Session(String user) {
this.user = user;
}
}
class Printer {
}
Example explained
Line 1Session is Serializable but the graph is not: writeObject descends into logger, finds a class without the marker interface, and throws.
Line 2The exception message is only the offending class name, so this is how you locate which field in a large graph broke the write.
Line 3The buffer is non-empty because the header and part of the object were already written before the failure - a real file would be left unusable.
Line 4Marking logger transient, or making Printer serializable, is the fix; the in-memory object was never modified by the failed attempt.
Important notes
serialVersionUID only counts when declared in that exact class as static final long; it is not inherited, so a serializable superclass needs its own or its computed uid will still drift between builds.
Never deserialize a stream you did not produce: reading it can instantiate any serializable class on the classpath and run its readObject code. Keep Java serialization for short-lived, same-build data and use an explicit format such as JSON across process, trust or version boundaries.
Common mistakes
Shipping a serializable class with no serialVersionUID, then adding a method or changing a field's access in the next release: the computed uid changes and every previously written file fails with InvalidClassException even though the bytes are intact.
Keeping the uid while renaming a field, say amount to total: the check passes, the old value has nowhere to land, and the object loads silently with 0 or null instead of raising an error you could notice.
Assuming the constructor runs on load, so constructor validation never fires and transient caches stay at 0 or null - the restored object reaches your code in a state it treats as impossible.
Try it yourself
Change, predict, then run
Round-trip a Contact with fields String name, String email and transient String initials through a ByteArrayOutputStream and print initials after loading to see it come back null. Then add private void readObject that calls in.defaultReadObject() and rebuilds initials from name, and re-run to confirm it is filled in.
Open the Java workspaceCheck your understanding
Version 1 of Invoice declares private static final long serialVersionUID = 1L with fields int id and int amount. Version 2 keeps the same uid but renames amount to total. What happens when version 2 reads a file written by version 1?
- It loads: id is restored and total is 0, because the stream contains no field named total
- It throws InvalidClassException, because the set of field names no longer matches
- It loads and total holds the old amount, because fields are matched in declaration order
- It throws NotSerializableException, because the field layout of Invoice changed
Show answer
The uid check compares only 1L against 1L, so the class binds and reading proceeds; after that each stream field is looked up by name, so amount has no destination and is discarded while total, absent from the stream, keeps int's default 0. Option 2 is the tempting one, but nothing is matched positionally - InvalidClassException would only appear if the uids differed or if a field's declared primitive type had changed.