---
title: Serialization
source: 'https://academia.sh/en/courses/java-standard-library/serialization'
course: 'The Standard Library and Streams'
language: en
updated: '2026-08-17T18:09:43+00:00'
license: 'CC BY-SA 4.0'
---

# Serialization

Serialization is measured as never running the constructor, and as a consequence the invariants a constructor sets up can be skipped by a byte stream that arrived from outside. It shows how a readback hook brings the check back, and how a field name together with a version ID turns into a contract.

The previous lesson measured whether a file exists and which bytes it carries — but it never
asked what happens when an object itself is turned into bytes. This lesson looks at
serialization. The Object-Oriented Java course (M08/K02, `java-classes/01`, `02`) built up the
constructor chain and class invariants: an object can come into existence only by passing
through its constructor, and the constructor is where an invariant gets enforced.
Serialization stands **outside** this rule — turning an object into bytes and reading it back
is the standard library's way of producing an object without ever calling a constructor.

The course's question gets asked again here: does an invariant the constructor set up still
hold after a readback? A single class is enough to find the answer, because what is being
compared is not two different implementations but the same class coming into existence
through two different paths — through the constructor, and through serialization.

Three sections proceed in order. The first shows that the constructor is skipped and counts
the cost. The second shows how that cost can be recovered. The third goes beyond the
invariants and measures how the class's own **shape** — its field names and its version ID —
turns into a contract at readback.

This is one of the sources K03 calls "the caller's obligation," but in an unusual form: here
the caller is not the party that breaks the invariant, it is the party **obligated to recover
it**. The library made no promise and broke none; writing the line that fills the gap was
always up to whoever designed the class.

## The Constructor Never Runs

- **IO17** — The `Account` class enforces two invariants in its constructor: the owner name
  cannot be blank, the balance cannot be negative. Both are checked with a single `if` line
  each, inside the constructor.
- **IO18** — In hand is a byte stream produced without ever calling the constructor: an
  `Account` record with a blank owner and a negative balance. What matters in this lesson is
  not **how** that byte stream was produced but what happens when it is read back. The risk is
  measured by counting how many of the invariants the constructor set up still hold after the
  readback.

**Every** class that implements `Serializable` can be the subject of this measurement; the
interface requires no methods, it is only a marker. `Account` is not a special example picked
for this lesson, it is a single instance of a general behavior the library applies to any
serializable class. The measurement below demonstrates that behavior with a byte stream
already in hand; whether that stream came from a file, a network connection, or an earlier run
of the program itself makes no difference to the measurement — no matter where it came from,
the readback follows the same path.

```java
// ConstructorSkipped.java — serialization never runs the constructor, the invariants it sets up are not preserved
import java.io.*;
import java.util.Base64;

class Account implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String owner;
    private final long balance;

    Account(String owner, long balance) {
        if (owner == null || owner.isBlank()) throw new IllegalArgumentException("empty owner");
        if (balance < 0) throw new IllegalArgumentException("negative balance");
        this.owner = owner;
        this.balance = balance;
    }

    String owner() { return owner; }
    long balance() { return balance; }
}

public class ConstructorSkipped {
    // A byte stream produced without ever running the constructor: blank owner, negative balance.
    static final String BROKEN_RECORD =
            "rO0ABXNyAAdBY2NvdW50AAAAAAAAAAECAAJKAAdiYWxhbmNlTAAFb3duZXJ0ABJMamF2YS9sYW5nL1N0cmluZzt4cP/////////OdAAA";

    static int validInvariants(Account a) {
        int count = 0;
        if (a.owner() != null && !a.owner().isBlank()) count++;
        if (a.balance() >= 0) count++;
        return count;
    }

    public static void main(String[] args) throws Exception {
        int constructorBlocked = 0;
        try {
            new Account("", -50L);
        } catch (IllegalArgumentException e) {
            constructorBlocked = 1;
        }
        System.out.println("does the constructor enforce both invariants: " + (constructorBlocked == 1));

        byte[] bytes = Base64.getDecoder().decode(BROKEN_RECORD);
        Account readBack;
        try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            readBack = (Account) i.readObject();
        }
        System.out.println("does the readback succeed without ever running the constructor: true");
        System.out.printf("of the two invariants the constructor sets up, how many still hold: %d / 2%n",
                validInvariants(readBack));
    }
}
```

```
does the constructor enforce both invariants: true
does the readback succeed without ever running the constructor: true
of the two invariants the constructor sets up, how many still hold: 0 / 2
```

The first line confirms that a broken call going directly through the constructor is
rejected: the check runs there. The second line shows that the same class can read back a
record carrying **the same brokenness** — no exception falls, `readObject` completes without
issue. The third line counts the cost of that: of the two invariants the constructor set up,
**zero** still hold after the readback.

