Skip to content

06 · Serialization

Serialization converts an object's in-memory state into a format that can be stored or transmitted, and deserialization reconstructs it. Java has a built-in binary mechanism, but modern applications almost always use JSON instead — this module covers both.

java.io.Serializable

A class becomes serializable simply by implementing the Serializable marker interface (it has no methods to implement):

import java.io.Serializable;

public class Employee implements Serializable {
    private static final long serialVersionUID = 1L;   // versioning id, explained below

    private String name;
    private double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    @Override
    public String toString() {
        return "Employee{name='" + name + "', salary=" + salary + "}";
    }
}

serialVersionUID

The JVM uses serialVersionUID to check that a serialized object's class matches the class currently on the classpath during deserialization. If you don't declare it explicitly, Java computes one automatically from the class's structure — which means adding or removing a field can silently invalidate previously serialized data (InvalidClassException). Declaring it explicitly and bumping it deliberately when you make an incompatible change is the safer practice.

Writing an object — ObjectOutputStream

import java.io.*;

public class SerializeDemo {
    public static void main(String[] args) throws IOException {
        Employee alice = new Employee("Alice", 75000.0);

        try (ObjectOutputStream out =
                     new ObjectOutputStream(new FileOutputStream("employee.ser"))) {
            out.writeObject(alice);
        }
        System.out.println("Saved: " + alice);
    }
}

Reading an object — ObjectInputStream

import java.io.*;

public class DeserializeDemo {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        try (ObjectInputStream in =
                     new ObjectInputStream(new FileInputStream("employee.ser"))) {
            Employee loaded = (Employee) in.readObject();   // requires an explicit cast
            System.out.println("Loaded: " + loaded);
        }
    }
}
// Output:
// Loaded: Employee{name='Alice', salary=75000.0}

Why raw Java serialization is discouraged for external data

Java's built-in serialization is convenient for quick same-JVM persistence, but it has real drawbacks for anything crossing a process or language boundary:

  • Not language-agnostic — the binary format is Java-specific; nothing written in another language can read it.
  • Security risk — deserializing untrusted bytes can execute arbitrary code via crafted payloads ("deserialization gadget chains"); this is a well-known class of vulnerability.
  • Fragile versioning — as noted above, class changes can break compatibility with previously serialized data.
  • Not human-readable — impossible to inspect or debug without code.

For anything sent over a network, saved to a config file, or shared between services, JSON (or similar text formats) is the practical default.

JSON with Jackson

<!-- pom.xml -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.1</version>
</dependency>
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;

public class Person {
    @JsonProperty("full_name")   // maps this field to a different JSON key
    private String fullName;
    private int age;

    public Person() { }   // Jackson needs a no-arg constructor for deserialization

    public Person(String fullName, int age) {
        this.fullName = fullName;
        this.age = age;
    }

    public String getFullName() { return fullName; }
    public int getAge() { return age; }

    @Override
    public String toString() {
        return "Person{fullName='" + fullName + "', age=" + age + "}";
    }
}

public class JacksonDemo {
    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();

        Person alice = new Person("Alice Smith", 30);

        String json = mapper.writeValueAsString(alice);
        System.out.println(json);
        // {"full_name":"Alice Smith","age":30}

        Person parsed = mapper.readValue(json, Person.class);
        System.out.println(parsed);
        // Person{fullName='Alice Smith', age=30}
    }
}

Records work with Jackson too, with no annotations needed for the basic case — Jackson matches JSON keys to record component names automatically.

public record Point(int x, int y) { }

ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new Point(3, 4));   // {"x":3,"y":4}
Point p = mapper.readValue(json, Point.class);              // Point[x=3, y=4]
Approach Format Best for
Serializable + ObjectOutputStream Binary, Java-only Quick same-JVM persistence, caching
Jackson ObjectMapper JSON, text, cross-language APIs, config files, inter-service data
@JsonProperty Renaming a Java field's JSON key

Spring Boot uses Jackson automatically under the hood to serialize @RestController responses to JSON — see Level 3, Module 4.

How It Actually Works

ObjectOutputStream doesn't just dump raw field bytes — it writes a self-describing binary format: a stream header, then for each object a class descriptor (fully-qualified name, serialVersionUID, and a list of field names/types) followed by the field values, and object references are tracked in a handle table so that if the same object appears twice in the graph (or the graph is cyclic), it's serialized once and referenced by handle thereafter — this is genuinely how Serializable correctly round-trips shared and cyclic object graphs without infinite recursion or duplicate copies.

serialVersionUID matters mechanically because deserialization compares the UID embedded in the stream against the UID of the class currently loaded in the JVM — a mismatch throws InvalidClassException immediately, before any field data is even read, specifically to stop the JVM from mapping old field layouts onto a class whose shape has since changed in incompatible ways. If you omit it, javac computes one automatically from a hash of the class's structure (fields, methods, interfaces) — which is why adding an unrelated method can silently change the computed UID and break compatibility with previously serialized data.

Jackson's ObjectMapper works by reflection over your class's fields/ getters/setters (or record components) to build a cached BeanDescription the first time it sees a type, then reuses that introspected mapping on every subsequent (de)serialization of the same class — this is why Jackson performance is dominated by a one-time per-class reflection cost rather than per-call reflection, and why a class with no default constructor and no matching Jackson annotations fails at that introspection step rather than at each individual serialize call.

Exercise

Create a Product record with name, price, and inStock fields. Use Jackson's ObjectMapper to serialize a list of three Product instances to a JSON array string with writeValueAsString, print it, then deserialize it back into a List<Product> using mapper.readValue(json, new TypeReference<List<Product>>() {}) and print each product.