Index

Advanced Java IO Concepts

Java IO and NIO

4.1 Object Serialization and Deserialization

Java provides a powerful mechanism called serialization that allows you to convert an object into a sequence of bytes, which can then be saved to a file, transmitted over a network, or stored for later retrieval. The reverse process, deserialization, reconstructs the object from these bytes back into memory.

What is Serialization?

Serialization is the process of transforming the state of an object into a byte stream. This byte stream captures the object's data, and sometimes metadata, so it can be stored or transmitted. The key benefit is that the object’s entire state can be saved and restored later, enabling:

Without serialization, saving or transmitting complex objects would require manual conversion to a suitable format.

The Serializable Interface

In Java, an object must explicitly indicate that it can be serialized by implementing the marker interface java.io.Serializable. This interface has no methods; it simply marks the class as serializable.

import java.io.Serializable;

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

    private String name;
    private int age;

    // Constructor, getters, setters...
}

How Serialization Works in Java

The standard serialization mechanism uses two classes from the java.io package:

Basic Serialization Example

This example shows how to serialize a Person object to a file.

import java.io.*;

public class SerializeExample {
    public static void main(String[] args) {
        Person person = new Person("Alice", 30);

        try (FileOutputStream fileOut = new FileOutputStream("person.ser");
             ObjectOutputStream out = new ObjectOutputStream(fileOut)) {

            out.writeObject(person);  // Serialize the person object
            System.out.println("Object serialized to person.ser");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Basic Deserialization Example

To restore the serialized object from the file:

import java.io.*;

public class DeserializeExample {
    public static void main(String[] args) {
        try (FileInputStream fileIn = new FileInputStream("person.ser");
             ObjectInputStream in = new ObjectInputStream(fileIn)) {

            Person person = (Person) in.readObject();  // Deserialize object
            System.out.println("Deserialized Person:");
            System.out.println("Name: " + person.getName());
            System.out.println("Age: " + person.getAge());

        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Important Considerations

Transient Fields

If a field should not be serialized (e.g., sensitive information or fields that can be recalculated), declare it as transient:

private transient String password;

Such fields are ignored during serialization and restored with default values (null for objects, zero for primitives) on deserialization.

Object Graph Serialization

Java serialization handles entire object graphs. If a serialized object references other objects (fields holding other objects), those referenced objects must also implement Serializable. The whole graph is serialized recursively.

Customization via writeObject and readObject

Classes can customize the serialization process by defining these private methods:

private void writeObject(ObjectOutputStream out) throws IOException {
    // Custom serialization logic
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    // Custom deserialization logic
}

This allows, for example, encryption, compression, or special handling of certain fields.

Serial Version UID

Changing a class structure without updating serialVersionUID can lead to InvalidClassException during deserialization. Always update or maintain consistent version UIDs when evolving classes.

Why Serialization is Useful

Limitations and Alternatives

Recap

Index

4.2 Using ObjectInputStream and ObjectOutputStream

Serialization in Java revolves around two core classes: ObjectOutputStream and ObjectInputStream. These classes provide the functionality to convert Java objects into a byte stream and vice versa, enabling easy persistence and communication of complex data structures.

What Are ObjectOutputStream and ObjectInputStream?

Together, they simplify the process of writing and reading serializable objects to/from files, network sockets, or any stream.

How Do These Classes Work?

Basic Usage: Writing Objects to a File

The common pattern is to wrap a FileOutputStream with an ObjectOutputStream. This allows writing objects directly to a file.

Example: Serializing a Person Object

import java.io.*;

public class SerializeDemo {
    public static void main(String[] args) {
        Person person = new Person("Alice", 30);

        try (FileOutputStream fos = new FileOutputStream("person.ser");
             ObjectOutputStream oos = new ObjectOutputStream(fos)) {

            oos.writeObject(person);  // Serialize the person object
            System.out.println("Object serialized successfully.");

        } catch (IOException e) {
            System.err.println("Serialization failed:");
            e.printStackTrace();
        }
    }
}

Reading Objects from a File

To deserialize, wrap a FileInputStream with an ObjectInputStream and call readObject():

import java.io.*;

public class DeserializeDemo {
    public static void main(String[] args) {
        try (FileInputStream fis = new FileInputStream("person.ser");
             ObjectInputStream ois = new ObjectInputStream(fis)) {

            Person person = (Person) ois.readObject();  // Deserialize object
            System.out.println("Deserialized Person:");
            System.out.println("Name: " + person.getName());
            System.out.println("Age: " + person.getAge());

        } catch (IOException | ClassNotFoundException e) {
            System.err.println("Deserialization failed:");
            e.printStackTrace();
        }
    }
}

Handling Versioning with serialVersionUID

Java serialization includes a version control mechanism using the serialVersionUID field, which helps detect class mismatches between serialized objects and the current class version.

import java.io.Serializable;

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

    private String name;
    private int age;

    // Constructors, getters, setters...
}

Important Considerations When Using These Streams

Closing Streams

Always close streams after use to free system resources and avoid data corruption. Use try-with-resources or explicit close() calls.

Transient Fields

Fields marked transient are not serialized and will have default values after deserialization.

Custom Serialization

Classes can override writeObject and readObject private methods to customize serialization (e.g., encrypting data or handling transient fields).

private void writeObject(ObjectOutputStream out) throws IOException {
    // Custom serialization logic
    out.defaultWriteObject();
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    // Custom deserialization logic
    in.defaultReadObject();
}

Serializing Collections

Common Java collections like ArrayList, HashMap, etc., implement Serializable and can be serialized directly.

Full Example: Serialize and Deserialize Multiple Objects

import java.io.*;
import java.util.ArrayList;
import java.util.List;

public class SerializeMultipleObjects {
    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Alice", 30));
        people.add(new Person("Bob", 25));

        // Serialize list of people
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("people.ser"))) {
            oos.writeObject(people);
            System.out.println("List serialized.");
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Deserialize list of people
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("people.ser"))) {
            List<Person> deserializedPeople = (List<Person>) ois.readObject();
            System.out.println("Deserialized people:");
            for (Person p : deserializedPeople) {
                System.out.println(p.getName() + ", Age: " + p.getAge());
            }
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Recap

Index

4.3 Externalizable Interface

Java provides two main interfaces for object serialization: Serializable and Externalizable. While both enable object serialization, Externalizable offers greater control over the serialization process, allowing developers to customize exactly how an object's data is written and read. This section explains the Externalizable interface, how it differs from Serializable, and when and how to use it effectively.

What is Externalizable?

Externalizable is a subinterface of Serializable defined in the java.io package. It requires the implementing class to explicitly define how its fields are serialized and deserialized by overriding two methods:

In contrast, Serializable uses default serialization, which automatically serializes all non-transient fields.

Key Differences Between Serializable and Externalizable

Aspect Serializable Externalizable
Serialization Logic Automatic by JVM, serializes all non-transient fields Manual; developer writes explicit serialization code
Methods to Override None mandatory (optional writeObject/readObject) Must implement writeExternal and readExternal
Control Over Data Written Limited Complete control over what and how to serialize
Performance Simpler, but can be slower due to extra metadata Can be faster and more compact, if implemented carefully
Use Case General-purpose serialization When custom serialization is needed or optimized serialization required

Why Use Externalizable?

Externalizable is ideal when:

Implementing the Externalizable Interface

When a class implements Externalizable, it must provide implementations for both writeExternal and readExternal. The serialization runtime will not automatically serialize any fields.

Example: Implementing Externalizable

import java.io.*;

public class Person implements Externalizable {
    private String name;
    private int age;
    private transient String password; // will not be serialized

    // Mandatory no-arg constructor for deserialization
    public Person() {}

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

    // Serialize object data manually
    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeUTF(name);   // Write name as UTF string
        out.writeInt(age);    // Write age as int
        // Intentionally exclude password for security
    }

    // Deserialize object data manually
    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        name = in.readUTF();
        age = in.readInt();
        // password remains null after deserialization
    }

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

Testing Serialization and Deserialization

public class ExternalizableTest {
    public static void main(String[] args) {
        Person person = new Person("Alice", 30, "secretPass");

        // Serialize to file
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("personExt.ser"))) {
            oos.writeObject(person);
            System.out.println("Person serialized.");
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Deserialize from file
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("personExt.ser"))) {
            Person deserializedPerson = (Person) ois.readObject();
            System.out.println("Deserialized: " + deserializedPerson);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Output:

Person serialized.
Deserialized: Person{name='Alice', age=30, password='null'}

The password is not serialized because it was deliberately excluded in writeExternal.

Important Notes

When to Prefer Externalizable

For typical use cases where default serialization suffices, Serializable is easier and less error-prone.

Recap

Index

4.4 Piped Streams for Inter-thread Communication

In Java IO, piped streams provide a simple yet powerful mechanism for threads to communicate by sending data through a stream, similar to how one thread writes data that another thread reads. The classes PipedInputStream and PipedOutputStream are designed to work together to create a pipe — a one-way communication channel — between threads.

What Are Piped Streams?

This mechanism is analogous to a physical pipe: data flows in one end and emerges from the other.

Why Use Piped Streams?

How Do Piped Streams Work?

To establish a pipe:

  1. Create a PipedOutputStream.
  2. Create a PipedInputStream.
  3. Connect them using the constructor or the connect() method.

After connection, writing bytes to the PipedOutputStream makes those bytes available for reading from the PipedInputStream.

Thread Safety and Synchronization

Example: Producer-Consumer Using Piped Streams

The example demonstrates two threads:

import java.io.*;

public class PipedStreamExample {

    public static void main(String[] args) {
        try {
            // Create piped input and output streams and connect them
            PipedOutputStream pos = new PipedOutputStream();
            PipedInputStream pis = new PipedInputStream(pos);

            // Producer thread writes to PipedOutputStream
            Thread producer = new Thread(() -> {
                try (PrintWriter writer = new PrintWriter(pos)) {
                    String[] messages = {"Hello", "from", "the", "Producer", "thread!"};
                    for (String msg : messages) {
                        writer.println(msg);
                        writer.flush(); // Ensure data is sent immediately
                        System.out.println("Producer sent: " + msg);
                        Thread.sleep(500); // Simulate delay
                    }
                } catch (InterruptedException e) {
                    System.err.println("Producer error: " + e.getMessage());
                }
            });

            // Consumer thread reads from PipedInputStream
            Thread consumer = new Thread(() -> {
                try (BufferedReader reader = new BufferedReader(new InputStreamReader(pis))) {
                    String line;
                    while ((line = reader.readLine()) != null) {
                        System.out.println("Consumer received: " + line);
                    }
                } catch (IOException e) {
                    System.err.println("Consumer error: " + e.getMessage());
                }
            });

            // Start both threads
            consumer.start();
            producer.start();

            // Wait for threads to finish
            producer.join();
            // Closing the output stream signals end of data, consumer will exit read loop
            pos.close();
            consumer.join();

        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Explanation of the Example

Benefits and Limitations

Benefits:

Limitations:

Best Practices

Recap

Index

4.5 PushbackInputStream and Mark/Reset Methods

Java IO streams provide versatile tools for reading data sequentially. However, in some scenarios—such as parsing or processing complex data formats—you may need the ability to “unread” bytes or go back to a previously read position in the stream. This is where PushbackInputStream and the mark() / reset() methods come into play.

What is PushbackInputStream?

PushbackInputStream is a subclass of FilterInputStream that allows you to push bytes back into the input stream, effectively “unreading” them. This is especially useful in parsing scenarios where you read ahead some bytes to decide how to process them but realize that some of those bytes belong to the next data unit.

How Does PushbackInputStream Work?

Common Use Case: Lookahead in Parsing

Imagine reading a stream where the next few bytes determine how to interpret the data, but you do not want to lose these bytes permanently if you decide to process them differently. PushbackInputStream lets you peek and then push bytes back so they can be reread.

Example: Using PushbackInputStream

import java.io.*;

public class PushbackExample {
    public static void main(String[] args) throws IOException {
        byte[] data = { 'a', 'b', 'c', 'd' };
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data);
        PushbackInputStream pushbackInputStream = new PushbackInputStream(byteArrayInputStream);

        int firstByte = pushbackInputStream.read();
        System.out.println("Read byte: " + (char) firstByte); // Output: a

        int secondByte = pushbackInputStream.read();
        System.out.println("Read byte: " + (char) secondByte); // Output: b

        // Decide to "unread" the second byte
        pushbackInputStream.unread(secondByte);
        System.out.println("Pushed back byte: " + (char) secondByte);

        // Read again, should get the same byte
        int rereadByte = pushbackInputStream.read();
        System.out.println("Reread byte: " + (char) rereadByte); // Output: b

        pushbackInputStream.close();
    }
}

Output:

Read byte: a
Read byte: b
Pushed back byte: b
Reread byte: b

Important Notes About PushbackInputStream

The mark() and reset() Methods

While PushbackInputStream lets you “unread” bytes, many streams support marking a position and later resetting the stream back to that position, allowing multiple bytes to be reread without pushing them back individually.

How mark() and reset() Work

Which Streams Support mark/reset?

Example: Using mark() and reset()

import java.io.*;

public class MarkResetExample {
    public static void main(String[] args) throws IOException {
        byte[] data = { 'x', 'y', 'z' };
        ByteArrayInputStream bais = new ByteArrayInputStream(data);
        BufferedInputStream bis = new BufferedInputStream(bais);

        System.out.println("Read: " + (char) bis.read()); // x

        if (bis.markSupported()) {
            bis.mark(10); // mark current position with a buffer limit
            System.out.println("Read after mark: " + (char) bis.read()); // y
            System.out.println("Read after mark: " + (char) bis.read()); // z

            bis.reset(); // reset to marked position
            System.out.println("After reset: " + (char) bis.read()); // y (again)
        }

        bis.close();
    }
}

Output:

Read: x
Read after mark: y
Read after mark: z
After reset: y

Use Cases for mark/reset

Comparing Pushback and mark/reset

Feature PushbackInputStream mark() / reset()
Usage Manually unread bytes Mark and rewind stream position
Buffer Size Fixed buffer size specified in constructor (default 1) Managed internally by the stream, up to readlimit
Flexibility Precise byte-level unread Allows rewinding to a mark position
Stream Support Only byte streams (InputStream) Depends on stream; many support it
Typical Use Single or few bytes lookahead/unread Larger lookahead with automatic rewind

Recap

Index