Java Serialization and Deserialization: Serializable, transient and serialVersionUID (1Z0-831)
Java serialization converts an object, and every object it references, into a stream of bytes with ObjectOutputStream, and deserialization rebuilds an equivalent object graph from those bytes with ObjectInputStream. Oracle 1Z0-831 objective 9.2 asks you to serialize and de-serialize Java objects, which in practice means predicting exactly what a round trip preserves, what it resets and what it throws. This lesson covers the Serializable requirement, the object-stream API and its exceptions, transient and static fields, which constructors run on reading, serialVersionUID and class evolution, the writeObject/readObject and readResolve/writeReplace hooks, how records, enums and Externalizable classes differ, shared references in object graphs, and deserialization filters. Byte and character streams belong to objective 9.1, and Path and Files to 9.3.
On this page10 sections
- What serialization is, and which objects can be serialized
- Writing and reading with ObjectOutputStream and ObjectInputStream
- What is not saved: transient and static fields
- Which constructors run when an object is deserialized
- serialVersionUID and evolving a serializable class
- Customizing the stream with private writeObject and readObject
- Replacing objects with readResolve and writeReplace
- Records, enums and Externalizable: three special cases
- Object graphs, cycles and shared references
- Deserialization filters: reading untrusted data safely
- Decide whether an object graph can be serialized, and predict where NotSerializableException is thrown.
- Write and read objects with ObjectOutputStream and ObjectInputStream, handling ClassNotFoundException, EOFException and primitive data in order.
- Predict the values of transient, static and newly added fields after deserialization.
- Identify which constructors and initializers run when ordinary classes, records, enums and Externalizable classes are read.
- Declare serialVersionUID correctly and tell compatible class changes from incompatible ones.
- Customize serialization with private writeObject/readObject and replace objects with readResolve/writeReplace.
- Explain back-references, reset() and writeUnshared(), and restrict deserialization with ObjectInputFilter patterns.
What serialization is, and which objects can be serialized
An object can be serialized only if its class implements java.io.Serializable (directly or through a superclass) and every object reachable through its non-transient instance fields is serializable too. Serializable is a marker interface with no methods: it is permission, not behaviour. Serialization (ObjectOutputStream.writeObject) turns that object graph into bytes; deserialization (ObjectInputStream.readObject) builds a new, equivalent graph from those bytes.
The check happens at run time, not at compile time. writeObject takes an Object, so passing a non-serializable object compiles and then throws java.io.NotSerializableException (naming the offending class) when the stream reaches it. Output printed before that call has already happened.
- Only actual values matter. A field whose declared type is not serializable is fine while it holds
null; the exception appears only when it refers to a real object. - Containers are checked element by element.
ArrayList,HashMapand the immutableList.of/Map.ofcollections are serializable, but one non-serializable element (such as a plainnew Object()) makes the whole write fail. Arrays are serializable when their elements are. - Common JDK types:
String, the primitive wrappers,StringBuilder, thejava.timetypes and enums are serializable;Object,ThreadandOptionalare not (Optionalis designed as a return type, not a field). - Inheritance: once a superclass implements
Serializable, every subclass is serializable and all of its fields (the subclass's and the superclass's) are saved.
The examples in this lesson use a small helper that writes one object to a byte array and reads it straight back:
static Object roundTrip(Object o) throws Exception {
var buf = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(buf)) {
out.writeObject(o);
}
var in = new ObjectInputStream(
new ByteArrayInputStream(buf.toByteArray()));
return in.readObject();
}Writing and reading with ObjectOutputStream and ObjectInputStream
You serialize by wrapping any OutputStream (a FileOutputStream, a ByteArrayOutputStream) in an ObjectOutputStream and calling writeObject; you deserialize by wrapping the matching InputStream in an ObjectInputStream and calling readObject. The byte and file streams themselves are covered in the console and file I/O lesson; here they are only the transport.
import java.io.*;
class Customer implements Serializable {
private static final long serialVersionUID = 1L;
String name;
transient String password = "secret";
static int created = 0;
Customer(String name) { this.name = name; created++; }
}
public class Main {
public static void main(String[] args) throws Exception {
var buf = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(buf)) {
out.writeObject(new Customer("Ana"));
}
var bytes = new ByteArrayInputStream(buf.toByteArray());
try (var in = new ObjectInputStream(bytes)) {
Customer c = (Customer) in.readObject();
System.out.println(c.name + " " + c.password
+ " " + Customer.created);
}
}
}This prints Ana null 1: the name is restored, the transient password comes back null, and the static counter simply shows the class's current value (the next section explains both). The API rules that matter:
readObjectreturnsObject, so you cast to the type you expect. A wrong cast is an ordinaryClassCastException.- Two checked exceptions.
readObjectdeclaresIOExceptionandClassNotFoundException(the stream may name a class the reader does not have). Code that calls it must catch or declare both.ClassNotFoundExceptionis not anIOException, so noIOExceptiontype in acatchorthrowsclause, however specific or broad, covers it.writeObjectdeclares onlyIOException. - End of stream is an exception, not
null. Reading past the last object throwsEOFException;readObjectreturnsnullonly when anullwas written. A loop that reads untilEOFExceptionis a valid (if blunt) way to read an unknown number of objects; writing a count first, or a single collection, is cleaner. - Read in the order you wrote, with the matching method.
ObjectOutputStreamalso haswriteInt,writeUTFand the otherDataOutputmethods. Primitive data and objects are not interchangeable: callingreadObjectwhen the next item is primitive data throwsOptionalDataException, and reading primitives in the wrong order can silently give wrong values (twoints read in reverse simply swap), because the stream matches by position, not by name. A mismatched mix, such asreadUTFwhere anintwas written, can instead throw anIOExceptionsuch asEOFExceptionorUTFDataFormatException.
| Exception | Typical cause |
|---|---|
NotSerializableException | Writing (or reaching) an object whose class is not Serializable |
InvalidClassException | Reading: serialVersionUID mismatch, no accessible no-arg constructor where one is required, or a filter rejection |
ClassNotFoundException | Reading: the class named in the stream is not on the reader's class path (checked, not an IOException) |
EOFException | Reading past the end of the stream |
OptionalDataException | readObject called where primitive data comes next |
What is not saved: transient and static fields
Default serialization saves an object's non-static, non-transient instance fields and nothing else. Marking a field transient leaves it out of the stream; marking it static makes it part of the class, not the object. Other modifiers make no difference: a final (non-transient) instance field compiles in a Serializable class and is saved and restored like any other field.
- Transient fields come back as defaults —
0,falseornull— because deserialization does not run the field initializer (= 1234,= new ArrayList<>()) or the constructor. A transient collection is thereforenullafter reading, and the first call that uses it throwsNullPointerExceptionunless you rebuild it (typically in a privatereadObject). - One exception to "defaults": a
transient finalfield of primitive orStringtype initialized with a compile-time constant is a constant variable; the compiler inlines its value at every use, so it still reads as that constant. Only primitive andStringfields with a constant initializer qualify; every othertransient finalfield (of a reference type, or with an initializer that is not a constant expression) is not inlined and reads as its default after a round trip. - Static fields are never written or restored. After reading, a static field shows whatever value the class holds in the reading JVM right now — changing it between the write and the read changes what you see. Because statics are never written, their type does not need to be serializable either.
Use transient for values that must not leave the process (passwords, tokens), that cannot be serialized (threads, sockets, locks) or that are cheap to recompute (caches).
Which constructors run when an object is deserialized
For an ordinary Serializable class, no constructor, instance initializer or field initializer of any serializable class in the hierarchy runs during deserialization; the fields are filled straight from the stream. The one exception is the first non-serializable superclass (often just Object): its no-arg constructor runs, together with its own field initializers, and its fields end up with the values that constructor sets, because they were never written.
class Animal {
String sound = "none";
Animal() { System.out.print("Animal() "); }
}
class Cat extends Animal implements Serializable {
String name = "unnamed";
Cat(String name) {
this.name = name;
this.sound = "meow";
System.out.print("Cat() ");
}
}Creating new Cat("Tom") prints Animal() Cat(). Round-tripping it prints only Animal(), and the copy shows Tom none: name comes from the stream (the "unnamed" initializer never runs), while sound belongs to the non-serializable Animal, so it is reset by Animal's initializer rather than restored to "meow".
That no-arg constructor must be accessible to the first serializable subclass. If the non-serializable superclass has only constructors with parameters (or a private one), the code still compiles and writing still works, but reading throws InvalidClassException ("no valid constructor"). Fix it by giving the superclass an accessible no-arg constructor or by making the superclass serializable.
| Kind of class | What runs when it is read |
|---|---|
Ordinary Serializable class | Only the no-arg constructor of the first non-serializable superclass; fields come from the stream |
| Record | The canonical constructor, with the stream's component values (so its validation runs) |
Externalizable class | The public no-arg constructor (with field initializers), then readExternal |
| Enum | Nothing: the existing constant is looked up by name |
serialVersionUID and evolving a serializable class
serialVersionUID is the version number written with every serialized class; when reading, the class in the JVM must have the same value or readObject throws InvalidClassException ("local class incompatible"). Declare it explicitly so that compatible changes keep old data readable:
private static final long serialVersionUID = 1L;To be recognised it must be named exactly serialVersionUID and be static, final and of primitive type long. The access modifier is free (private is recommended because it is never useful to subclasses). Only a field matching all of those rules counts; any other declaration is just an ordinary field, and the JVM computes a default UID from the class's structure instead. That computed value changes with almost any edit (adding a field or method) and can differ between compilers, which is why relying on it breaks old files.
With matching UIDs, serialization tolerates compatible changes:
- Added field: not in the old stream, so it gets its default value (
null,0) — its initializer does not run. - Removed field: its value in the stream is read and discarded.
- Changing access modifiers, or adding
writeObject/readObjecthooks, is also compatible.
Incompatible changes still fail even with matching UIDs — for example changing a primitive field's type, or changing a field between a primitive and a reference type (both throw InvalidClassException, "incompatible types for field"); changing one reference type to an incompatible one (String to Integer) instead fails with ClassCastException when the value is assigned; or switching between Serializable and Externalizable. Records and enums have their own rules: a record's UID defaults to 0L and its value is not checked on reading, and an enum's UID is always 0L.
Annotate serialization members with @java.io.Serial (Java 14+). It changes nothing at run time, but lets the compiler (with -Xlint:serial) warn about a misdeclared serialVersionUID or hook method.
Customizing the stream with private writeObject and readObject
A Serializable class takes control of its own state by declaring two methods with these exact signatures; serialization finds them by reflection and calls them instead of the default mechanism for that class:
private void writeObject(ObjectOutputStream out)
throws IOException
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundExceptionThey must be private and non-static. A public or static method with the same name compiles but is silently ignored, so default serialization runs and any transient field the method meant to save comes back as its default.
- Call
out.defaultWriteObject()first to write the non-transient fields as usual, then write any extra values.readObjectmirrors it:in.defaultReadObject(), then the extras in the same order and with the matching methods — reading twoints in reverse order swaps them without any error. - What you write does not have to equal the field: you can write a transformed value and rebuild the field from it, or recompute a transient field from the restored ones.
- The specification expects those default calls, but leaving them out is not detected: as long as
writeObjectandreadObjectagree on what they write and read, the program compiles and runs without an exception, and the class's non-transient fields are simply not written, so they come back as their defaults. defaultWriteObjectanddefaultReadObjectmay only be called from inside these hooks; calling one anywhere else throwsNotActiveException.
class Event implements Serializable {
String title;
transient LocalDate cached;
int year;
Event(String title, int year) {
this.title = title;
this.year = year;
this.cached = LocalDate.of(year, 1, 1);
}
@Serial
private void writeObject(ObjectOutputStream out)
throws IOException {
out.defaultWriteObject();
out.writeUTF(title.toUpperCase());
}
@Serial
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
String upper = in.readUTF();
cached = LocalDate.of(year, 1, 1);
System.out.println("read " + upper);
}
}Round-tripping new Event("expo", 2026) prints read EXPO and then expo 2026-01-01: title and year travel through the default mechanism, the extra upper-case string is read back in order, and the transient cached date is recomputed instead of being left null.
Replacing objects with readResolve and writeReplace
writeReplace lets an object nominate a different object to be written in its place, and readResolve lets a freshly deserialized object nominate a different object to be returned in its place. Both are instance methods with return type Object and any access modifier:
ANY-ACCESS-MODIFIER Object writeReplace() throws ObjectStreamException
ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamExceptionSingletons. Deserialization always creates a new object, so a serializable singleton would gain a second instance on every read. A readResolve that returns the canonical instance fixes it, and == then holds:
class Settings implements Serializable {
static final Settings INSTANCE = new Settings();
private Settings() { }
@Serial
private Object readResolve() {
return INSTANCE;
}
}The access modifier is free, but the rest of the signature is not: serialization looks only for an instance method named readResolve, taking no parameters, whose declared return type is exactly Object. Any other declaration is an ordinary method that serialization never calls, and the copy stays a separate object. Only a read-side hook can swap the result: a write-side hook runs on the way out, and whatever it writes is still deserialized into a new object.
Serialization proxies. The two hooks combine into the proxy pattern: writeReplace returns a small serializable proxy (often a record) holding just the logical state, and the proxy's readResolve builds the real object through its normal constructor. The reader never sees the proxy, and the real class's invariants are enforced by its constructor rather than bypassed.
Records, enums and Externalizable: three special cases
Records, enums and Externalizable classes are serialized by fixed rules that differ from ordinary classes, and most of the usual customization hooks are ignored for them.
Records
A record that implements Serializable is written as its component values and is rebuilt by calling its canonical constructor with them. Its compact or canonical constructor therefore runs for every copy — validation and normalization included, so a stream holding invalid values is rejected — and the copy is equals to the original. The process cannot be customized: writeObject, readObject, readObjectNoData, writeExternal, readExternal and serialPersistentFields are ignored for records. writeReplace and readResolve are honoured, which is why records make good serialization proxies.
record Point(int x, int y) implements Serializable {
Point {
if (x < 0) throw new IllegalArgumentException("x < 0");
System.out.print("canonical ");
}
}Round-tripping new Point(1, 2) prints canonical canonical Point[x=1, y=2]: once for new, once for the read. A static field initialized with a record instance runs the constructor once more when the record class is initialized.
Enums
An enum constant is written as its name only and read back with valueOf, so the result is the very same constant (== holds) and the constant's fields are never written — even a field of a non-serializable type does not break serialization, and a field's current value in the reading JVM is what you see. Enum serialization cannot be customized: writeObject, readObject, writeReplace and readResolve declared in an enum are ignored, and its serialVersionUID is always 0L. This is why an enum is the simplest serialization-safe singleton.
Externalizable
java.io.Externalizable extends Serializable and hands the whole format to the class through two public methods. Nothing is saved automatically — not the fields, not the superclass state.
class Version implements Externalizable {
int major = 1;
public Version() { System.out.print("no-arg "); }
Version(int major) { this.major = major; }
@Override
public void writeExternal(ObjectOutput out)
throws IOException {
out.writeInt(major);
}
@Override
public void readExternal(ObjectInput in)
throws IOException {
major = in.readInt();
}
}Round-tripping new Version(3) prints no-arg 3. On reading, serialization first calls the public no-arg constructor (field initializers and constructor body run), then readExternal, which starts from that constructed state. Only a public no-arg constructor counts: without one, reading throws InvalidClassException ("no valid constructor"), and nothing else in the class declaration substitutes for it.
Serializable | Externalizable | |
|---|---|---|
| Methods to implement | None (optional private writeObject/readObject) | public writeExternal(ObjectOutput) and public readExternal(ObjectInput) |
| What is saved | All non-transient, non-static fields of serializable classes, automatically | Only what writeExternal writes |
| Constructor on reading | No-arg constructor of the first non-serializable superclass only | Public no-arg constructor of the class itself (required) |
transient | Excludes a field | Meaningless — nothing is automatic |
Deserialization filters: reading untrusted data safely
A deserialization filter (java.io.ObjectInputFilter) inspects each class, array size and graph limit as the stream is read and can reject it before the object is created. Deserializing untrusted bytes without one is a classic remote-code-execution risk: the stream chooses which classes are instantiated. Oracle's guidance is to avoid deserializing untrusted data at all and, where you must, to filter it with an allow-list.
Create a filter from a pattern string with ObjectInputFilter.Config.createFilter and install it on one stream with setObjectInputFilter, or JVM-wide with the jdk.serialFilter system/security property (or Config.setSerialFilter):
import java.io.*;
class Invoice implements Serializable { int total = 40; }
class Token implements Serializable { long id = 99; }
public class Main {
public static void main(String[] args) throws Exception {
var buf = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(buf)) {
out.writeObject(new Invoice());
out.writeObject(new Token());
}
try (var in = new ObjectInputStream(
new ByteArrayInputStream(buf.toByteArray()))) {
in.setObjectInputFilter(ObjectInputFilter.Config
.createFilter("Inv*;java.base/*;!*"));
var inv = (Invoice) in.readObject();
System.out.println("invoice " + inv.total);
in.readObject();
} catch (InvalidClassException e) {
System.out.println(e.getMessage());
}
}
}This prints invoice 40 and then the exception message filter status: REJECTED: Inv* allows any class whose name starts with Inv, and Token falls through to !*. How a pattern string is evaluated:
- Patterns are separated by
;and checked left to right; the first match decides. So"com.example.*;!*"allows the classes ofcom.exampleand rejects everything else, while"!*;com.example.*"rejects everything, because!*matches first. - A leading
!rejects; no prefix allows.Namematches one class;pkg.*every class in a package;pkg.**a package and its subpackages;Prefix*any class whose name starts with the prefix;module/patternrestricts by module. - A class no pattern matches is undecided, and an undecided class is not rejected — so a pattern list made only of allow patterns blocks nothing it does not name: the unmatched classes are still read. A real allow-list ends with
!*. - Limit patterns cap the graph:
maxdepth=,maxrefs=(class descriptors count as references),maxbytes=andmaxarray=.
A rejection throws InvalidClassException from the readObject call that meets the rejected class; objects read before it are unaffected. The filter must be set before the first readObject and only once per stream; setting it after a read, or a second time, throws IllegalStateException.
Tip. Objective 9.2 is examined through short programs that write an object and read it back, asking what is printed, whether it compiles, or which exception is thrown and when. Expect to trace transient, static and newly added fields to their values after a round trip, to work out which constructors and initializers run for ordinary classes, records, enums and Externalizable classes, and to judge whether serialVersionUID, writeObject/readObject and readResolve declarations are recognised exactly as written. Multiple-answer items ask which declarations, expressions or changes produce a given result, including stream-level behaviour such as back-references, reset() and writeUnshared(), and the left-to-right matching of deserialization filter patterns. Keep the checked exceptions of readObject and the runtime timing of NotSerializableException clear in your mind.
- Serializable is checked at run time: writing an object graph that reaches a non-serializable value throws NotSerializableException; null fields and static fields are never a problem.
- readObject returns Object, declares IOException and ClassNotFoundException, and signals the end of the stream with EOFException, never null.
- transient fields come back as 0, false or null because no initializer or constructor of a serializable class runs; only the first non-serializable superclass's no-arg constructor runs, and it must be accessible.
- Static fields are not saved: after reading they show the class's current value in the reading JVM.
- serialVersionUID must be a static final long with that exact name; with matching UIDs, added fields read as defaults and removed fields are ignored.
- Custom writeObject/readObject must be private and non-static, should call defaultWriteObject/defaultReadObject first (omitting them is not detected, but non-transient fields are then not saved), and must read extra data in write order.
- readResolve (an instance method returning Object) keeps singletons single; writeReplace plus a proxy's readResolve forms the serialization-proxy pattern.
- Records are rebuilt through the canonical constructor and ignore readObject/writeObject; enums serialize by name and stay ==; Externalizable needs a public no-arg constructor and saves only what writeExternal writes.
- Writing the same object twice on one stream writes a back-reference; use reset() or writeUnshared() to write its new state.
- An ObjectInputFilter pattern is matched left to right, first match wins, unmatched classes are undecided, and a rejection throws InvalidClassException; set it before the first read.
Frequently asked questions
What happens to a transient field when a Java object is deserialized?
A transient field is not written to the stream, so after deserialization it holds its type's default value: 0 for numbers, false for boolean and null for references. Its field initializer and the class's constructors do not run for a Serializable class, so an initializer such as = new ArrayList<>() is not applied either. The only exception is a transient final field initialized with a compile-time constant, which the compiler inlines.
Are constructors called during Java deserialization?
For an ordinary Serializable class, no constructor or initializer of any serializable class in the hierarchy runs; only the no-arg constructor of the first non-serializable superclass runs, and it must be accessible or reading throws InvalidClassException. Records are different: they are rebuilt through their canonical constructor. Externalizable classes run their public no-arg constructor and then readExternal.
Why should a serializable class declare serialVersionUID?
Without an explicit serialVersionUID, Java computes one from the class structure, and almost any change to the class (or a different compiler) changes it, so older serialized data fails with InvalidClassException. Declaring private static final long serialVersionUID = 1L keeps the version stable, letting compatible changes such as added or removed fields still read old data; added fields then come back as default values.
Are static fields serialized in Java?
No. Serialization saves an object's instance state only, so static fields are neither written nor restored. After deserialization a static field shows whatever value the class currently holds in the reading JVM, and a static field's type does not need to be serializable.
How do you keep a singleton a singleton when it is serialized?
Declare an instance method Object readResolve() that returns the existing instance; deserialization then discards the newly created copy and returns the canonical object, so == holds. The method may have any access modifier but must not be static and must return Object. Alternatively use a single-element enum, which serialization always resolves to the existing constant.
What is the difference between Serializable and Externalizable?
Serializable is a marker interface: serialization saves all non-transient instance fields automatically and you can optionally refine it with private writeObject and readObject methods. Externalizable extends Serializable but saves nothing automatically: the class must implement public writeExternal and readExternal and provide a public no-arg constructor, which runs before readExternal when the object is read.
Why does writing a modified object a second time to the same ObjectOutputStream not save the change?
An ObjectOutputStream remembers every object it has written and writes later occurrences as back-references, so the second writeObject records only a pointer to the first state, and the reader gets the same, unchanged copy twice. Calling reset() before the second write, or using writeUnshared(), writes the object's current state in full. flush() does not change this.
How do you protect an application from deserializing untrusted data?
Avoid deserializing untrusted bytes where possible; otherwise install an ObjectInputFilter, for example ObjectInputFilter.Config.createFilter("com.example.*;!*") set with setObjectInputFilter before the first readObject, or JVM-wide through the jdk.serialFilter property. Patterns are matched left to right and the first match wins; ending with !* turns the list into an allow-list. A rejected class makes readObject throw InvalidClassException.
Source
This lesson covers the "Performing Input and Output Operations Using the Java I/O API" domain of the official 1Z0-831 exam guide. Vendors revise their guides — check the source for the current version.
- Java SE 25 Developer Professional (1Z0-831) exam page and topics — Oracle University
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.