Skip to main content
Object Oriented Software Design
Object Oriented Software Design

Lecture #9: Files and Object Serialization in Java

Introduction

Sooner or later, almost every program needs to make its data outlive the process that created it. A user’s session, a game’s saved state, a set of parsed measurements — all of this lives in objects sitting in RAM, and RAM is erased the moment the program exits. Java addresses this need at two levels. At the lower level, the java.io package gives you streams: sequential channels of bytes or characters that connect your program to files, network sockets, or in-memory buffers. At a higher level, Java offers object serialization, a mechanism that can take an entire object — including the web of other objects it references — and turn it into a flat sequence of bytes that can later be read back into a live, working object graph.

This chapter walks through both layers. We start with the general idea of streams and how Java organizes them, then look at a concrete example of reading and writing primitive data to files, and finally dive into serialization proper: what problem it solves, how to opt an object into it, and the traps that catch developers who use it carelessly.

Streams: The Foundation

A stream is an abstraction over a source or destination of data that lets you read or write it sequentially, one chunk at a time, without worrying about what is actually on the other end. The same InputStream/OutputStream API can sit on top of a file on disk, a block of memory, or a live network connection — the calling code barely needs to change.

Java splits streams into two families depending on what kind of data flows through them:

  • Character streams (Reader / Writer) are meant for text. FileReader and FileWriter are the file-based members of this family. On top of a raw Reader you typically wrap a Scanner to parse tokens conveniently when reading, or a PrintWriter to format output conveniently when writing.
  • Byte streams (InputStream / OutputStream) are meant for arbitrary binary data. FileInputStream and FileOutputStream are their file-based members. On top of a raw byte stream you wrap a DataInputStream to read typed primitive values (int, double, etc.), or a DataOutputStream to write them.

This “wrapping” pattern — constructing one stream object by passing another stream object into its constructor — is a recurring design idea in java.io. Each wrapper adds one specific capability (buffering, parsing, typed I/O, object I/O) while delegating the actual reading or writing of raw bytes to the stream it wraps. Once you recognize the pattern, the whole java.io hierarchy becomes much easier to navigate: instead of memorizing dozens of classes, you just ask “what capability does this wrapper add, and what does it need to be wrapped around?”

A quick way to keep the naming straight: anything ending in Reader/Writer deals in characters (and therefore needs a character encoding to talk to raw bytes), while anything ending in InputStream/OutputStream deals in raw bytes directly.

A worked example: converting a text file of numbers to binary

Suppose you have a plain text file containing decimal numbers separated by whitespace, and you want to produce a compact binary file containing the same numbers as IEEE-754 doubles. Scanner makes reading the tokenized text file trivial, and DataOutputStream gives you a direct writeDouble method:

import java.io.*;
import java.util.Scanner;

public class TextToBinaryConverter {
    public static void main(String[] args) throws IOException {
        Scanner in = new Scanner(new FileReader(args[0]));
        DataOutputStream out =
            new DataOutputStream(new FileOutputStream(args[1]));

        while (in.hasNext()) {
            out.writeDouble(in.nextDouble());
        }
        in.close();
        out.close();
    }
}

Notice the two wrapping chains: Scanner wraps a FileReader (character stream) to get convenient tokenized reading, and DataOutputStream wraps a FileOutputStream (byte stream) to get typed binary writing.

Reading such a binary file back is just as direct, but with one twist: DataInputStream has no “is there more data?” query method. Instead, you keep reading until you hit the end of the stream, which Java signals by throwing EOFException. The idiomatic pattern is to read in a loop protected by a try/catch that treats EOFException as the normal termination condition rather than an error:

import java.io.*;

public class ThresholdFilter {
    public static void main(String[] args) throws IOException {
        double threshold = Double.parseDouble(args[1]);

        DataInputStream in = new DataInputStream(new FileInputStream(args[0]));
        DataOutputStream out = new DataOutputStream(new FileOutputStream(args[2]));

        try {
            while (true) {
                double x = in.readDouble();
                if (x > threshold) {
                    out.writeDouble(x);
                }
            }
        } catch (EOFException endOfFile) {
            // Expected: readDouble() throws this once the stream is exhausted.
        }

        in.close();
        out.close();
    }
}

This program reads the binary file produced above, keeps only the values above a given threshold, and writes those into a second binary file. It illustrates the general shape of stream-based file processing in Java: open, loop until exhausted, close — with resource cleanup traditionally handled in finally or, in modern Java, with try-with-resources.

Why Object Serialization Exists

Reading and writing primitive values like double or int is straightforward because their representation is fixed-size and self-contained. Objects are a different story. Saving an object to disk in a way that lets you reconstruct it later requires capturing at least three things:

  1. The object’s class, so that when you read the data back, Java knows what type of object to reconstruct.
  2. The object’s state — the values of all its instance variables. When an instance variable is itself a reference to another object, that referenced object’s state has to be captured too, recursively.
  3. The object’s identity, so that if several different variables in your program pointed at the same object in memory, that sharing relationship survives the round trip.

