Java

Chapter 14: Java I/O and NIO.2 – File Handling and Data Streams

By Ali Naqi | October 24, 2026

Chapter 14: Java I/O and NIO.2 – File Handling and Data Streams

💾 Chapter 14: Java I/O and NIO.2 – File Handling and Data Streams


## 1. The Core Abstraction: Streams vs. Channels


In Java I/O, the term "stream" means something different from the Stream API covered in Chapter 13. Here, an **I/O Stream** represents a continuous, unidirectional sequence of data flowing between your application and an external source or destination.


* **Input Stream / Reader:** Data flows **into** your program (reading).

* **Output Stream / Writer:** Data flows **out of** your program (writing).


Java divides classic I/O (`java.io`) into two distinct categories:


| Dimension | Byte Streams (`java.io.*Stream`) | Character Streams (`java.io.*Reader` / `*Writer`) |

| --- | --- | --- |

| **Data Unit** | 8-bit raw bytes | 16-bit Unicode characters |

| **Primary Use** | Binary files (images, audio, PDF, compiled `.class` files) | Human-readable text files (`.txt`, `.json`, `.csv`) |

| **Base Abstract Classes** | `InputStream`, `OutputStream` | `Reader`, `Writer` |

| **Encoding Handling** | Agnostic; moves raw binary chunks directly | Decodes bytes to characters using a `Charset` (e.g., UTF-8) |


---


## 2. Resource Leaks and the `try-with-resources` Statement


Working with operating system resources (file handles, database connections, sockets) comes with a strict rule: **you must close them when finished**. Failing to do so leaks system handles, locks files on disk, and eventually exhausts OS limits.


Before Java 7, closing resources required nested `try-catch-finally` blocks that were notoriously brittle. Java 7 introduced the **`try-with-resources`** construct, which guarantees cleanup for any object implementing `java.lang.AutoCloseable`.


```java

// Modern standard: try-with-resources automatically invokes .close() on exit

try (FileInputStream fis = new FileInputStream("app.log")) {

    int byteData;

    while ((byteData = fis.read()) != -1) {

        // Process raw byte

    }

} catch (IOException e) {

    System.err.println("Failed to read log: " + e.getMessage());

}

// fis is guaranteed to be closed here, even if an exception was thrown


```


---


## 3. Classic Character Streams: Readers and Writers


When dealing with text files, reading one character or byte at a time directly from disk causes massive performance degradation. Each single-byte read triggers an operating system system-call (syscall).


To fix this, we wrap base streams inside **Buffered Wrappers** (`BufferedReader`, `BufferedWriter`). These maintain an in-memory buffer (typically 8 KB), reading large blocks from the OS at once and letting your code parse them line by line.


```java

import java.io.*;

import java.nio.charset.StandardCharsets;


public class ClassicTextIO {

    public static void main(String[] args) {

        File targetFile = new File("inventory.csv");


        // 1. Writing Text Efficiently

        try (BufferedWriter writer = new BufferedWriter(

                new OutputStreamWriter(new FileOutputStream(targetFile), StandardCharsets.UTF_8))) {

            

            writer.write("SKU,Item,Price");

            writer.newLine();

            writer.write("KB-99,Mechanical Keyboard,119.99");

            writer.newLine();

            writer.write("MS-40,Ergonomic Mouse,49.50");

            writer.newLine();

        } catch (IOException e) {

            e.printStackTrace();

        }


        // 2. Reading Text Line-by-Line

        try (BufferedReader reader = new BufferedReader(

                new InputStreamReader(new FileInputStream(targetFile), StandardCharsets.UTF_8))) {

            

            String line;

            while ((line = reader.readLine()) != null) {

                System.out.println("Row: " + line);

            }

        } catch (IOException e) {

            e.printStackTrace();

        }

    }

}


```


---


## 4. Modern File Operations: The NIO.2 Standard (`java.nio.file`)


Introduced in Java 7 and refined in subsequent releases, **NIO.2** completely modernized Java's approach to the filesystem. It fixed long-standing bugs with `java.io.File` (such as poor error reporting and lack of symbolic link support).


In modern Java, you should prefer NIO.2 over `java.io.File` for almost all file operations. It is organized around three core classes:


1. **`Path`**: A modern, platform-independent representation of a hierarchical file path (replacing `java.io.File`).

2. **`Paths` / `Path.of()**`: Factory utility to construct `Path` objects.