The reason is how `ObjectInputStream` works. The default readback writes the values from the
stream **directly** into the class's fields; in doing so it calls no constructor, passes
through no `if` line. Even `final` fields can be written this way, because serialization is
the language's own mechanism and it does not enter through the constructor's door — it builds
the object directly in memory. This is the **only** exception to the rule K02 established, "an
object can come into existence only by passing through its constructor" — and the exception
is not a weakness in the rule, it is the very **definition** of serialization: the readback's
job is not to call a constructor, it is to reconstruct an object exactly as it was.

This path is not entirely closed off either: if `Account` did not implement `Serializable` at
all, the `writeObject` call itself would fall with `NotSerializableException` and the readback
question would never come up. Declaring the interface is the decision that **opens** the class
to this second path — and that decision belongs to whoever designed the class, it is not
something serialization imposes.

K02's encapsulation lesson established that making a field `private` makes it modifiable only
through the class's own methods. Serialization does not tear this wall down, but it **goes
around it**: there is still no `private` setter, no ordinary call can assign directly into the
`balance` field from outside — but a readback is not an ordinary call, it is the virtual
machine's own mechanism and it never trips over the access modifier. Encapsulation restricts
**method calls** coming from outside; serialization is not a method call, it is a
reconstruction that belongs to the language itself.

## A Readback Hook Brings the Check Back

`ObjectInputStream`'s documentation defines an escape route: if a class defines a method whose
signature is exactly `private void readObject(ObjectInputStream)`, the readback drops the
default field-filling behavior and calls this method instead. The signature is a contract that
must be followed to the letter — if the method becomes `public`, if the parameter type
changes, or if the name is spelled differently, the readback never sees it and silently falls
back to the default behavior.

- **IO19** — A private method named `readObject` is added to `Account`. This method first
  fills the fields as usual with `defaultReadObject`, then repeats the **same two** checks
  that are in the constructor.

```java
// ReadbackHook.java — once a readObject hook is written, both invariants are checked again on readback
import java.io.*;
import java.util.Base64;

class Account implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String owner;
    private final long balance;

    Account(String owner, long balance) {
        if (owner == null || owner.isBlank()) throw new IllegalArgumentException("empty owner");
        if (balance < 0) throw new IllegalArgumentException("negative balance");
        this.owner = owner;
        this.balance = balance;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        if (owner == null || owner.isBlank()) throw new InvalidObjectException("empty owner");
        if (balance < 0) throw new InvalidObjectException("negative balance: " + balance);
    }
}

public class ReadbackHook {
    static final String BROKEN_RECORD =
            "rO0ABXNyAAdBY2NvdW50AAAAAAAAAAECAAJKAAdiYWxhbmNlTAAFb3duZXJ0ABJMamF2YS9sYW5nL1N0cmluZzt4cP/////////OdAAA";

    public static void main(String[] args) throws Exception {
        byte[] bytes = Base64.getDecoder().decode(BROKEN_RECORD);
        String result;
        try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            i.readObject();
            result = "opened";
        } catch (InvalidObjectException e) {
            result = "rejected: " + e.getMessage();
        }
        System.out.println("same broken record after the hook is written: " + result);
    }
}
```

```
same broken record after the hook is written: rejected: empty owner
```

The **exact same** byte stream that was read in the previous section is rejected this time.
The only thing that changed is a two-line method added to the class: the call to
`defaultReadObject` fills the fields as usual, then the two `if` lines that follow set up the
**exact same** check as the one in the constructor. `ObjectInputStream` calls a method named
`readObject` instead of the default behavior whenever it finds one on a class; if the method
throws an exception, the readback is left unfinished too and the object is never produced.

What this measurement says is that serialization's promise was **not incomplete**. The promise
was always "I reconstruct an object exactly as it was, I fill its fields directly," and there
was never any invariant checking in that promise — because that check was the constructor's
job, not serialization's. What was needed to bring the check back was not a fix from the
library but the class writing its **own** `readObject`; writing this line is still, as always,
up to the caller — that is, whoever designed the class.

The course's third claim is confirmed here too: none of this gap is caught by the compiler.
Both the hookless `Account` and the hooked `Account` compile with the same lack of complaint;
the compiler never checks whether a class has a `readObject`, or whether the one it has
repeats the checks that are in the constructor. `Serializable` is a marker interface — it
carries no abstract method — and so there is nothing that can be enforced at compile time;
every check is left to the optional body of an optional method.

## A Field Name and a Version ID Are a Contract

- **IO20** — In hand is an old `Account` record whose version ID is `42`; at that time the
  class's field was named `balance`. In the current class the same field has been renamed to
  `amount`, but the version ID is still `42`.
- **IO21** — The same old record is read this time against a class definition whose version ID
  is `43` — measuring what happens if the version ID had changed too.