That third point is the subtle part. Imagine you have a hash table, a tree, or any data structure built out of interlinked objects, and two different nodes both hold a reference to a third, shared object. If you naively wrote each node “by value” to a file, you would end up with two independent copies of that shared object after reloading — the sharing relationship would be lost, and if your program later mutated one copy, the other would not reflect the change. A serialization mechanism has to detect this sharing (even when the object graph is cyclic) and preserve it, typically by writing each distinct object only once and having subsequent references point back to it by an internal identifier.

Java calls this whole process — turning a possibly cyclic graph of objects into a flat, sequential stream of bytes, and being able to reconstruct the graph from that stream — serialization (sometimes described as “linearizing” an object graph, since a stream is inherently one-dimensional while an object graph is not). It is not limited to saving objects to files: the same mechanism, in the same byte format, is used whenever Java needs to move an object out of one JVM’s memory and into another’s, for instance across a network connection (as used by RMI and various Java EE technologies).

Making a Class Serializable

By default, no ordinary object can be serialized. Java requires you to opt in explicitly by having your class implement the marker interface java.io.Serializable:

import java.io.Serializable;

public class Employee implements Serializable {
    private String name;
    private int employeeId;

    public Employee(String name, int employeeId) {
        this.name = name;
        this.employeeId = employeeId;
    }

    @Override
    public String toString() {
        return "Employee#" + employeeId + "(" + name + ")";
    }
}

Serializable declares no methods at all — it exists purely as a marker that tells the JVM’s built-in serialization machinery “yes, you are allowed to inspect and dump my fields.” Because it is an ordinary interface, it is inherited: if a class is serializable, every subclass of it is automatically serializable too, without needing to repeat the implements clause.

The actual reading and writing is performed by two wrapper streams that follow exactly the same “wrapping” pattern seen earlier:

// Writing objects: wrap a byte-oriented OutputStream
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data.bin"));
out.writeObject(someSerializableObject);
out.close();

// Reading objects: wrap a byte-oriented InputStream
ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.bin"));
Object restored = in.readObject();
in.close();

ObjectOutputStream.writeObject(Object) throws NotSerializableException if, anywhere in the object graph being written, it encounters an object whose class does not implement Serializable. ObjectInputStream.readObject() throws ClassNotFoundException if the class of the object being reconstructed is not available on the classpath of the reading program — which matters when you serialize an object in one application and try to deserialize it in another that does not have the same classes available.

One easily-missed detail: readObject() returns a plain Object, regardless of what type was actually written. You must downcast the result to the type you expect:

Employee e = (Employee) in.readObject();

Like DataInputStream/DataOutputStream, ObjectInputStream and ObjectOutputStream also expose methods for primitive types (readInt/writeInt, readDouble/writeDouble, and so on), so a single stream can freely mix serialized objects and raw primitive values.

The transient Keyword

Sometimes an object legitimately holds data that should never be written to disk — a password held temporarily in memory, a cached value that can be recomputed, a handle to an OS resource such as an open socket that would be meaningless after being reloaded in a different process. Marking a field transient tells the serialization machinery to skip that field entirely: its value is not written when the object is serialized, and after deserialization the field simply holds its type’s default (null for objects, 0/0.0/false for primitives).

Here is a small example modeling login events, deliberately keeping the plaintext password out of the saved data:

import java.io.Serializable;

public class User implements Serializable {
    private String username;

    public User(String username) {
        this.username = username;
    }

    @Override
    public String toString() {
        return super.toString() + " " + username; // super.toString() shows identity
    }
}

class LoginEvent implements Serializable { // package-private: same file as User for this example only —
                                            // in real code these would be two separate .java files
    private User user;
    private String date;
    private transient String password; // deliberately not persisted

    public LoginEvent(User user, String password, String date) {
        this.user = user;
        this.password = password;
        this.date = date;
    }

    @Override
    public String toString() {
        return "user:" + user + " date:" + date + " password:" + password + "\n";
    }
}

If you print a LoginEvent before serializing it, password shows its real value. Save it to a file, reload it in a fresh ObjectInputStream, and print it again: password now prints as null, while user and date come back intact. This is exactly the behavior you want for sensitive or non-persistable data.

This example also demonstrates identity preservation. If two different LoginEvent objects reference the same User instance (say, the same person logging in twice), and you write both events to the same ObjectOutputStream, then after reading both back from the same ObjectInputStream, the two reconstructed LoginEvent objects will again point at the very same User object in memory — not two separate copies. You can confirm this by comparing the identity strings printed by Object.toString() (the default hashCode-based suffix), or more directly with ==. This sharing is preserved only within a single stream: if you serialize objects across two independent files and read each one back separately, Java has no way to know they were ever related, and you will get two distinct objects even if they used to be == in the original program.

