<< Home

Chapter 14: Files and I/O

All programs eventually need to interact with the outside world. Whether reading configuration data, saving user information, processing text, or managing large data sets, Java provides powerful and flexible ways to perform input and output (I/O). From its earliest versions, the language has included a rich set of classes in the java.io package, which handle streams of data in both byte and character form. Later, the java.nio.file package was added, bringing modern, efficient, and more intuitive APIs for working with files and directories.

This chapter explores Java’s I/O capabilities in depth. You will learn how to use both the classic stream-based model and the newer NIO approach, how to read and write text and binary data, how to serialize objects, and how to manage character encodings and buffered input/output for improved performance.

💡 I/O stands for input and output, and in Java it covers not only files but also network connections, system input and output streams, and even inter-process communication.

Understanding Java’s I/O architecture is essential for writing efficient, reliable, and scalable applications. The API is deliberately layered, separating the source or destination of data (such as files or sockets) from the way data is read or written (such as streams, readers, and writers). This allows developers to combine components flexibly and to adapt to a wide variety of data sources without rewriting core logic.

⚠️ Always ensure that file and stream resources are properly closed after use. Modern Java simplifies this through the try-with-resources statement, which automatically handles cleanup and prevents resource leaks.

By the end of this chapter, you will be able to confidently handle file and data operations, understand the differences between the old and new I/O systems, and write programs that manage files efficiently and safely.

Working with java.io streams

The java.io package forms the foundation of Java’s classic input and output system. It provides a wide range of stream classes that let you read and write data sequentially, one byte or character at a time. Streams are Java’s abstraction for data flow, allowing you to work with files, memory buffers, network connections, and other data sources in a consistent way.

At the core of this system are two major hierarchies: one for byte streams and another for character streams. Byte streams handle raw binary data, while character streams handle text data using Unicode encoding. Both hierarchies share a similar design, making it easy to switch between them depending on your needs.

Byte streams

