Skip to content
academia.sh

Lesson 13 / 15

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.

Contents

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.

// 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.
// 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.
// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close