💾 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.