File I/O & NIO.2

Serialization & Deserialization

15 min Lesson 9 of 13

Serialization & Deserialization

Serialization is the process of converting a live Java object into a sequence of bytes so it can be persisted to a file, sent over a network, or stored in a database. Deserialization is the reverse: reconstructing the object from those bytes. Java has a built-in mechanism for this, but it comes with significant trade-offs that every experienced developer must understand.

The Serializable Interface

To opt an object into Java's built-in serialization, implement java.io.Serializable. This is a marker interface — it has no methods to implement. It simply signals to the JVM that objects of this class may be serialized.

import java.io.Serializable; public class UserProfile implements Serializable { // Always declare a serialVersionUID — explained below private static final long serialVersionUID = 1L; private String username; private int age; private String email; public UserProfile(String username, int age, String email) { this.username = username; this.age = age; this.email = email; } @Override public String toString() { return "UserProfile{username='" + username + "', age=" + age + ", email='" + email + "'}"; } }
Why declare serialVersionUID? When you serialize an object, Java embeds a version fingerprint in the byte stream. If you later modify the class (add a field, change a method signature) without declaring a serialVersionUID, Java recomputes the fingerprint and your old files become unreadable. Declare it explicitly so you control when a version break happens.

Writing an Object: ObjectOutputStream

ObjectOutputStream wraps any OutputStream and adds the ability to write whole objects. Combine it with FileOutputStream to persist to disk, and always wrap it in BufferedOutputStream for performance.

import java.io.*; import java.nio.file.*; public class SerializeDemo { public static void main(String[] args) { UserProfile user = new UserProfile("alice", 30, "alice@example.com"); Path file = Path.of("user.ser"); // try-with-resources ensures the stream is closed even on exception try (var out = new ObjectOutputStream( new BufferedOutputStream(Files.newOutputStream(file)))) { out.writeObject(user); System.out.println("Serialized: " + user); } catch (IOException e) { System.err.println("Serialization failed: " + e.getMessage()); } } }

Reading an Object: ObjectInputStream

ObjectInputStream mirrors ObjectOutputStream. Call readObject() and cast to your expected type. The class definition must be on the classpath at deserialization time.

import java.io.*; import java.nio.file.*; public class DeserializeDemo { public static void main(String[] args) { Path file = Path.of("user.ser"); try (var in = new ObjectInputStream( new BufferedInputStream(Files.newInputStream(file)))) { // readObject() returns Object — cast carefully UserProfile restored = (UserProfile) in.readObject(); System.out.println("Deserialized: " + restored); } catch (IOException | ClassNotFoundException e) { System.err.println("Deserialization failed: " + e.getMessage()); } } }
Catch both IOException and ClassNotFoundException. readObject() throws both. ClassNotFoundException means the bytes referred to a class that does not exist in your current runtime — common when deserializing data from a different version of the application.

The transient Keyword

Not every field should be persisted. Passwords, open file handles, caches, or fields whose value can be recomputed should be marked transient. The JVM skips transient fields during serialization; they are reset to their default value (null for objects, 0 for numbers) upon deserialization.

import java.io.Serializable; public class Session implements Serializable { private static final long serialVersionUID = 1L; private String sessionId; private String userId; // We never want to persist the raw password, even temporarily private transient String password; // A cached computation — transient because it can be rebuilt from userId private transient String displayName; public Session(String sessionId, String userId, String password) { this.sessionId = sessionId; this.userId = userId; this.password = password; this.displayName = "User-" + userId; // computed field } public String getPassword() { return password; } public String getDisplayName() { return displayName; } @Override public String toString() { return "Session{id=" + sessionId + ", userId=" + userId + ", password=" + password + ", displayName=" + displayName + "}"; } }

After deserializing a Session, getPassword() returns null and getDisplayName() returns null — both were transient. If you need to restore derived state after deserialization, implement the special method private void readResolve() or use a custom readObject().

Serializing Collections and Object Graphs

Java serializes the entire object graph reachable from the root object. If a UserProfile has a List<Order>, every Order in the list is also serialized — but every class in that graph must also implement Serializable, or you will get a NotSerializableException at runtime.

import java.io.Serializable; import java.util.List; import java.util.ArrayList; public class Order implements Serializable { private static final long serialVersionUID = 1L; private String orderId; private double total; public Order(String orderId, double total) { this.orderId = orderId; this.total = total; } } public class Customer implements Serializable { private static final long serialVersionUID = 1L; private String name; private List<Order> orders = new ArrayList<>(); // ArrayList is Serializable public Customer(String name) { this.name = name; } public void addOrder(Order o) { orders.add(o); } }
Circular references are handled, but beware. If object A holds a reference to B and B holds a reference to A, Java handles this by tracking every object it has written and reusing the reference. However, deeply nested graphs serialized this way can produce unexpectedly large byte streams. For complex graphs, prefer an explicit format like JSON.

The Caveats: Why Built-in Serialization is Controversial

Java's built-in serialization has accumulated a long list of known problems. Understanding them is as important as understanding the API:

  • Security risk. Deserializing untrusted bytes has been the source of critical remote-code-execution vulnerabilities. Gadget chains inside libraries on the classpath can be triggered by a crafted byte stream. Never deserialize data from untrusted sources using Java's built-in mechanism.
  • Version fragility. Adding, removing, or renaming fields can silently corrupt old data or throw exceptions — even with a declared serialVersionUID.
  • No schema. The binary format is opaque. You cannot easily inspect or migrate data stored in .ser files.
  • Performance. The reflection-heavy default mechanism is slower than formats like JSON, Protocol Buffers, or Kryo.
  • Java-only. The format is not interoperable with other languages.
Modern alternatives. For persistent storage or network communication prefer JSON (Jackson / Gson), Protocol Buffers, or Avro — all give you a readable, cross-language, evolvable schema. Use Java serialization only for short-lived, internal use cases (e.g. in-memory session caching with full control over both sides) and never expose it to external input.

Summary

Implement Serializable and always declare a serialVersionUID. Use ObjectOutputStream / ObjectInputStream inside try-with-resources to write and read objects. Mark sensitive or recomputable fields transient. Every class in the object graph must be serializable. Most importantly, treat Java's built-in serialization as a legacy tool — understand it thoroughly, but reach for JSON or Protocol Buffers in new production code.