3. **`Files`**: A powerhouse utility class packed with static methods for reading, writing, copying, deleting, and traversing.


```java

import java.io.IOException;

import java.nio.file.*;

import java.util.List;


public class NioBasics {

    public static void main(String[] args) throws IOException {

        // Constructing a Path

        Path configDir = Path.of("app", "config");

        Path configFile = configDir.resolve("application.properties");


        // Creating directories recursively (mkdir -p)

        if (!Files.exists(configDir)) {

            Files.createDirectories(configDir);

        }


        // Writing all lines in one atomic call

        List<String> configurations = List.of(

            "server.port=8080",

            "db.pool.size=10",

            "cache.enabled=true"

        );

        Files.write(configFile, configurations, StandardOpenOption.CREATE, StandardOpenOption.WRITE);


        // Reading small-to-medium files instantly

        List<String> loadedLines = Files.readAllLines(configFile);

        System.out.println("Config contents: " + loadedLines);


        // Inspection utilities

        System.out.println("File size (bytes): " + Files.size(configFile));

        System.out.println("Is readable? " + Files.isReadable(configFile));

    }

}


```


---


## 5. Bridging NIO.2 with the Stream API


One of the cleanest features of `java.nio.file.Files` is its ability to produce lazy `java.util.stream.Stream` instances directly from the file system. This allows you to process massive files without loading their entire contents into memory.


### Reading Large Files Lazily via `Files.lines()`


`Files.readAllLines()` loads every line into a `List<String>` on the Heap, which can trigger an `OutOfMemoryError` on multi-gigabyte log files. `Files.lines()` streams the file line by line, keeping memory consumption low:


```java

import java.io.IOException;

import java.nio.file.Files;

import java.nio.file.Path;

import java.util.stream.Stream;


public class LogAnalyzer {

    public static void main(String[] args) {

        Path logFilePath = Path.of("server-access.log");


        // The Stream holds an open file handle; wrap it in try-with-resources!

        try (Stream<String> lineStream = Files.lines(logFilePath)) {

            long errorCount = lineStream

                .filter(line -> line.contains("[ERROR]"))

                .filter(line -> line.contains("DatabaseTimeout"))

                .count();


            System.out.println("Total Database Timeout Errors: " + errorCount);

        } catch (IOException e) {

            System.err.println("File read failed: " + e.getMessage());

        }

    }

}


```


### Traversing Directory Trees via `Files.walk()`


Searching through nested directories used to require writing custom recursive functions. NIO.2 handles this with `Files.walk()`:


```java

import java.io.IOException;

import java.nio.file.*;

import java.util.stream.Stream;


public class DirectoryScanner {

    public static void main(String[] args) {

        Path projectRoot = Path.of(".");


        // Depth-first traversal of the filesystem

        try (Stream<Path> paths = Files.walk(projectRoot, 5)) {

            paths.filter(Files::isRegularFile)

                 .filter(path -> path.toString().endsWith(".java"))

                 .forEach(path -> System.out.println("Found Java Source: " + path));

        } catch (IOException e) {

            e.printStackTrace();

        }

    }

}


```


---


## 6. Object Serialization: Preserving State


**Serialization** is the process of converting an in-memory object's state into a binary format that can be stored on disk or sent over a network. **Deserialization** reverses this, reconstructing the live object from the byte stream.


### Key Rules of Serialization:


1. The class must implement the `java.io.Serializable` marker interface.

2. Every field inside the class must also be serializable (or marked `transient`).

3. **`transient` Keyword:** Fields marked `transient` are skipped during serialization. This is used for sensitive data (e.g., passwords) or temporary cache state.

4. **`serialVersionUID`:** A unique version identifier for the class structure. If you change class fields without updating this ID, deserializing old data throws an `InvalidClassException`.


