Serialization and Customization Methods

Author: Ter-Petrosyan Hakob

Serialization is the process of converting an object into a sequence of bytes so that it can be easily saved to a file or sent across a network. The opposite process is called deserialization — it recreates the object from those bytes.

Serialization allows you to:

How Serialization Works in Java

In Java, serialization is built into the standard library through two main classes:

Here’s a basic example:

import java.io.*;

class User implements Serializable {
    String name;
    int score;

    User(String name, int score) {
        this.name = name;
        this.score = score;
    }
}

public class SaveUser {
    public static void main(String[] args) throws Exception {
        User user = new User("Hakob", 100);

        // Serialize
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.dat"));
        out.writeObject(user);
        out.close();

        // Deserialize
        ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.dat"));
        User restored = (User) in.readObject();
        in.close();

        System.out.println(restored.name + " - " + restored.score);
    }
}

When you run this, Java writes the User object to the file user.dat and then reads it back.

Making a Class Serializable

To make a class serializable, you simply implement the Serializable interface:

class User implements Serializable { ... }

This interface doesn’t have any methods — it’s a marker interface. It tells Java’s serialization mechanism that objects of this class can be converted into bytes.

The Role of serialVersionUID

Every serializable class should define a constant called serialVersionUID:

@Serial
private static final long serialVersionUID = 1L;

It identifies the version of the class. If you later change the class (for example, add a new field) but keep the same serialVersionUID, Java knows that old serialized objects are still compatible with the new version.

If you don’t define it, Java will generate one automatically — but that can cause problems if the class changes.

Customizing Serialization

Sometimes, you don’t want Java to automatically serialize every field.

You might want to:

To do this, you can define special private methods with exact names and signatures. Java’s serialization system automatically calls these methods if they exist.

Let’s go through them one by one.

writeObject(ObjectOutputStream out)

This method lets you control how the object is written to the stream.

class Account implements Serializable {
    String username;
    transient String password; // not serialized

    @Serial
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject(); // write normal fields
        out.writeObject(encrypt(password)); // write encrypted password manually
    }

    private String encrypt(String input) {
        return new StringBuilder(input).reverse().toString(); // simple example
    }
}

readObject(ObjectInputStream in)

This method is the partner of writeObject. It lets you control how the object is read back from the stream.

@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject(); // read normal fields
    password = decrypt((String) in.readObject()); // restore password
}

private String decrypt(String input) {
    return new StringBuilder(input).reverse().toString();
}

Java automatically calls readObject() during deserialization. If this method doesn’t exist, it uses the default behavior.

readObjectNoData()

This method is called when no data is available for the current class — for example, if you’re reading an old serialized object from a version where the class didn’t exist yet.

@Serial
private void readObjectNoData() throws ObjectStreamException {
    System.out.println("No data found for this class. Using defaults.");
}

You can use it to set default values or handle missing information safely.

writeReplace()

This method gives you a chance to replace the object being serialized with another one.

@Serial
private Object writeReplace() throws ObjectStreamException {
    return new AccountProxy(username); // replace with a simpler object
}

This is often used when you don’t want to expose sensitive or complex objects during serialization. The returned object (like AccountProxy) is the one that gets serialized instead.

readResolve()

This method runs after deserialization. It allows you to replace the newly created object with another one — useful for singletons or caching.

class Config implements Serializable {
    private static final Config INSTANCE = new Config();

    private Config() {}

    public static Config getInstance() {
        return INSTANCE;
    }

    @Serial
    private Object readResolve() throws ObjectStreamException {
        return INSTANCE; // always return the same singleton
    }
}

Without readResolve(), deserialization would create a new object, breaking the singleton pattern.

Summary of Special Methods

Method Called When Purpose
writeObject(ObjectOutputStream out) During serialization Custom write logic
readObject(ObjectInputStream in) During deserialization Custom read logic
readObjectNoData() When no serialized data exists Set default values
writeReplace() Before serialization Replace object before writing
readResolve() After deserialization Replace object after reading

