Serialization and Deserialization in Java
You mark a field as transient, write the object to a file, read it back — and bird.getRing().getSize() fails with a NullPointerException. The reason: a transient field is not restored during deserialization, and the object constructor is not called, so the reference stays null. Let us see how this mechanism works.
Serialization in Java is the process of converting the state of an object into a sequence of bytes that can be saved to a file or a database or sent over the network. Deserialization is the reverse process: rebuilding the object from those bytes. An object can be serialized with the standard Java tools if its class implements the java.io.Serializable interface.
1. What is serialization and deserialization in Java
Serialization turns an object from program memory into a format that can be stored or transferred — a stream of bytes. Deserialization rebuilds an object with the same state from that stream. To serialize an object means to write it to a byte stream; to deserialize it means to read the bytes and get the object back.
Why you need it in practice:
- to keep the state of a program between runs (a shopping cart, user settings);
- to send an object to another process or machine (Java RMI, distributed caches, HTTP sessions in a cluster);
- to make a deep copy of an object (write it and read it back right away).
Built-in serialization in Java is provided by the Java Serialization API in the java.io package: the ObjectOutput and ObjectInput interfaces and the classes that implement them, ObjectOutputStream and ObjectInputStream. Both are wrappers over ordinary byte streams.
2. How to serialize an object in Java
To serialize an object in Java, its class must implement the Serializable interface, and the object itself is passed to the writeObject() method of ObjectOutputStream. To deserialize it, call ObjectInputStream.readObject() and cast the result to the required type.
The java.io.Serializable interface has no methods at all — it is a marker interface. It only tells the JVM that objects of the class may be serialized. If you try to serialize an object whose class does not implement Serializable, you get a java.io.NotSerializableException.
import java.io.Serializable;
public class Cat implements Serializable {
private String name;
public Cat(String name) {
this.name = name;
}
public String getName() {
return name;
}
} Steps to serialize an object:
- Create an
OutputStream(for example, aFileOutputStream). - Pass it to the
ObjectOutputStreamconstructor. - Call
writeObject(obj)— the object is serialized and written to the stream.
The streams are closed with try-with-resources:
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeCat {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
serialize();
Cat cat = deserialize();
System.out.println("Restored cat: " + cat.getName());
}
private static void serialize() {
Cat cat = new Cat("Tom");
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
os.writeObject(cat);
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
private static Cat deserialize() {
Cat cat = null;
try (FileInputStream fis = new FileInputStream(FILE_NAME);
ObjectInputStream ois = new ObjectInputStream(fis)) {
cat = (Cat) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
return cat;
}
}
// Output: Restored cat: Tom The serialized object can be restored in another program and on another machine, as long as the same class with a compatible serialVersionUID is on its classpath. Otherwise readObject() throws ClassNotFoundException or InvalidClassException.
3. How deserialization works
During deserialization the JVM allocates memory for the object and fills its fields with values from the stream without calling the constructor of the serializable class. That is why initialization logic from the constructor (checks, default values, opening resources) does not run when an object is read.
What happens to different kinds of fields:
| Field | Written to the stream? | Value after deserialization |
|---|---|---|
| Regular field (primitive or Serializable object) | Yes | The value from the stream |
transient | No | The default value: null, 0, false |
static | No | The current value of the static field in this JVM (it belongs to the class, not to the object) |
| Field of a non-serializable superclass | No | Whatever the no-arg constructor of that superclass assigns (see inheritance) |
Reference to an object whose class is not Serializable, without transient | — | Serialization fails: NotSerializableException in writeObject() |
Access modifiers do not matter: private fields are saved just like public ones. A field with an initializer (private transient int count = 10;) is still 0 after deserialization — field initializers run as part of the constructor, and the constructor is not called.
4. The object graph
The object graph is the object itself plus every object reachable from it through references, including references inside nested objects. Serialization writes the whole graph, so every object in it must be serializable, and deserialization restores the whole graph together with the links between objects.
If several references point to the same object, they also point to one object in the restored graph. The serialization mechanism remembers objects it has already written and, when it meets one again, writes only a reference to it. This is how shared and circular references are saved correctly and no object is written twice.
import java.io.Serializable;
public class Dog implements Serializable {
private Collar theCollar;
public Dog(Collar collar) {
theCollar = collar;
}
public Collar getCollar() {
return theCollar;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Dog dog = (Dog) o;
return theCollar != null ? theCollar.equals(dog.theCollar) : dog.theCollar == null;
}
@Override
public int hashCode() {
return theCollar != null ? theCollar.hashCode() : 0;
}
} import java.io.Serializable;
public class Collar implements Serializable {
private int collarSize;
public Collar(int size) {
collarSize = size;
}
public int getCollarSize() {
return collarSize;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Collar collar = (Collar) o;
return getCollarSize() == collar.getCollarSize();
}
@Override
public int hashCode() {
return getCollarSize();
}
} What will this code print? Pay attention to the last two lines of main().
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeDog {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
Collar collar = new Collar(3);
Dog dog1 = new Dog(collar);
Dog dog2 = new Dog(collar);
serialize(dog1, dog2);
Dog[] deserializedDogs = deserialize(2);
System.out.println(dog1.equals(deserializedDogs[0]));
System.out.println(dog2.equals(deserializedDogs[1]));
Collar desCollar1 = deserializedDogs[0].getCollar();
Collar desCollar2 = deserializedDogs[1].getCollar();
System.out.println(desCollar1.equals(desCollar2));
System.out.println(desCollar1 == desCollar2);
System.out.println(dog1 == deserializedDogs[0]);
}
private static void serialize(Dog... dogs) {
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
for (Dog dog : dogs) {
os.writeObject(dog);
}
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
private static Dog[] deserialize(int dogNumber) {
Dog[] dogs = new Dog[dogNumber];
try (FileInputStream fs = new FileInputStream(FILE_NAME);
ObjectInputStream os = new ObjectInputStream(fs)) {
for (int i = 0; i < dogNumber; i++) {
dogs[i] = (Dog) os.readObject();
}
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
return dogs;
}
}
// Output:
// true
// true
// true
// true - after deserialization both dogs refer to ONE Collar object
// false - deserialization always creates a new object Easy to miss
A shared reference survives only within one ObjectOutputStream. If you write dog1 and dog2 to two different streams (or files), each dog gets its own separate Collar object after reading, and desCollar1 == desCollar2 becomes false.
5. The transient keyword in Java
The transient keyword excludes a field from serialization: its value is not written to the stream, and after deserialization the field gets the default value of its type. Use it for fields that should not or cannot be saved: passwords, caches, computed values, references to non-serializable objects.
If a field refers to an object whose class does not implement Serializable, you have two options: make that class serializable or mark the field as transient. If you do neither, writeObject() throws java.io.NotSerializableException with the name of the non-serializable class.
import java.io.Serializable;
public class Bird implements Serializable {
private String name;
private transient Ring ring;
public Bird(String name, Ring ring) {
this.name = name;
this.ring = ring;
}
public Ring getRing() {
return ring;
}
} public class Ring { // does not implement Serializable
private int size;
public Ring(int size) {
this.size = size;
}
public int getSize() {
return size;
}
} import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeBird {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
Bird bird = new Bird("pigeon", new Ring(3));
System.out.println("Ring size before serialization: "
+ bird.getRing().getSize());
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
os.writeObject(bird);
} catch (IOException e) {
System.out.println(e.getMessage());
}
try (FileInputStream fis = new FileInputStream(FILE_NAME);
ObjectInputStream ois = new ObjectInputStream(fis)) {
bird = (Bird) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
// bird.getRing().getSize() would throw NullPointerException here
System.out.println("Ring after deserialization: " + bird.getRing());
}
}
// Output:
// Ring size before serialization: 3
// Ring after deserialization: null If you remove transient from the ring field, the program never gets to reading: writeObject() throws NotSerializableException, and the console shows the message Ring (the class name). How to save the value of a transient field by hand is shown in the section writeObject() and readObject().
6. serialVersionUID and class versions
serialVersionUID is the version number of a serializable class, written to the stream together with the object. During deserialization the JVM compares the number from the stream with the number of the class on the classpath; if they differ, readObject() throws java.io.InvalidClassException.
If the field is not declared explicitly, the JVM computes it from the structure of the class (name, interfaces, fields, methods). Any change to the class — even adding a method — can change the computed value, and different compilers may compute it differently. So it is better to declare the version explicitly:
import java.io.Serial;
import java.io.Serializable;
public class Cat implements Serializable {
@Serial // Java 14+: the compiler checks the declaration
private static final long serialVersionUID = 1L;
private String name;
// ...
} If you write an object with serialVersionUID = 1L, change the value in the class to 2L and try to read the file, you get:
java.io.InvalidClassException: Cat; local class incompatible:
stream classdesc serialVersionUID = 1, local class serialVersionUID = 2 With an explicit serialVersionUID that stays the same, compatible changes (adding a field, removing a field) do not break reading old data: a new field gets its default value, and an extra field from the stream is ignored. Change the number only when old data really cannot be read by the new version of the class (for example, the type of a field has changed).
7. Deserialization and inheritance
If a serializable class extends a class that does not implement Serializable, the fields of that superclass are not saved. During deserialization they are initialized by the no-arg constructor of the nearest non-serializable superclass, while the fields of the serializable class itself are restored from the stream. The constructor of the deserialized class is still not called.
The no-arg constructor of the non-serializable superclass must exist and be accessible from the subclass. If it is missing, readObject() throws java.io.InvalidClassException with a message like Bug; no valid constructor.
import java.util.Objects;
public class Insect { // does not implement Serializable
private String name;
public Insect(String type) {
this.name = type;
}
public Insect() {
}
public String getName() {
return name;
}
public void setName(String type) {
this.name = type;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Insect insect = (Insect) o;
return Objects.equals(name, insect.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
@Override
public String toString() {
return "Insect{name=" + name + "}";
}
} import java.io.Serializable;
import java.util.Objects;
public class Bug extends Insect implements Serializable {
private boolean fly;
public Bug(String type, boolean fly) {
super(type);
this.fly = fly;
}
public boolean isFly() {
return fly;
}
public void setFly(boolean fly) {
this.fly = fly;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Bug bug = (Bug) o;
return fly == bug.fly;
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), fly);
}
@Override
public String toString() {
return "Bug{fly=" + fly + "} " + super.toString();
}
} import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeInsectExample {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
Bug bug = new Bug("May beetle", true);
System.out.println("Before serialization: " + bug);
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
os.writeObject(bug);
} catch (IOException e) {
System.out.println(e.getMessage());
}
try (FileInputStream fis = new FileInputStream(FILE_NAME);
ObjectInputStream ois = new ObjectInputStream(fis)) {
bug = (Bug) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
System.out.println("After deserialization: " + bug);
}
}
// Output:
// Before serialization: Bug{fly=true} Insect{name=May beetle}
// After deserialization: Bug{fly=true} Insect{name=null} The fly field belongs to the serializable class Bug and is restored from the stream. The name field is declared in the non-serializable Insect, so it gets its value from the Insect() constructor, that is null. If you remove the no-arg constructor from Insect, writing still succeeds, but reading ends with InvalidClassException.
8. Serializable vs Externalizable
The Externalizable interface extends Serializable and gives the programmer full control over the format: the class writes and reads its own data in the writeExternal() and readExternal() methods. With standard serialization (Serializable) the fields are written automatically.
| Feature | Serializable | Externalizable |
|---|---|---|
| Methods to implement | None (marker); optionally private writeObject() / readObject() | Required: writeExternal(ObjectOutput) and readExternal(ObjectInput) |
| What is written | All non-static, non-transient fields, automatically | Only what writeExternal() writes explicitly |
| Constructor on reading | Not called (except the constructor of a non-serializable superclass) | The public no-arg constructor is called; without it — InvalidClassException |
Role of transient | Excludes the field | Irrelevant: the code of writeExternal() decides |
| When to choose | Almost always: simple and reliable | You need a compact or special format, or speed matters |
Partial control without switching to Externalizable comes from private writeObject() and readObject() methods in the serializable class itself. The JVM calls them instead of the default logic. For example, this is how the ring size from the transient field of Bird can be saved:
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
public class Bird implements Serializable {
private String name;
private transient Ring ring;
// constructor and getRing() - as in section 5
@Serial
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // regular fields (name)
out.writeInt(ring != null ? ring.getSize() : -1);
}
@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
int size = in.readInt();
ring = size >= 0 ? new Ring(size) : null;
}
}
// SerializeBird now prints: Ring after deserialization: Ring@...
// and bird.getRing().getSize() returns 3 Records are different
Records (record, Java 16+) are also serialized through Serializable, but they are deserialized differently: the JVM calls the canonical constructor of the record. This means the checks in the constructor also run when reading from a stream, and writeObject()/readObject() methods are ignored for records.
9. Serialization vs marshalling
Marshalling is preparing data for transfer between processes or systems, while serialization is converting an object into a stream of bytes. In everyday speech the two terms are often used as synonyms, but marshalling is broader: it can include extra information needed for the transfer (in Java RMI, for example, the object travels together with the location its class can be loaded from).
In Java the word marshalling usually shows up in two contexts:
- Java RMI — the arguments and results of a remote call are marshalled with standard serialization;
- JAXB — the
Marshallerclass turns an object into XML, andUnmarshallerturns it back.
Built-in Java serialization produces a binary format that only Java programs understand. To exchange data with other systems, objects are usually serialized to JSON (the Jackson and Gson libraries), XML or Protocol Buffers: these formats do not depend on the language and do not execute code when read.
10. Why an object is not serializable: common mistakes
Most often an object is not serializable because its graph contains an object whose class does not implement Serializable: writeObject() throws NotSerializableException with the name of that class. The other typical errors appear on reading — because of a changed class version, a missing constructor, or a mismatch between the number of objects written and read.
| Symptom | Cause | How to fix |
|---|---|---|
NotSerializableException: Ring on writing | A field refers to an object whose class is not Serializable (at any level of the graph) | Implement Serializable in that class or mark the field transient |
NotSerializableException with the name of the outer class | An instance of a non-static inner or anonymous class is serialized: it holds a hidden reference to the outer object | Make the nested class static or move it to a separate file |
NotSerializableException for a lambda in a field | A regular lambda is not serializable | Do not keep lambdas in serializable fields, or mark the field transient |
InvalidClassException: ... local class incompatible | The serialVersionUID has changed (explicit or computed) | Declare serialVersionUID explicitly and do not change it without a reason |
InvalidClassException: ... no valid constructor | The non-serializable superclass has no accessible no-arg constructor | Add such a constructor to the superclass |
ClassNotFoundException on reading | The class is not on the classpath of the reading program | Add the same class (same package and name) |
EOFException | More objects are read than were written | Write a collection as one object, or write the number of elements first |
NullPointerException after reading | The field was transient and became null | Check for null or restore the field in readObject() |
All these exceptions except NullPointerException are checked subclasses of IOException or ReflectiveOperationException, so the compiler makes you handle them — see Java exceptions and the overview of common Java exceptions.
11. Deserialization security
Deserializing data from an untrusted source is dangerous: readObject() creates objects of any serializable class on the classpath, and a specially crafted stream can trigger a chain of calls leading to arbitrary code execution or memory exhaustion. That is why built-in Java serialization must not be applied to data that comes from a user or over the network without checks.
What to do:
- do not accept serialized Java objects from outside; use JSON or Protobuf for data exchange;
- if deserialization cannot be avoided, restrict the allowed classes with an
ObjectInputFilter(JEP 290, Java 9; backported to 8u121). Java 17 added filter factories for the whole application (JEP 415); - validate the object state in
readObject()the same way you do in the constructor.
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.model.*;java.base/*;maxdepth=10;!*");
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_NAME))) {
ois.setObjectInputFilter(filter);
Object obj = ois.readObject(); // class not in the list -> InvalidClassException: filter status: REJECTED
} The same filter can be set for the whole JVM without changing the code — with the system property -Djdk.serialFilter=....
Frequently asked questions
Are private fields and fields without getters serialized?
Yes. Built-in serialization works with fields directly and bypasses getters and setters, so access modifiers make no difference. Only static and transient fields are skipped. A summary of all field kinds is in the section How deserialization works.
Do I have to change serialVersionUID after adding a new field to the class?
No. Adding a field is a compatible change: old data is still read, and the new field gets its default value. Change the number only when you deliberately want to forbid reading old data. Details are in the section serialVersionUID and class versions.
Can I serialize an ArrayList or a HashMap?
Yes, the standard classes of the Collections Framework implement Serializable. But all their elements must be serializable too (for a HashMap, both keys and values), otherwise writing ends with NotSerializableException. You can declare the field as List, but make sure the actual implementation is serializable.
How do I make a deep copy of an object with serialization?
Write the object to a ByteArrayOutputStream through an ObjectOutputStream and immediately read it from a ByteArrayInputStream through an ObjectInputStream: you get an independent copy of the whole object graph. The approach is simple but slow, and every object in the graph must be serializable.
Is built-in Java serialization used in new projects?
Rarely. For data exchange and storage, teams usually pick JSON or Protobuf: they are faster, readable by other languages and safer. Built-in serialization remains in legacy code, RMI, some caches and HTTP sessions, and how it works is a regular interview topic.
Comments