Java Serialization and Deserialization Basics

Introduction Serialization turns a Java object - together with all the objects it refers to - into a sequence of bytes, which can be saved to a file or sent over a network. Deserialization turns those bytes back into objects. Java has had a built-in mechanism for this since version 1.1. This tutorial explains the basics of Java serialization and deserialization. We will start by making a class Serializable, and saving and restoring an object. We will then leave fields out with transient, and see what happens to static fields and to objects shared between references. Next we will understand serialVersionUID and what happens when a class changes. Lastly, we will look at serializing records, and at the security risks which mean modern applications usually prefer formats such as JSON.

(Note - This tutorial uses the byte streams from Byte Streams vs Character StreamsClick to read tutorial: Java Byte Streams vs Character Streams and Buffered I/O.) Making a class serializable A class opts in to serialization by implementing java.io.Serializable - a marker interface with no methods. Objects are written - inside try-with-resourcesClick to read tutorial: Java try-with-resources and AutoCloseable Explained - with ObjectOutputStream.writeObject() and read back with ObjectInputStream.readObject(), which returns an Object that must be cast.
A serializable class
//Employee.java
import java.io.Serializable;
import java.util.List;

public class Employee implements Serializable {
  private static final long serialVersionUID = 1L;

  private final String name;
  private final double salary;
  private final List<String> skills;
  private transient String sessionToken;          // not serialized
  static int instancesCreated = 0;                // static: belongs to the class, not serialized

  public Employee(String name, double salary, List<String> skills, String sessionToken) {
    this.name = name;
    this.salary = salary;
    this.skills = skills;
    this.sessionToken = sessionToken;
    instancesCreated++;
  }

  @Override
  public String toString() {
    return "Employee[name=" + name + ", salary=" + salary + ", skills=" + skills + ", sessionToken=" + sessionToken + "]";
  }
}
Serializing an object to a file and reading it back
//SaveAndLoad.java
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class SaveAndLoad {
  public static void main(String[] args) throws IOException, ClassNotFoundException {
    Path file = Path.of("employee.ser");
    Employee tom = new Employee("Tom", 85000, new ArrayList<>(List.of("Java", "SQL")), "a1b2c3");
    System.out.println("Original : " + tom);

    try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
      out.writeObject(tom);
    }
    System.out.println("Written  : " + Files.size(file) + " bytes");

    try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) {
      Employee restored = (Employee) in.readObject();
      System.out.println("Restored : " + restored);
      System.out.println("Same object? " + (restored == tom) + ", instancesCreated = " + Employee.instancesCreated);
    }
    Files.delete(file);
  }
}
 OUTPUT of the above code
Original : Employee[name=Tom, salary=85000.0, skills=[Java, SQL], sessionToken=a1b2c3]
Written  : 175 bytes
Restored : Employee[name=Tom, salary=85000.0, skills=[Java, SQL], sessionToken=null]
Same object? false, instancesCreated = 1
Explanation of the code
  • The whole object graph was saved - the Employee, its String name and its ArrayList of skills - and rebuilt. Every object in the graph must be serializable; String and ArrayList are.
  • The transient field sessionToken was skipped, so it came back with its default value, null. Use transient for secrets, caches and values which make no sense outside the running program.
  • Deserialization created a new object, without calling the constructor - instancesCreated stayed at 1. Static fields belong to the class, so they are never serialized.
  • readObject() can throw ClassNotFoundException if the class of a stored object is not available when reading.
Non-serializable fields and shared objects If any object in the graph is not serializable, writeObject() fails with a NotSerializableException. And when two references point to the same object, serialization stores that object once, so the shared structure survives the round trip.
Shared references and a non-serializable field
//GraphDemo.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.NotSerializableException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class GraphDemo {
  static class Address implements Serializable {
    String city;
    Address(String city) { this.city = city; }
  }
  static class Person implements Serializable {
    String name;
    Address home;
    Person(String name, Address home) { this.name = name; this.home = home; }
  }
  static class Logger { }                                        // not Serializable
  static class Service implements Serializable {
    Logger logger = new Logger();
  }

  static byte[] serialize(Object o) throws IOException {
    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
      out.writeObject(o);
    }
    return bytes.toByteArray();
  }

  public static void main(String[] args) throws Exception {
    Address shared = new Address("London");
    Person[] couple = {new Person("Tom", shared), new Person("Nancy", shared)};
    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(serialize(couple)))) {
      Person[] copy = (Person[]) in.readObject();
      System.out.println("Still one shared Address? " + (copy[0].home == copy[1].home));
    }

    try {
      serialize(new Service());
    } catch (NotSerializableException e) {
      System.out.println("NotSerializableException: " + e.getMessage());
    }
  }
}
 OUTPUT of the above code