Best Practices

Serialization (Writing the Object)

 ┌─────────────────────────────┐
 │  ObjectOutputStream out     │
 └──────────────┬──────────────┘
                │
                ▼
     ┌───────────────────────────┐
     │  Object is being written  │
     └──────────────┬────────────┘
                    │
                    ▼
       Does the class implement Serializable?
                    │
          ┌─────────┴──────────┐
          │                    │
         YES                  NO
          │                    │
          ▼                    ▼
  ┌────────────────┐     Throw NotSerializableException
  │  Check for     │
  │  writeReplace()│
  └──────┬─────────┘
         │
         ▼
If present → Replace object with returned one
         │
         ▼
 ┌─────────────────────────────┐
 │ Call writeObject(out) if it │
 │ exists, else default write  │
 └──────────────┬──────────────┘
                │
                ▼
 Writes bytes to stream → file/network
                │
                ▼
        Serialization done

Deserialization (Reading the Object)

 ┌─────────────────────────────┐
 │  ObjectInputStream in       │
 └──────────────┬──────────────┘
                │
                ▼
   Read class metadata (name, UID, etc.)
                │
                ▼
 Match with loaded class version
                │
       ┌────────┴────────┐
       │                 │
    Match            Mismatch 
       │                 │
       ▼                 ▼
  Continue          InvalidClassException
       │
       ▼
Check for readObject() method
       │
       ▼
 ┌─────────────────────────────┐
 │ Call readObject(in) if it   │
 │ exists, else default read   │
 └──────────────┬──────────────┘
                │
                ▼
If no data for this class → call readObjectNoData()
                │
                ▼
Call readResolve() if defined → replace object
                │
                ▼
       Deserialization done 

Real-World Use Case: Saving and Loading User Profiles

Serialization is not just a theoretical topic — it’s used in many real applications, especially when data must be saved to a file or sent over a network. Let’s see how the methods like writeObject, readObject, and readResolve work together in a realistic situation.

Imagine a simple game that saves each player’s profile (username, score, and level) so the player can continue late

Step 1: Define a Serializable Class

import java.io.*;

class PlayerProfile implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    private String username;
    private int score;
    private int level;

    // transient → don’t save it automatically
    private transient long lastLoginTime;

    PlayerProfile(String username, int score, int level) {
        this.username = username;
        this.score = score;
        this.level = level;
        this.lastLoginTime = System.currentTimeMillis();
    }

    // Custom serialization logic
    @Serial
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject(); // write non-transient fields
        out.writeLong(System.currentTimeMillis()); // manually write login time
        System.out.println("writeObject() called for " + username);
    }

    @Serial
    private void readObject(ObjectInputStream in)
            throws IOException, ClassNotFoundException {
        in.defaultReadObject(); // read non-transient fields
        this.lastLoginTime = in.readLong(); // manually read login time
        System.out.println("readObject() called for " + username);
    }

    // Fix deserialized object (optional)
    @Serial
    private Object readResolve() throws ObjectStreamException {
        System.out.println("readResolve() called for " + username);
        if (score < 0) score = 0; // Ensure data integrity
        return this;
    }

    @Override
    public String toString() {
        return username + " — Level " + level + " — Score " + score;
    }
}

Step 2: Saving and Loading Data

public class GameDataDemo {
    public static void main(String[] args) throws Exception {
        PlayerProfile p1 = new PlayerProfile("Hakob", 1500, 5);

        // Save (serialize)
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("player.dat"))) {
            out.writeObject(p1);
        }

        // Load (deserialize)
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("player.dat"))) {
            PlayerProfile p2 = (PlayerProfile) in.readObject();
            System.out.println("Loaded: " + p2);
        }
    }
}

What Happens Internally

Stage Method Called Purpose
Writing writeObject() Customizes what data gets saved
Reading readObject() Recreates object from saved data
After Reading readResolve() Ensures object is valid or replaces it

Why Use These Methods?

Key Takeaways