Serialization is the process of converting a Java object into a sequence of bytes so they can be written to disk, sent over a network, or stored outside of memory. Later, the Java virtual machine (JVM) reads those bytes and reconstructs the original object. This process is called deserialization.
Under normal circumstances, objects exist only in memory and disappear when a program terminates. Serialization allows an object’s state to outlive the program that created it, or to be transferred between different execution contexts.
The Serializable interface
Java does not allow every object to be serialized. A class must explicitly opt in by implementing the Serializable interface, as shown here:
public class Challenger implements Serializable {
private Long id;
private String name;
public Challenger(Long id, String name) {
this.id = id;
this.name = name;
}
}
Serializable (java.io.Serializable) is a marker interface, meaning that it does not define any methods. By implementing it, the class signals to the JVM that its instances may be converted into bytes. If Java attempts to serialize an object whose class does not implement the Serializable interface, it fails at runtime with a NotSerializableException. There is no compile‑time warning.
Serialization traverses the entire object graph. Every non‑transient field must refer to an object that is itself serializable. If any referenced object cannot be serialized, the entire operation fails. All primitive wrapper types (Integer, Long, Boolean, and others), as well as String, implement Serializable, which is why they can be safely used in serialized object graphs.
Limits of Java serialization
Serialization stores instance state and preserves reference identity within the object graph (shared references and cycles). It does not preserve behavior or JVM identity across runs. Remember the following guidelines when using serialization:
- Instance fields are written to the byte stream.
- Behavior is not serialized.
- Static fields are not serialized.
- Object identity is preserved.
Also note: If two fields reference the same object before serialization, that relationship is preserved after deserialization.
A Java serialization example
As an example of serialization, consider the following example, a Java Challengers player:
Challenger duke = new Challenger(1L, "Duke");
What do you notice? Let’s unpack it.
1. Writing the object
First, Java verifies that the class implements Serializable, converts the object’s field values into bytes, and writes them to the file:
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("duke.ser"))) {
out.writeObject(duke);
}
2. Reading the object back
During deserialization, the serializable class’s own constructor is not called. The JVM creates the object through an internal mechanism and assigns field values directly from the serialized data. However, if the class extends a nonserializable superclass, that superclass’s no‑argument constructor will run:
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("duke.ser"))) {
Challenger duke = (Challenger) in.readObject();
}
This behavior often surprises developers the first time they debug a deserialized object, as the invariants are silently broken. This distinction is also important in class hierarchies, which we’ll discuss later in the article.
Serialization callbacks
Because the JVM controls object creation and field restoration during serialization, it also provides hooks that allow a class to customize how its state is written and restored. A class can define two private methods with exact signatures:
private void writeObject(ObjectOutputStream out) throws IOException
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
These methods are not called directly by application code. They are JVM callbacks invoked automatically during serialization and deserialization. Calling them manually results in a NotActiveException, because they require an active serialization context managed by the JVM:
import java.io.*;
public class OrderSensitiveExample implements Serializable {
private static final long serialVersionUID = 1L;
void main() throws IOException, ClassNotFoundException {
OrderSensitiveExample example = new OrderSensitiveExample();
// Serialization: triggers writeObject(...)
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("example.ser"))) {
out.writeObject(example);
}
// Deserialization: triggers readObject(...)
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("example.ser"))) {
in.readObject();
}
}
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject("Duke");
out.writeObject("Juggy");
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
String first = (String) in.readObject();
String second = (String) in.readObject();
System.out.println(first + " " + second);
}
}
Meanwhile, writeObject and readObject are invoked by the JVM. This is done via Java reflection, as part of ObjectOutputStream.writeObject and ObjectInputStream.readObject, and cannot be meaningfully called by application code.
Using serialVersionUID for version control
Every serializable class has a version identifier called serialVersionUID. This value is written into the serialized data. During deserialization, the JVM compares the value stored in the serialized data with the value declared in the current version of the class. If they differ, deserialization fails with an InvalidClassException.
If you do not declare a serialVersionUID, Java generates one automatically based on the given class structure. Adding a field, removing a method, or even recompiling the class can change it and break compatibility. This is why relying on the generated value is usually a mistake.
Choosing a serialVersionUID
For new classes, it is common and correct to start with the following declaration:
private static final long serialVersionUID = 1L;
IDEs often suggest long, generated values that mirror the JVM’s default computation. While technically correct, those values are frequently misunderstood. They do not distinguish objects, prevent name collisions, or identify individual instances. All objects of the same class share the same serialVersionUID.
The purpose of this value is to identify the class definition, not the object. It acts as a compatibility check during deserialization, ensuring that the class structure matches the one used when the data was written. This usually becomes a problem only after data has already been serialized and deployed.
The number itself has no special meaning; Java does not treat 1L differently from any other value. What matters is that the value is explicit, stable, and changed intentionally.
When to change serialVersionUID
You should change serialVersionUID when a class change causes previously serialized field values to have a different meaning for the current code.
Typical reasons include removing or renaming a serialized field, changing the type of a serialized field, changing the meaning of stored values such as status codes, introducing new constraints that old data may violate, or changing the class hierarchy or custom serialization logic.
In these cases, deserialization may still succeed, but the resulting object would represent an incorrect logical state. Changing the serialVersionUID ensures such data is rejected instead of silently misused.
If changes only add behavior or optional data, such as adding new fields or methods, the value usually does not need to change.
Excluding fields with transient
Some fields should not be serialized, such as passwords, cached values, or temporary data. In these cases, you can use the transient keyword:
public class ChallengerAccount implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private transient String password;
public ChallengerAccount(String username, String password) {
this.username = username;
this.password = password;
}
}
A field marked transient is skipped during serialization. When the object is deserialized, the field is set to its default value, which is usually null.
Serialization and inheritance
Serialization works across class hierarchies, but there are strict rules.
If a superclass does not implement Serializable, its fields are not serialized, and it must provide a no‑argument constructor. This failure tends to surface late, often after a seemingly harmless refactor of a base class:
class Person {
String name;
public Person() { this.name = "unknown"; }
}
class RankedChallenger extends Person implements Serializable {
private static final long serialVersionUID = 1L;
int ranking;
}
During deserialization, the superclass constructor runs and initializes its fields, while only the subclass fields are restored from the serialized data. If the no‑argument constructor is missing, deserialization fails at runtime.
Custom serialization with sensitive data
Revisiting the ChallengerAccount example we looked at earlier, the password field was marked as transient, so it is not included in default serialization and will be null after deserialization. In controlled environments, this behavior can be overridden by defining custom serialization logic.
In the example below, the writeObject and readObject methods are shown inline for clarity, but they must be declared as private methods inside the serializable class. Here’s what happens during deserialization:
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject(password);
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.password = (String) in.readObject();
}
This is considered custom serialization because the class explicitly writes and reads part of its state instead of relying entirely on the JVM’s default mechanism. The call to readObject() does not read a field by name. Java serialization is a linear byte stream, not a keyed structure.
The value returned here is simply the next object in the stream, which happens to be the password because it was written immediately after the default object data. For this reason, values must be read in the exact order they were written. Changing that order will corrupt the stream or cause deserialization to fail.
Transforming data during serialization
Custom serialization can also transform data before writing it. This is useful for derived values, normalization, or compact representations:
public class ChallengerProfile implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private transient LocalDate joinDate;
public ChallengerProfile(String username, LocalDate joinDate) {
this.username = username;
this.joinDate = joinDate;
}
}
The joinDate field is marked as transient, so it is not serialized by default. Although LocalDate is itself Serializable, marking it as transient and writing it as a single long demonstrates how custom serialization can transform a field into a different representation:
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeLong(joinDate.toEpochDay());
}
During deserialization, the epoch day is converted back into a LocalDate:
private void readObject(ObjectInputStream in)throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.joinDate = LocalDate.ofEpochDay(in.readLong());
}
The important point is not the specific transformation, but that writeObject and readObject must apply inverse transformations and read values in the exact order they were written. Here, toEpochDay and ofEpochDay are natural inverses: One converts a date to a number, and the other converts it back.
Restoring derived fields
Some fields are derived from others and should not be serialized:
public class ChallengerStats implements Serializable {
private static final long serialVersionUID = 1L;
private int wins;
private int losses;
private transient int score;
public ChallengerStats(int wins, int losses) {
this.wins = wins;
this.losses = losses;
this.score = calculateScore();
}
private int calculateScore() {
return wins * 3 - losses;
}
}
After deserialization, score will be zero. It can be restored as follows:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.score = calculateScore();
}
Why order matters in custom serialization logic
When writing custom serialization logic, the order in which values are written must exactly match the order in which they are read:
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeInt(42);
out.writeUTF("Duke");
out.writeLong(1_000_000L);
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
int level = in.readInt();
String name = in.readUTF();
long score = in.readLong();
}
Because the stream is not keyed by field name, each read call simply consumes the next value in sequence. If readUTF were called before readInt, the stream would attempt to interpret the bytes of an integer as a UTF string, resulting in corrupted data or a deserialization failure. This is one of the main reasons custom serialization should be used sparingly. A useful mental model is to think of serialization as a tape recorder: Deserialization must replay the tape in exactly the order it was recorded.
Why serialization is risky
Serialization is fragile when classes change. Even small modifications can make previously stored data unreadable.
Deserializing untrusted data is particularly dangerous. Deserialization can trigger unexpected code paths on attacker‑controlled object graphs, and this has been the source of real‑world security vulnerabilities.
For these reasons, Java serialization should be used only in controlled environments.
When serialization makes sense
Java serialization is suitable only for a narrow set of use cases where class versions and trust boundaries are tightly controlled.
| Use case | Recommendation |
| Internal caching | Java serialization works well when data is short-lived and controlled by the same application. |
| Session storage | Acceptable with care, provided all participating systems run compatible class versions. |
| Long-term storage | Risky: Even small class changes can make old data unreadable. |
| Public APIs | Use JSON. It is language-agnostic, stable across versions, and widely supported. Java serialization exposes implementation details and is fragile. |
| System-to-system communication | Prefer JSON or schema-based formats such as Protocol Buffers or Avro. |
| Cross-language communication | Avoid Java serialization entirely. It is Java-specific and not interoperable with other platforms. |
Rule of thumb: If the data must survive class evolution, cross trust boundaries, or be consumed by non‑Java systems, prefer JSON or a schema‑based format over Java serialization.
Advanced serialization techniques
The mechanisms we’ve covered so far handle most practical scenarios, but Java serialization has a few additional tools for solving problems that default serialization cannot.
Preserving singletons with readResolve
Deserialization creates a new object. For classes that enforce a single instance, this breaks the guarantee silently:
public class GameConfig implements Serializable {
private static final long serialVersionUID = 1L;
private static final GameConfig INSTANCE = new GameConfig();
private GameConfig() {}
public static GameConfig getInstance() {
return INSTANCE;
}
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}
Without readResolve, deserializing a GameConfig would produce a second instance, and any identity check using == would fail. The method intercepts the deserialized object and substitutes the canonical one. The deserialized copy is discarded.
Substituting objects with writeReplace
Whereas readResolve controls what comes out of deserialization, writeReplace controls what goes into serialization. A class can define this method to substitute a different object before any bytes are written.
The two methods are often used together to implement a serialization proxy. One class represents the object’s runtime form, while another represents its serialized form.
In this example,ChallengerWriteReplace plays the role of the “real” object, while ChallengerProxy represents its serialized form:
public class ChallengerProxy implements Serializable {
private static final long serialVersionUID = 1L;
private final long id;
private final String name;
public ChallengerProxy(long id, String name) {
this.id = id;
this.name = name;
}
private Object readResolve() throws ObjectStreamException {
return new ChallengerWriteReplace(id, name);
}
}
class ChallengerWriteReplace implements Serializable {
private static final long serialVersionUID = 1L;
private long id;
private String name;
public ChallengerWriteReplace(long id, String name) {
this.id = id;
this.name = name;
}
private Object writeReplace() throws ObjectStreamException {
return new ChallengerProxy(id, name);
}
}
When a ChallengerWriteReplace instance is serialized, its writeReplace method substitutes it with a lightweight ChallengerProxy. The proxy is the only object that is actually written to the byte stream.
During deserialization, the proxy’s readResolve method reconstructs a new ChallengerWriteReplace instance, and the proxy itself is discarded. The application never observes the proxy object directly.
This technique keeps the serialized form decoupled from the internal structure of ChallengerWriteReplace. As long as the proxy remains stable, the main class can evolve freely without breaking previously serialized data. It also provides a controlled point where invariants can be enforced during reconstruction.
Filtering deserialized classes with ObjectInputFilter
I have explained why deserializing untrusted data is dangerous. Introduced in Java 9, the ObjectInputFilter API gives applications a way to restrict which classes are allowed during deserialization:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.model.*;!*"
);
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.ser"))) {
in.setObjectInputFilter(filter); // must be set before readObject()
Object obj = in.readObject();
}
This filter allows only classes under com.example.model and rejects everything else. The pattern syntax supports allowlisting by package, as well as setting limits on array sizes, object graph depth, and total object count.
Java 9 made it possible to set a process-wide filter via ObjectInputFilter.Config.setSerialFilter or the jdk.serialFilter system property, ensuring that no ObjectInputStream would be left unprotected by default. Java 17 extended this further by introducing filter factories (ObjectInputFilter.Config.setSerialFilterFactory), which allow context‑specific filters to be applied per stream rather than relying on a single global policy. If your application deserializes data that crosses a trust boundary, an input filter is not optional; it is the minimum viable defense.
Java records and serialization
Java records can implement Serializable, but they behave differently from ordinary classes in one critical way: During deserialization, the record’s canonical constructor is called. This means any validation logic in the constructor runs on deserialized data, which is a significant safety advantage:
public record ChallengerRecord(Long id, String name) implements Serializable {
public ChallengerRecord {
if (id == null || name == null) {
throw new IllegalArgumentException(
"id and name must not be null");
}
}
}
With a traditional Serializable class, a corrupted or malicious stream could inject null values into fields that the constructor would normally reject. With a record, the constructor acts as a gatekeeper even during deserialization.
Records do not support writeObject, readObject, or serialPersistentFields. Their serialized form is derived entirely from their components, a design decision that intentionally favors predictability and safety over customization.
Alternatives to Java serialization
The Externalizable interface is an alternative to Serializable that gives the class complete control over the byte format. A class that implements Externalizable must define writeExternal and readExternal, and must provide a public no‑argument constructor:
public class ChallengerExt implements Externalizable {
private long id;
private String name;
public ChallengerExt() {} // required
public ChallengerExt(long id, String name) {
this.id = id;
this.name = name;
}
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeLong(id);
out.writeUTF(name);
}
@Override
public void readExternal(ObjectInput in) throws IOException {
this.id = in.readLong();
this.name = in.readUTF();
}
}
Unlike Serializable, no field metadata or field values are written automatically. The class descriptor (class name and serialVersionUID) is still written, but the developer is fully responsible for writing and reading all instance state.
Because writeExternal and readExternal work directly with primitives and raw values, fields should use primitive types where possible. Using a wrapper type such as Long with writeLong would throw a NullPointerException if the value were null, since auto‑unboxing cannot handle that case.
This approach can produce more compact output, but the developer is fully responsible for versioning, field ordering, and backward compatibility.
In practice, Externalizable is rarely used in modern Java. When a full control over-the-wire format is needed, most teams choose Protocol Buffers, Avro, or similar schema‑based formats instead.
Conclusion
Java serialization is a low-level JVM mechanism for saving and restoring object state. Known for being powerful but unforgiving, serialization bypasses constructors, assumes stable class definitions, and provides no automatic safety guarantees. Used deliberately in tightly controlled systems, it can be effective. Used casually, it introduces subtle bugs and serious security vulnerabilities. Understanding the trade-offs discussed in this article will help you use serialization correctly and avoid accidental misuse.
Go to Source
Author: