Externalizable and Custom Serialization in Java - Quiz
Total: 5 questions
1. What rules must writeObject() and readObject() follow, and why should you check invariants in readObject()?
What rules must writeObject() and readObject() follow, and why should you check invariants in readObject()?
These methods are not declared in the Serializable interface: ObjectOutputStream and ObjectInputStream find them through reflection by name and signature. So they work only when the rules are followed:
- Exact signature:
private void writeObject(ObjectOutputStream out) throws IOExceptionandprivate void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException. The JVM will not call apublicorstaticmethod and silently falls back to standard serialization. - Standard fields first: the first call is
out.defaultWriteObject()/in.defaultReadObject(), even if all fields are transient: this leaves room for fields of future class versions. - Same order: extra data is read in the same order and with the same types it was written; otherwise you get
OptionalDataException,EOFExceptionor corrupted values. - Each class handles itself: the JVM calls the methods of every serializable class in the hierarchy, from the superclass to the subclass; there is no need (and no way) to call "super.writeObject()" — the method is
private.
Invariants need checking because the constructor is not called during deserialization and the stream can be tampered with: a Range class with a from > to check in the constructor is read from a modified stream as Range[from=10, to=5]. The fix is to repeat the check after defaultReadObject() and throw InvalidObjectException; for mutable fields (Date, arrays, collections), also make defensive copies.
2. How does readResolve() protect a singleton during deserialization, and how is writeReplace() used in the Serialization Proxy pattern?
How does readResolve() protect a singleton during deserialization, and how is writeReplace() used in the Serialization Proxy pattern?
readResolve() is called after the object has been read, and the calling code receives whatever the method returns. Without it, deserializing a singleton creates a second instance, bypassing the private constructor: restored == Settings.INSTANCE gives false. With the method
@Serial
private Object readResolve() {
return INSTANCE;
}
the comparison gives true, and the copy that was read is garbage collected. Singleton fields with state should be transient, otherwise a tampered stream can "steal" a reference to the temporary object before readResolve() runs. The most reliable singleton is an enum: enums are serialized by constant name, and the JVM guarantees uniqueness without readResolve().
writeReplace() runs before writing: the object it returns goes into the stream. In the Serialization Proxy pattern the class writes a simple nested proxy class instead of itself, and the proxy's readResolve() creates the real object through the constructor with all its checks. The class's own readObject() throws InvalidObjectException("Proxy required"), so a forged instance in the stream is rejected. If the proxy is tampered with, the constructor check fires, and the IllegalArgumentException reaches the calling code as is: an exception from readResolve() is not wrapped.
Pros: fields stay final, invariants are checked in one place, the internal structure can change without changing the format. Cons: reading is a bit slower, and the pattern does not work for extendable classes or circular references, so the class is declared final.
3. How does Externalizable work when an object is read, what are its limitations, and when should you choose it over Serializable?
How does Externalizable work when an object is read, what are its limitations, and when should you choose it over Serializable?
Externalizable is a subinterface of Serializable with the 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. On reading, the JVM calls the public no-arg constructor and then readExternal(). That leads to these limitations:
- no
publicno-arg constructor —java.io.InvalidClassException: Point; no valid constructor; - fields cannot be
final: values are assigned after the constructor; - the superclass state has to be written and read by hand in the same methods;
transienthas no effect: only what you write explicitly is saved;readExternal()is public, any code can overwrite the fields of a ready object, so you cannot build an immutable class withExternalizable;- version compatibility is fully manual, for example with a format version byte at the start of the data.
The gain is compactness and speed: an object with three int fields takes 50 bytes with Serializable and 42 bytes with Externalizable (including the version byte). The difference is the field description that standard serialization writes for every class. In a collection of thousands of objects of one class it is written only once, so the main advantage is that no reflection is used for reading and writing.
Choose Serializable by default; Serializable with writeObject()/readObject() when you need to restore transient fields or check invariants; Externalizable when format and speed matter more than convenience. In practice, teams in that situation usually pick JSON, Protobuf or Kryo instead.
4. How is record serialization different from serialization of an ordinary class, and which serialization methods work for a record?
How is record serialization different from serialization of an ordinary class, and which serialization methods work for a record?
A record (Java 16+) writes its component values to the stream, and on reading the JVM calls the canonical constructor instead of filling the fields behind its back. So the constructor checks also run for data from the stream: a tampered stream for
public record Range(int from, int to) implements Serializable {
public Range {
if (from > to) {
throw new IllegalArgumentException("from > to");
}
}
}
gives java.io.InvalidObjectException: from > to, and the original IllegalArgumentException is available through getCause(). For an ordinary class the same stream would be read as Range[from=10, to=5].
writeObject(),readObject()andreadObjectNoData()are ignored for a record, even if they are declared.- If a record implements
Externalizable, the code compiles, butwriteExternal()andreadExternal()are ignored too: the record is still serialized by its components. writeReplace()andreadResolve()work as they do for ordinary classes.
Bottom line: for data carriers, a record is the safest form of built-in serialization: checks in one place and no hand-written serialization code.
5. Why is built-in Java serialization dangerous, and how do you configure ObjectInputFilter: patterns, limits, a JVM-wide filter?
Why is built-in Java serialization dangerous, and how do you configure ObjectInputFilter: patterns, limits, a JVM-wide filter?
The reasons:
- The stream decides which classes to create:
readObject()creates objects of any serializable class on the classpath, and the cast(Order) in.readObject()happens only after reading. - Foreign code runs during reading:
readObject()andreadResolve()of library classes are called automatically, and attackers chain them (a gadget chain) into arbitrary code execution. - Denial of service: a small stream describes a huge array or deeply nested collections.
The main rule is not to deserialize untrusted data and to use JSON or Protobuf for data exchange. If you cannot drop built-in serialization, ObjectInputFilter checks classes and sizes before the object is created. Patterns separated by ; are checked from left to right, and the first match decides:
com.example.*— a package without subpackages;com.example.**— with subpackages;java.base/*— a module;!before any pattern rejects;!*at the end rejects everything else (an allowlist);- the limits
maxdepth,maxrefs,maxbytes,maxarrayprotect against denial of service.
The trap: the filter "Order;java.util.ArrayList;!*" rejects a list of orders (filter status: REJECTED), because for the internal Object[] array the element type is checked; the working version is "Order;java.util.ArrayList;java.lang.Object;!*". For the whole JVM a filter is set with the -Djdk.serialFilter property. A filter reduces the risk but does not remove it: allowed classes can be vulnerable too.