06 · File I/O & NIO¶
Level 1's contact book project
already used Files.newBufferedReader/newBufferedWriter to persist data.
This module rounds out classic java.io and covers the modern
java.nio.file API in more depth.
Classic java.io recap¶
The original file APIs are stream- and reader/writer-based, wrapped for efficiency with buffering:
import java.io.*;
// Writing
try (BufferedWriter writer = new BufferedWriter(new FileWriter("notes.txt"))) {
writer.write("First line");
writer.newLine();
writer.write("Second line");
}
// Reading
try (BufferedReader reader = new BufferedReader(new FileReader("notes.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
// Output:
// First line
// Second line
This still works fine, but java.nio.file (introduced in Java 7, "NIO.2") is
more convenient for most everyday tasks and is the modern default.
Path and Paths.get¶
A Path represents a filesystem location — it doesn't have to exist yet, and
no I/O happens just by creating one.
import java.nio.file.Path;
import java.nio.file.Paths;
Path file = Paths.get("data", "reports", "summary.txt"); // data/reports/summary.txt
Path fromString = Path.of("data/reports/summary.txt"); // Path.of is the newer, preferred form
System.out.println(file.getFileName()); // summary.txt
System.out.println(file.getParent()); // data/reports
System.out.println(file.toAbsolutePath()); // full path from filesystem root
Reading and writing whole files¶
For small-to-medium text files, Files offers one-line read/write helpers —
no manual buffering, no manual close().
import java.nio.file.Files;
import java.util.List;
Path path = Path.of("greeting.txt");
Files.writeString(path, "Hello, NIO!\n"); // write a String directly
Files.write(path, List.of("line one", "line two")); // write a List<String>, one per line
List<String> lines = Files.readAllLines(path); // read entire file into a List<String>
String contents = Files.readString(path); // read entire file into one String
System.out.println(lines);
// [line one, line two]
Checking, creating, copying, deleting¶
import java.nio.file.*;
Path dir = Path.of("output");
Path source = Path.of("greeting.txt");
Path target = Path.of("output/greeting-copy.txt");
if (!Files.exists(dir)) {
Files.createDirectories(dir); // creates all missing parent directories too
}
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println(Files.exists(target)); // true
System.out.println(Files.size(target)); // size in bytes
Files.delete(target); // throws NoSuchFileException if it doesn't exist
Files.deleteIfExists(target); // safe no-op version
| Operation | Method |
|---|---|
| Check existence | Files.exists(path) / Files.notExists(path) |
| Create directory (and parents) | Files.createDirectories(path) |
| Copy | Files.copy(source, target, options...) |
| Move / rename | Files.move(source, target, options...) |
| Delete | Files.delete(path) / Files.deleteIfExists(path) |
| Read all lines | Files.readAllLines(path) |
| Read whole file | Files.readString(path) |
| Write lines/string | Files.write(path, lines) / Files.writeString(path, text) |
Walking a directory tree¶
Files.walk and Files.list return a Stream<Path>, so directory traversal
plugs directly into the Streams API from
Module 4. Both return I/O-backed streams, so they
must be closed — a try-with-resources block does that automatically.
import java.nio.file.*;
import java.util.stream.Stream;
// Files.list -- only the immediate children of a directory
try (Stream<Path> entries = Files.list(Path.of("."))) {
entries.filter(Files::isRegularFile)
.forEach(System.out::println);
}
// Files.walk -- recurses into subdirectories too
try (Stream<Path> allFiles = Files.walk(Path.of("."))) {
long javaFileCount = allFiles
.filter(p -> p.toString().endsWith(".java"))
.count();
System.out.println("Java files found: " + javaFileCount);
}
Appending to an existing file¶
By default Files.write/writeString overwrite the target. Pass
StandardOpenOption.APPEND to add to the end instead — useful for simple log
files:
Files.writeString(
Path.of("log.txt"),
"New entry\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND
);
How It Actually Works¶
Classic java.io streams are byte-at-a-time blocking abstractions
over OS file descriptors — every unbuffered read() is a system call,
which is why wrapping in BufferedInputStream/BufferedReader matters:
it batches many logical reads into far fewer syscalls by filling an
internal byte array once and serving subsequent reads from memory.
java.nio channels work differently: they're built around
ByteBuffer, a fixed-capacity block of memory with position/limit/
capacity markers, and channels can transfer data directly between a file
and a buffer without the byte-by-byte copying overhead of streams.
FileChannel.map() goes further and uses mmap() to map a file region
directly into the process's virtual address space — reads and writes
become plain memory accesses the OS page cache satisfies, with no
explicit read/write syscall per access, which is why memory-mapped I/O
dramatically outperforms stream I/O for large, randomly-accessed files.
Files.readAllLines() and NIO's WatchService (for directory-change
notification) both rely on native OS APIs under a uniform Java API —
WatchService on Linux is backed by inotify, on macOS by FSEvents
via a polling/kqueue bridge — meaning latency and exact semantics (e.g.
whether a rename fires two events or one) are genuinely OS-dependent
even though the Java code is portable.
Exercise¶
Write a method logMessage(String message) that appends message plus a
timestamp-free newline to a file app.log, creating the file (and a logs/
parent directory) if it doesn't exist yet. Write a second method
countLogEntries() that returns how many lines are currently in app.log
using Files.readAllLines. Call logMessage three times with different
strings, then print the result of countLogEntries() to confirm it reports
3.