```java

import java.io.*;


class UserSession implements Serializable {

    @Serial

    private static final long serialVersionUID = 1L;


    private final String username;

    private final String role;

    

    // transient fields are never written to disk

    private transient String sessionToken;


    public UserSession(String username, String role, String sessionToken) {

        this.username = username;

        this.role = role;

        this.sessionToken = sessionToken;

    }


    @Override

    public String toString() {

        return "UserSession[user=" + username + ", role=" + role + ", token=" + sessionToken + "]";

    }

}


public class SerializationDemo {

    public static void main(String[] args) {

        File file = new File("session.ser");


        // 1. Serialize object to disk

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) {

            UserSession activeUser = new UserSession("dev_alex", "ADMIN", "secret_jwt_xyz123");

            oos.writeObject(activeUser);

            System.out.println("Serialized: " + activeUser);

        } catch (IOException e) {

            e.printStackTrace();

        }


        // 2. Deserialize object from disk

        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {

            UserSession restoredUser = (UserSession) ois.readObject();

            // Notice sessionToken is restored as null because it was marked transient

            System.out.println("Restored: " + restoredUser);

        } catch (IOException | ClassNotFoundException e) {

            e.printStackTrace();

        }

    }

}


```


---


## 7. Practical Example: A Fast Audit-Log Archiver


Let's combine `NIO.2`, streams, and path utilities into an administrative utility: an archive tool that scans a log directory, locates log files older than a certain size threshold, aggregates their contents, and moves the raw files into an archive folder.


```java

import java.io.BufferedWriter;

import java.io.IOException;

import java.nio.file.*;

import java.util.List;

import java.util.stream.Stream;


public class LogArchiver {


    public static void archiveLogs(Path sourceDir, Path targetDir, long sizeThresholdBytes) throws IOException {

        // Ensure directories exist

        if (!Files.exists(targetDir)) {

            Files.createDirectories(targetDir);

        }


        Path consolidatedReport = targetDir.resolve("archived-summary.log");


        // Collect qualifying files

        List<Path> filesToArchive;

        try (Stream<Path> stream = Files.list(sourceDir)) {

            filesToArchive = stream

                .filter(Files::isRegularFile)

                .filter(path -> path.toString().endsWith(".log"))

                .filter(path -> {

                    try {

                        return Files.size(path) >= sizeThresholdBytes;

                    } catch (IOException e) {

                        return false;

                    }

                })

                .toList();

        }


        System.out.println("Found " + filesToArchive.size() + " files exceeding threshold.");


        // Consolidate entries into a single archive file

        try (BufferedWriter writer = Files.newBufferedWriter(consolidatedReport, 

                StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {

            

            for (Path logPath : filesToArchive) {

                writer.write("=== ARCHIVED FROM: " + logPath.getFileName() + " ===");

                writer.newLine();


                try (Stream<String> lines = Files.lines(logPath)) {

                    lines.forEach(line -> {

                        try {

                            writer.write(line);

                            writer.newLine();

                        } catch (IOException e) {

                            throw new RuntimeException(e);

                        }

                    });

                }


                // Move file to archive directory

                Path destination = targetDir.resolve(logPath.getFileName());

                Files.move(logPath, destination, StandardCopyOption.REPLACE_EXISTING);

                System.out.println("Relocated: " + logPath.getFileName() + " -> " + destination);

            }

        }

    }


    public static void main(String[] args) {

        Path logs = Path.of("server-logs");

        Path archive = Path.of("server-logs", "archive");


        try {

            // Setup demo files

            Files.createDirectories(logs);

            Files.writeString(logs.resolve("service-1.log"), "2026-10-24 10:00:01 INFO Service started\n");

            Files.writeString(logs.resolve("service-2.log"), "2026-10-24 10:00:05 ERROR Worker crashed with OOM\n");


            // Archive files larger than 10 bytes

            archiveLogs(logs, archive, 10L);

        } catch (IOException e) {

            System.err.println("Archival process failed: " + e.getMessage());

        }

    }

}


```


---


## 📝 Chapter Summary


* **Byte Streams** (`InputStream`/`OutputStream`) handle raw binary data; **Character Streams** (`Reader`/`Writer`) handle text encoding via character sets.

* Always wrap bare file streams in buffered decorators (`BufferedReader`/`BufferedWriter`) to avoid performance-killing per-byte system calls.

* Use **`try-with-resources`** on any `AutoCloseable` instance to prevent resource leaks and OS handle exhaustion.

* Standardize on **NIO.2 (`java.nio.file`)**:

* Use `Path.of()` rather than `java.io.File`.

* Use `Files.readAllLines()` for small files, and `Files.lines()` for lazy, memory-efficient streaming of large datasets.

* Use `Files.walk()` for recursive directory tree traversals.



* **Serialization** enables saving live object graphs to disk using the `Serializable` marker interface; use `transient` to shield sensitive or non-serializable fields from disk writes.