Byte streams are based on the abstract classes InputStream and OutputStream. These classes provide methods for reading and writing bytes, and many concrete subclasses handle specific data sources such as files or network connections.

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class CopyFile {
  public static void main(String[] args) {
    try (FileInputStream in = new FileInputStream("input.dat");
         FileOutputStream out = new FileOutputStream("output.dat")) {
      int b;
      while ((b = in.read()) != -1) {
        out.write(b);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This simple example copies one file to another by reading bytes from an InputStream and writing them to an OutputStream. The try-with-resources statement ensures both streams are automatically closed at the end of the operation.

💡 Byte streams are best suited for binary data such as images, audio, and serialized objects. For human-readable text, use character streams instead.

Character streams

Character streams extend the idea of byte streams but work with Unicode characters instead of raw bytes. They are built on Reader and Writer classes and automatically handle character encoding and decoding.

import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

public class CopyText {
  public static void main(String[] args) {
    try (FileReader reader = new FileReader("input.txt");
         FileWriter writer = new FileWriter("output.txt")) {
      int c;
      while ((c = reader.read()) != -1) {
        writer.write(c);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This version reads and writes characters rather than bytes, making it ideal for plain-text files. Because Reader and Writer classes handle encoding automatically, the program can process text reliably across platforms.

⚠️ Avoid mixing byte and character streams unless you explicitly manage encodings. For example, converting between InputStream and Reader should be done through helper classes such as InputStreamReader or OutputStreamWriter.

Buffered streams

To improve performance, Java provides buffered stream wrappers that minimize the number of I/O operations by reading or writing chunks of data at once. For example, wrapping a FileReader in a BufferedReader allows efficient reading of entire lines of text.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class ReadLines {
  public static void main(String[] args) {
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
      String line;
      while ((line = br.readLine()) != null) {
        System.out.println(line);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

Buffered streams can significantly speed up file access by reducing the number of system-level reads and writes. They are widely used for both binary and text I/O operations.

💡 You can chain stream wrappers together to combine features. For example, a BufferedWriter can wrap a FileWriter to add buffering, while an OutputStreamWriter can wrap an OutputStream to handle encoding.

java.nio.file and modern I/O

The java.nio.file package, introduced in Java 7, provides a modern, flexible, and efficient way to handle files and directories. Known as the NIO.2 API (New Input/Output), it was designed to overcome many of the limitations of the older java.io classes, offering better scalability, richer metadata handling, and improved exception reporting. It also integrates cleanly with the rest of the Java platform and supports advanced features such as symbolic links, file attributes, and asynchronous I/O.

Central to this package is the Path interface, which represents a platform-independent file or directory path. It replaces the older File class as the preferred abstraction for file system locations, supporting powerful operations such as joining, normalizing, and resolving paths.

Using Path and Files

The Paths class provides factory methods for creating Path objects, while the Files utility class offers static methods to perform most file operations, including reading, writing, copying, moving, and deleting files.

import java.nio.file.*;

public class NIOExample {
  public static void main(String[] args) {
    Path path = Paths.get("example.txt");

    try {
      // Write text to a file
      Files.writeString(path, "Hello, NIO.2!");

      // Read text from a file
      String content = Files.readString(path);
      System.out.println(content);

      // Copy the file
      Files.copy(path, Paths.get("copy.txt"), StandardCopyOption.REPLACE_EXISTING);

      // Delete the file
      Files.deleteIfExists(path);
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

This example demonstrates how concise and expressive the new API can be. The Files class eliminates much of the boilerplate code needed with traditional I/O, while still handling errors safely through exceptions.

💡 The Files class includes over one hundred static methods, covering almost every file system task imaginable, from metadata inspection to recursive directory traversal.

Working with directories

The NIO API also provides powerful directory handling via methods such as Files.list(), Files.walk(), and Files.newDirectoryStream(). These allow you to iterate through files efficiently, even across large directory trees.

import java.nio.file.*;
import java.io.IOException;

public class ListFiles {
  public static void main(String[] args) {
    Path dir = Paths.get(".");

    try {
      Files.list(dir)
           .filter(Files::isRegularFile)
           .forEach(System.out::println);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This code lists all regular files in the current directory. For recursive traversal, Files.walk() can be used instead, returning a stream of paths for an entire directory tree.

Reading and writing with streams

The NIO API supports stream-based file I/O that integrates seamlessly with the java.util.stream library, making it easy to process files using functional constructs.

import java.nio.file.*;
import java.io.IOException;

public class ReadLinesNIO {
  public static void main(String[] args) {
    Path file = Paths.get("data.txt");

    try {
      Files.lines(file)
           .filter(line -> line.contains("Java"))
           .forEach(System.out::println);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

Here, Files.lines() returns a lazily evaluated stream of strings, which can be filtered and processed using familiar functional operations. This allows concise, memory-efficient processing of large text files.

⚠️ Streams returned by methods such as Files.lines() or Files.walk() must be closed after use, either explicitly or with try-with-resources, to release underlying file handles.

File attributes and metadata

Unlike the older I/O API, NIO.2 provides detailed access to file metadata, such as size, creation time, last modified time, and permissions. These can be read or modified using the Files.getAttribute() and Files.setAttribute() methods, or through attribute views such as BasicFileAttributes.

import java.nio.file.*;
import java.nio.file.attribute.*;
import java.io.IOException;

public class FileInfo {
  public static void main(String[] args) {
    Path file = Paths.get("data.txt");

    try {
      BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
      System.out.println("Size: " + attrs.size());
      System.out.println("Created: " + attrs.creationTime());
      System.out.println("Modified: " + attrs.lastModifiedTime());
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This feature makes NIO.2 an ideal choice for systems programming, file synchronization, and applications that require detailed control over file metadata.

💡 NIO.2 supports platform-specific attribute views such as DosFileAttributes and PosixFileAttributes, allowing direct access to file permissions and flags on different operating systems.

Overall, java.nio.file represents the modern standard for file handling in Java. It is both easier to use and more powerful than the legacy I/O model, while remaining fully interoperable with it.

Reading and writing text and binary files

Reading from and writing to files is one of the most common I/O tasks in any Java program. The Java platform offers several ways to do this, ranging from simple text-based operations to efficient handling of large binary files. Whether you use the traditional java.io streams or the newer java.nio.file utilities, the same basic principles apply: open a file, process its contents, and close it safely when finished.

Reading text files

Text files are typically read line by line or all at once into memory. The NIO.2 API simplifies this greatly using methods such as Files.readString() or Files.readAllLines(), which automatically handle character encoding and resource management.

import java.nio.file.*;
import java.io.IOException;
import java.util.List;

public class ReadTextFile {
  public static void main(String[] args) {
    Path file = Paths.get("example.txt");

    try {
      List<String> lines = Files.readAllLines(file);
      for (String line : lines) {
        System.out.println(line);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

For large files or streaming scenarios, use Files.lines() instead, which returns a Stream<String> that can be processed lazily without loading the entire file into memory.

💡 When working with text files, always be aware of character encoding. Use Files.newBufferedReader() with a specific Charset (for example, StandardCharsets.UTF_8) if you need precise control.

Writing text files

Writing text to a file is just as simple. The Files.writeString() and Files.write() methods allow direct creation or replacement of files with string or byte content. For appending data, you can supply the StandardOpenOption.APPEND flag.

import java.nio.file.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class WriteTextFile {
  public static void main(String[] args) {
    Path file = Paths.get("output.txt");

    try {
      Files.writeString(file, "First line\n", StandardCharsets.UTF_8);
      Files.writeString(file, "Second line\n", StandardCharsets.UTF_8, 
        StandardOpenOption.APPEND);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This example writes text to a file in UTF-8 encoding, appending new content to the end. If the file does not exist, it will be created automatically.

⚠️ By default, writing to a file overwrites its existing contents. To preserve the original data, always use StandardOpenOption.APPEND or StandardOpenOption.CREATE_NEW.

Reading and writing binary files

Binary files contain raw data, such as images, audio, or serialized objects. These files are read and written using byte-oriented streams or NIO methods. The Files.readAllBytes() and Files.write() methods provide the simplest approach.

import java.nio.file.*;
import java.io.IOException;

public class CopyBinaryFile {
  public static void main(String[] args) {
    Path source = Paths.get("image.jpg");
    Path target = Paths.get("copy.jpg");

    try {
      byte[] data = Files.readAllBytes(source);
      Files.write(target, data);
      System.out.println("File copied successfully.");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This reads the entire binary file into a byte array and writes it to a new location. For larger files, however, streaming with buffered I/O is preferred to avoid excessive memory use.

Buffered I/O for efficiency

Using buffers can improve performance when dealing with large files by reducing the number of system calls. The BufferedInputStream and BufferedOutputStream classes wrap lower-level streams to provide this optimization.

import java.io.*;

public class BufferedCopy {
  public static void main(String[] args) {
    try (BufferedInputStream in = new BufferedInputStream(new FileInputStream("data.bin"));
         BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream("copy.bin"))) {
      byte[] buffer = new byte[4096];
      int bytesRead;
      while ((bytesRead = in.read(buffer)) != -1) {
        out.write(buffer, 0, bytesRead);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

Buffered I/O is especially useful for large or frequently accessed files, as it minimizes disk access and speeds up data transfer.

💡 The ideal buffer size depends on the system and file type, but 4 KB or 8 KB is a common choice. Experimenting with buffer sizes can yield noticeable performance improvements.

By mastering these text and binary techniques, you can efficiently handle all types of file data in Java, combining ease of use with fine-grained control where necessary.

Serialization basics

Serialization is the process of converting an object into a stream of bytes so that it can be saved to a file, sent over a network, or stored for later use. Deserialization reverses this process, reconstructing the object from its byte representation. Java provides built-in support for object serialization through the java.io.Serializable interface and associated stream classes.

Serialization allows programs to persist complex data structures and restore them exactly as they were, including the values of fields and object references. However, not all objects can or should be serialized, especially those that represent transient or external resources such as open files or sockets.

Implementing Serializable

To make a class serializable, you simply declare that it implements the Serializable interface. This is a marker interface that has no methods, but signals to the JVM that instances of the class can be written to and read from an ObjectOutputStream or ObjectInputStream.

import java.io.*;

class Person implements Serializable {
  private String name;
  private int age;

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

  public String toString() {
    return name + " (" + age + ")";
  }
}

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

    try (ObjectOutputStream out =
           new ObjectOutputStream(new FileOutputStream("person.dat"))) {
      out.writeObject(p);
      System.out.println("Object serialized successfully.");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This example serializes a Person object to a binary file. The ObjectOutputStream automatically handles the conversion of fields and references into a byte sequence that can later be deserialized.

Deserializing objects

Deserialization reconstructs the object from its serialized form. It must be performed using the same class definition (including the same serial version identifier, if one is set).

import java.io.*;

public class DeserializeExample {
  public static void main(String[] args) {
    try (ObjectInputStream in =
           new ObjectInputStream(new FileInputStream("person.dat"))) {
      Person p = (Person) in.readObject();
      System.out.println("Read object: " + p);
    } catch (IOException | ClassNotFoundException e) {
      e.printStackTrace();
    }
  }
}

The readObject() method returns a generic Object, which you must cast to the correct type. During deserialization, Java restores the original field values and object references, recreating the same logical structure that was saved.

⚠️ If the class definition has changed since serialization, or if the serialVersionUID does not match, a InvalidClassException will occur during deserialization.

Using serialVersionUID

Each serializable class has an associated identifier called serialVersionUID. This ID ensures that the serialized data is compatible with the class definition used to read it back. If you do not specify it explicitly, the JVM generates one automatically based on class details, which can lead to compatibility issues if the class is later modified.

private static final long serialVersionUID = 1L;

Including a constant serial version ID helps maintain forward and backward compatibility across versions of your code, especially when objects are stored or transmitted between different systems.

Transient and static fields

By default, all non-static fields of a serializable class are saved. If you want to exclude specific fields from serialization, mark them as transient. Static fields are also not serialized because they belong to the class, not the instance.

class User implements Serializable {
  private String username;
  private transient String password; // will not be serialized
}

Marking sensitive data as transient protects it from being written to disk or transmitted insecurely. Upon deserialization, transient fields are initialized with their default values.

💡 You can customize serialization by defining private writeObject() and readObject() methods inside your class. These let you encrypt data, validate input, or perform version conversions during the process.

Limitations and alternatives

While Java’s built-in serialization is convenient, it is not always ideal for long-term data storage or for communication between different programming environments. Serialized data is JVM-specific, may expose internal details, and is not human-readable. For interoperability, formats such as JSON, XML, or protocol buffers are often preferred.

Nevertheless, serialization remains an important feature within Java itself, used widely in frameworks such as RMI, HTTP session storage, and certain caching systems.

Character encodings and buffering

Text in Java is represented internally using Unicode, which can encode virtually every character from all writing systems. However, files and network streams store text as sequences of bytes, so encoding and decoding are required when converting between in-memory characters and external data sources. Understanding character encodings and how to manage them is essential for writing portable, reliable I/O code.

Character encodings

A character encoding defines how characters are mapped to bytes. Common encodings include UTF-8 (the default in modern Java versions), UTF-16, and ISO-8859-1. When reading or writing text, Java must know which encoding to use, or characters may become garbled.

The java.nio.charset package provides the Charset class for working with encodings, including standard constants defined in StandardCharsets. You can specify an encoding explicitly when using readers, writers, or NIO methods.

import java.nio.file.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class EncodingExample {
  public static void main(String[] args) {
    Path file = Paths.get("utf8.txt");

    try {
      Files.writeString(file, "Café", StandardCharsets.UTF_8);
      String text = Files.readString(file, StandardCharsets.UTF_8);
      System.out.println(text);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

This example ensures that both writing and reading use UTF-8, preserving accented and special characters correctly. If no encoding is specified, Java will use the platform default, which can vary by system and lead to inconsistent results.

⚠️ Always specify an explicit encoding when reading or writing text files. Relying on the platform default can cause unexpected behaviour when moving code between systems.

Reader and Writer conversion

When working with byte streams, conversion to or from characters is handled using InputStreamReader and OutputStreamWriter. These bridge classes apply a specific encoding while translating between bytes and characters.

import java.io.*;
import java.nio.charset.StandardCharsets;

public class ReaderWriterExample {
  public static void main(String[] args) {
    try (OutputStreamWriter writer =
           new OutputStreamWriter(new FileOutputStream("text.txt"), StandardCharsets.UTF_8)) {
      writer.write("Hello, 世界");
    } catch (IOException e) {
      e.printStackTrace();
    }

    try (InputStreamReader reader =
           new InputStreamReader(new FileInputStream("text.txt"), StandardCharsets.UTF_8)) {
      int ch;
      while ((ch = reader.read()) != -1) {
        System.out.print((char) ch);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

These classes are particularly useful when dealing with mixed byte and character data, such as when reading text from a network socket or compressing encoded data streams.

💡 Wrapping readers and writers with buffering classes such as BufferedReader and BufferedWriter can greatly improve efficiency, especially when processing large text files.

Buffered reading and writing

Buffered I/O improves performance by reducing the number of physical read and write operations. Instead of reading or writing each character individually, buffered streams operate on blocks of data stored temporarily in memory.

import java.io.*;
import java.nio.charset.StandardCharsets;

public class BufferedTextIO {
  public static void main(String[] args) {
    try (BufferedWriter writer = new BufferedWriter(
           new OutputStreamWriter(new FileOutputStream("buffered.txt"), StandardCharsets.UTF_8))) {
      writer.write("Buffered output example");
    } catch (IOException e) {
      e.printStackTrace();
    }

    try (BufferedReader reader = new BufferedReader(
           new InputStreamReader(new FileInputStream("buffered.txt"), StandardCharsets.UTF_8))) {
      String line = reader.readLine();
      System.out.println(line);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

Using buffers ensures smoother performance and helps reduce latency in high-volume I/O scenarios. Buffered streams are standard practice for almost all file-based operations in Java.

Choosing the right approach

The combination of correct encoding and buffering ensures both correctness and speed in text-based I/O. For most modern applications, UTF-8 encoding and buffered readers or writers provide the best balance between compatibility and performance. For binary data or large-scale operations, the java.nio.file API offers even greater efficiency through memory mapping and non-blocking channels, which are covered in later chapters.

💡 You can check available character sets on your system with Charset.availableCharsets(). This returns a sorted map of encoding names supported by your Java runtime.

With a clear understanding of character encoding and buffered I/O, you can reliably process text files across platforms and locales without corruption or performance issues.