```java
// FieldNameContract.java — the field name and the serialization version ID turn into a contract
import java.io.*;
import java.util.Base64;

class Account implements Serializable {
    private static final long serialVersionUID = 42L;
    private final String owner;
    private final long amount;
    Account(String owner, long amount) {
        if (amount < 0) throw new IllegalArgumentException("negative amount");
        this.owner = owner;
        this.amount = amount;
    }
    long amount() { return amount; }
    String owner() { return owner; }
}

public class FieldNameContract {
    // In the old version the same class carried a field named "balance", not "amount";
    // the serialization version ID (42L) has not been changed since then.
    static final String OLD_RECORD =
            "rO0ABXNyAAdBY2NvdW50AAAAAAAAACoCAAJKAAdiYWxhbmNlTAAFb3duZXJ0ABJMamF2YS9sYW5nL1N0cmluZzt4cAAAAAAAAABkdAADYW15";

    public static void main(String[] args) throws Exception {
        byte[] bytes = Base64.getDecoder().decode(OLD_RECORD);
        Account h;
        try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            h = (Account) i.readObject();
        }
        System.out.println("passed (no exception): true");
        System.out.println("owner field (name unchanged): " + h.owner());
        System.out.println("amount field (used to be balance): " + h.amount());
    }
}
```

```
passed (no exception): true
owner field (name unchanged): amy
amount field (used to be balance): 0
```

The readback raises no objection at all. The `owner` field fills without issue because its
name did not change: `amy`. But the `amount` field **never sees** the value the record in the
stream carried under the name `balance` — it is filled with the `long` type's default value,
zero. `ObjectInputStream` maps every field in the stream by its name; if the stream has no
field named `amount`, the class's `amount` is treated as never touched at all. Because the
version ID stayed the same, no warning appears either: as far as the library is concerned,
this is a valid readback.

```java
// VersionIdMismatch.java — the same byte stream is rejected once the version ID changes
import java.io.*;
import java.util.Base64;

class Account implements Serializable {
    private static final long serialVersionUID = 43L;
    private final String owner;
    private final long amount;
    Account(String owner, long amount) {
        if (amount < 0) throw new IllegalArgumentException("negative amount");
        this.owner = owner;
        this.amount = amount;
    }
}

public class VersionIdMismatch {
    static final String OLD_RECORD =
            "rO0ABXNyAAdBY2NvdW50AAAAAAAAACoCAAJKAAdiYWxhbmNlTAAFb3duZXJ0ABJMamF2YS9sYW5nL1N0cmluZzt4cAAAAAAAAABkdAADYW15";

    public static void main(String[] args) throws Exception {
        byte[] bytes = Base64.getDecoder().decode(OLD_RECORD);
        String result;
        try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            i.readObject();
            result = "passed";
        } catch (InvalidClassException e) {
            result = "rejected: " + e.getClass().getSimpleName();
        }
        System.out.println("same old record with version ID 43: " + result);
    }
}
```

```
same old record with version ID 43: rejected: InvalidClassException
```

This time the same old record does not even get read that deep: because the version ID does
not match, the readback is rejected without ever looking at the fields. Placing the two
measurements side by side produces an inverted outcome. **Changing** the version ID stops an
incompatible record loudly. **Keeping** the version ID fixed — behaving as if the class never
changed at all — silently produces a wrong value once the shape has actually changed.
The serialization version ID is therefore not a version number, it is a single-bit answer to
the question "does this class definition recognize that stream"; the field names themselves
are part of that answer too, because the binding is made by name.

If the version ID had never been written at all, the outcome would not change, because in that
case the compiler computes the ID itself by looking at the class's current shape; two IDs
computed from two different shapes come out different almost every time, and the result would
again be `InvalidClassException`. Writing the version ID by hand and keeping it fixed is a
deliberate way of turning this automatic protection off — it is necessary if backward
compatibility is wanted, but wanting it also requires thinking through what **every** field
change will do to the readback. The same logic applies to an added field as well: a new field
that is not in the stream at all silently receives its own default value, exactly like
`amount` did.

## Summary

- Serialization never runs the constructor; `ObjectInputStream` fills the fields directly, and
  even `final` fields can be written this way.
- Neither of the two invariants the constructor set up is preserved automatically on readback:
  a broken record already in hand reads back without raising any exception.
- Once a class-specific `readObject` method is added and the checks from the constructor are
  repeated, the same broken record is rejected; the check comes back because the caller wrote
  it.
- A field name is the binding key on readback: a field in the stream that is not in the class
  is ignored, and a field in the class that is not in the stream is silently filled with its
  default value.
- When the serialization version ID is kept fixed, a change in shape passes through silently;
  when the version ID is changed, the same mismatch is rejected loudly.
- `Serializable` is a marker interface and carries no abstract method; because of this, the
  compiler never checks whether a class also preserves its invariants on readback — the check
  depends entirely on the body of an optional `readObject`.

## Next Step

Everything measured in this lesson was an in-memory, fixed byte stream — time never entered
the picture. But one of the most common kinds of data a system carries is time itself. The
next lesson looks at the date and time API: what information an instant, a local date-time,
and a zoned date-time each carry and each fail to carry, and how that difference can silently
lead to a wrong result when two values are compared.