Still one shared Address? true
NotSerializableException: GraphDemo$Logger
The two people still share a single Address after deserialization. The Service could not be serialized because its Logger field is not serializable - marking that field transient would fix it.
serialVersionUID - versioning a class Serialized bytes may be read back long after they were written, by a newer version of the class. Every serializable class has a version number, serialVersionUID; when reading, Java compares the number stored in the bytes with the class's current number, and refuses to load the object if they differ.

If you do not declare one, Java calculates it from the class's details - fields, methods, interfaces. Almost any change to the class, even adding a method, then changes the number and makes all previously saved data unreadable. So always declare it explicitly, as Employee does with private static final long serialVersionUID = 1L;. With a fixed number, compatible changes - such as adding a field, which simply gets its default value when old data is read - keep working, and you change the number deliberately only when old data can no longer be read correctly.
Viewing the serialVersionUID of classes
//VersionCheck.java
import java.io.ObjectStreamClass;
import java.io.Serializable;

public class VersionCheck {
  static class WithoutUid implements Serializable {
    String name;
  }
  static class WithoutUidPlusMethod implements Serializable {
    String name;
    String greet() { return "hi " + name; }        // the only difference: one extra method
  }

  public static void main(String[] args) {
    System.out.println("Employee (declared)   : " + ObjectStreamClass.lookup(Employee.class).getSerialVersionUID());
    System.out.println("computed, class A     : " + ObjectStreamClass.lookup(WithoutUid.class).getSerialVersionUID());
    System.out.println("computed, A + method  : " + ObjectStreamClass.lookup(WithoutUidPlusMethod.class).getSerialVersionUID());
  }
}
 OUTPUT of the above code
Employee (declared)   : 1
computed, class A     : 2571512725159962110
computed, A + method  : -4108601833121539039
The computed numbers are long hash values, worked out from the class name, its interfaces, its fields and its non-private methods and constructors. A class cannot be compiled twice in one program, so the example compares two similar classes; in a real project, adding the greet() method to a class with no declared number would likewise change its computed number, and reading data saved by the old version would fail with an InvalidClassException. The JDK's serialver tool prints the computed number of an existing class, useful when adding an explicit one to a class which already has saved data. Serializing records Records (Java 16+) can be serialized too, and are the safest kind of serializable class: they are always deserialized through their canonical constructor, so any validation in it runs again for data read from a file.
A serializable record is rebuilt through its constructor
//RecordSerialization.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class RecordSerialization {
  record Point(int x, int y) implements Serializable {
    Point {
      System.out.println("  constructor called with (" + x + ", " + y + ")");
    }
  }

  public static void main(String[] args) throws Exception {
    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
      out.writeObject(new Point(3, 4));
    }
    System.out.println("Serialized; now reading back:");
    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
      System.out.println("Restored: " + in.readObject());
    }
  }
}
 OUTPUT of the above code
  constructor called with (3, 4)
Serialized; now reading back:
  constructor called with (3, 4)
Restored: Point[x=3, y=4]
Security risks - and modern alternatives Java serialization has a serious weakness: deserializing untrusted data is dangerous. readObject() can create objects of any serializable class on the classpath and run code in them, and attackers have used carefully crafted byte streams ("gadget chains") to run arbitrary code on servers. Joshua Bloch's advice in Effective Java is that there is no reason to use Java serialization in new systems.
  • Never deserialize data from untrusted sources - network requests, uploaded files, cookies.
  • If you must, restrict which classes can be read with a serialization filter (ObjectInputFilter, Java 9+, or the jdk.serialFilter setting).
  • Prefer data formats - JSON (with libraries such as Jackson or Gson), XML, or Protocol Buffers. They are readable by other languages, easy to inspect, and do not execute code when read.
You will still meet Java serialization in older systems, in caches and session stores, and in Java interview questions - which is why its rules are worth knowing. Summary In this tutorial we first made a class Serializable, and saved and restored a whole object graph with ObjectOutputStream and ObjectInputStream. We saw that transient and static fields are not serialized, and that deserialization does not call the constructor. We then saw that shared objects stay shared, and that a non-serializable field causes a NotSerializableException. Next we learned why serialVersionUID should always be declared. We then serialized a record, whose constructor runs on deserialization, and lastly looked at the security risks of deserialization and at modern alternatives such as JSON. This completes the file I/O module. Next, we move on to concurrency with synchronized and volatileClick to read tutorial: Java synchronized and volatile Explained with Examples.

Java Serialization and Deserialization Basics - JavaBrahman