Collections and Arrays Serialize for Free

A very practical consequence of how widely Serializable is implemented in the standard library is that arrays and the standard collection types (ArrayList, HashMap, TreeMap, and so on) are themselves serializable, provided the elements they contain are also serializable. This means you can often serialize an entire data structure — a whole map of records, say — with a single writeObject call, instead of manually iterating and saving each entry.

import java.io.*;
import java.util.Map;
import java.util.TreeMap;

public class Book implements Serializable {
    private String title;
    private String author;

    public Book(String title, String author) {
        this.title = title;
        this.author = author;
    }

    @Override
    public String toString() {
        return title + " by " + author;
    }
}

class Library { // package-private: same file as Book for this example only
    private Map<String, Book> catalog = new TreeMap<>();

    public void add(String isbn, Book book) {
        catalog.put(isbn, book);
    }

    public void save(String backupFile) throws IOException {
        try (ObjectOutputStream out =
                 new ObjectOutputStream(new FileOutputStream(backupFile))) {
            out.writeObject(catalog);
        }
    }

    @SuppressWarnings("unchecked")
    public void load(String backupFile) throws IOException, ClassNotFoundException {
        try (ObjectInputStream in =
                 new ObjectInputStream(new FileInputStream(backupFile))) {
            catalog = (Map<String, Book>) in.readObject();
        }
    }

    public void listing() {
        catalog.forEach((isbn, book) -> System.out.println(isbn + ": " + book));
    }
}

Because TreeMap is serializable and it knows how to serialize its own keys and values, one writeObject(catalog) call is enough to persist the whole library, and one readObject() call restores it — including every Book it contained. Note the two idioms used here that are worth adopting as habits: try-with-resources for automatic stream closing, and an unchecked-cast suppression on the line where a generic collection type is downcast from the Object returned by readObject() (the cast is unavoidable, since generics are erased at runtime and the serialization API predates generics entirely).

Common Pitfalls

Two mistakes account for most of the confusion beginners run into with serialization.

Forgetting that every reachable object must be serializable. If class A implements Serializable but has a field of type B, and B does not implement Serializable, then calling writeObject on an A instance will compile fine but fail at runtime with NotSerializableException — thrown lazily, only when the serializer actually walks into that field. This is a common trap when adding a new field to an already-serializable class: the compiler gives no warning, and the bug surfaces only the first time someone tries to save an object. The fix is either to make B serializable too (if it makes sense for it to be persisted), or to mark the field transient if it should simply be skipped:

class Sensor { /* does NOT implement Serializable — e.g. wraps a live hardware handle */ }

public class Reading implements Serializable {
    private double value;
    private Sensor sensor; // BUG: will throw NotSerializableException when saved
}

Omitting serialVersionUID. Every serializable class is implicitly assigned a version identifier that Java’s deserialization machinery uses to check compatibility between the class that wrote the data and the class trying to read it back. If you do not declare this identifier explicitly, the JVM computes one automatically from details of the class (its fields, methods, and so on), which means that almost any change to the class — adding a method, reordering a field, even recompiling with a different compiler — can silently produce a different computed value. The result is that previously-saved data becomes unreadable, failing with InvalidClassException, even though the change you made had nothing to do with the data’s actual structure. The fix is cheap: declare the field explicitly and only change it when you deliberately want to break compatibility with older saved data.

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

    private String name;
    private int employeeId;
    // ...
}

Most IDEs will even warn you (as a lint suggestion) when a Serializable class is missing this field, precisely because its absence is such a common source of hard-to-diagnose bugs after a class is refactored.

Recap

  • Java’s java.io package models input/output as streams, split into character streams (Reader/Writer, for text) and byte streams (InputStream/OutputStream, for binary data), with file-specific and format-specific variants built by wrapping one stream inside another.
  • DataInputStream/DataOutputStream add typed read/write methods for primitive values on top of raw byte streams; reading until EOFException is the idiomatic way to detect the end of such a stream.
  • Serialization converts an object graph into a linear byte sequence capturing class identity, field values, and reference-sharing relationships (including cycles), so it can later be reconstructed faithfully — including reproducing which objects were the same instance, as long as they were part of the same write/read stream.
  • A class opts into serialization simply by declaring implements Serializable; subclasses inherit this automatically.
  • ObjectOutputStream.writeObject / ObjectInputStream.readObject, wrapped around file streams (or any other stream), perform the actual work; readObject() must be downcast since it returns Object.
  • The transient keyword excludes a specific field from serialization — useful for secrets, caches, or non-persistable resources; such fields come back as their type’s default value after deserialization.
  • Arrays and standard collections are serializable out of the box as long as their elements are, making it possible to persist an entire data structure with one writeObject call.
  • The two classic pitfalls are: a non-serializable field reachable from a serializable class (NotSerializableException at runtime), and omitting an explicit serialVersionUID (silent version-compatibility breakage after seemingly unrelated code changes).