Java I/O Introduction
An overview of Java I/O: byte vs. character streams, buffered I/O, java.io vs. java.nio.file.
Part 12 ended with a vocabulary you can carry directly into this part: lambdas, Consumer<T> and Supplier<T> as the shapes behind "give me a line" and "do something with this line," try-with-resources for anything that needs deterministic cleanup, and the Stream pipeline for line-oriented data. Java's I/O APIs were designed around exactly those shapes — long before the words "functional interface" existed, the underlying objects already had one method each, and the post-Java-8 facade brought the rest of the way.
This part covers four overlapping toolkits:
java.iostreams — the original Java 1.0 API:InputStream/OutputStreamfor bytes,Reader/Writerfor characters, and theBuffered*,Data*,Print*decorators that wrap them.java.io.File— the legacy "this string is a path" class. Still everywhere in older code; superseded byjava.nio.file.Pathfor new work.java.nio.file— the modern API (Java 7+):Path,Files, and the static helpers (Files.readString,Files.writeString,Files.lines,Files.walk) that turn most file operations into one line.- Serialization — turning object graphs into bytes and back with
ObjectOutputStream/ObjectInputStream.
The first six chapters work through the high-level file operations (open, create, read, write, delete) using both java.io and java.nio.file so you can see the same task two ways. The middle chapters zoom into the stream classes themselves — byte vs character, buffering, data, print. The last chapters cover serialization and the path/walk API.
The byte/character split
Every I/O API in java.io is one of two shapes:
InputStream / OutputStream — byte-oriented (raw bytes: int read() returns 0..255 or -1)
Reader / Writer — character-oriented (decoded text: int read() returns a char or -1)The split is not cosmetic. Bytes are what disks and sockets store; characters are what humans read. A .png is bytes; a .txt is also bytes on disk but you usually want it decoded into characters using a charset. Mixing the two without a charset is the single most common source of "weird character" bugs in legacy Java.
The bridge classes — InputStreamReader and OutputStreamWriter — convert between the two and take a Charset argument. Use StandardCharsets.UTF_8 unless you have a documented reason to use something else; the no-argument forms use the platform default, which differs across operating systems and is the textbook source of "works on my Mac, broken on the Linux server" bugs.
The decorator pattern
java.io is built on the decorator pattern: a small set of raw streams (FileInputStream, FileOutputStream, FileReader, FileWriter) wrapped in layered functionality (buffering, line-by-line text, primitive types, formatted output). You compose what you need at the call site:
// Read a UTF-8 text file line by line:
try (BufferedReader in = new BufferedReader(
new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8))) {
String line;
while ((line = in.readLine()) != null) {
System.out.println(line);
}
}Three layers, bottom up: FileInputStream reads raw bytes; InputStreamReader decodes them as UTF-8 characters; BufferedReader adds an in-memory buffer and the readLine() method. Each layer is a separate class with one job. Java 11 cleaned this exact pattern up to one line — Files.newBufferedReader(path) — but the decoration is still what happens underneath.
try-with-resources is the rule
Every stream, reader, writer, and channel in java.io and java.nio implements AutoCloseable. Closing matters: an unclosed FileOutputStream can lose its tail buffer; an unclosed socket leaks a file descriptor; an unclosed reader on Windows holds a lock the OS won't release. The try-with-resources construct (Java 7+) guarantees close() is called on every successful and exceptional path:
try (BufferedReader in = Files.newBufferedReader(path)) {
return in.readLine();
} // close() runs here, even if readLine() throwsYou can declare more than one resource in the same try; they're closed in reverse order. Older try/finally code that calls close() by hand is wrong nearly every time — the inner exception swallows the close exception, or the close itself is forgotten on the error path. Use try-with-resources for anything that opens a handle.
java.io versus java.nio.file
java.io.File (1996) modelled a path as a String and offered a handful of operations (exists, isFile, delete, listFiles). The class is still everywhere in legacy code, and many APIs still return or accept File. But it has limits the JDK no longer pretends about:
- No way to ask why an operation failed —
file.delete()returnsfalsefor "the file doesn't exist," "permission denied," and "the file is open." You can't tell which. - No support for symbolic links, file attributes, permissions, or atomic operations.
- No way to walk a directory tree without writing the recursion yourself.
java.nio.file (Java 7) replaces it. Path is the new "this is a path" type, and Files is a static utility class with around 80 methods for everything you'd want to do with one:
Path p = Path.of("data", "users.txt"); // platform-independent path
String text = Files.readString(p, StandardCharsets.UTF_8); // whole file, one call
List<String> lines = Files.readAllLines(p, StandardCharsets.UTF_8);
Files.writeString(p, "hello\n", StandardCharsets.UTF_8);
try (Stream<String> s = Files.lines(p, StandardCharsets.UTF_8)) {
s.filter(l -> !l.isBlank()).forEach(System.out::println);
}Two things to notice. First, Files.lines(path) returns a Stream<String> — the stream pipeline you learned in Part 12 reads files directly. Second, the stream owns an open file handle, so the try-with-resources wrapper is required — without it, the file stays open until the next GC.
Throughout Part 13 we'll show both APIs side by side. New code should reach for java.nio.file first; the legacy chapters exist because you'll meet the older shapes in every codebase older than Java 11.
Where this part is going
- The next chapter, Java File Class, walks the legacy
java.io.FileAPI — its query methods, listing, and the limits that motivatedjava.nio.file. - The four chapters after that (Creating Files, Reading Files, Writing Files, Deleting Files) cover the high-level "do one thing to a file" operations using both APIs.
- Chapters on byte, character, and buffered streams then zoom into the underlying
java.iodecorator stack. - Serialization, then
Path,Files, and the directory walk API close the part.
A worked example: same task, four ways
The program below writes one short text file four ways — once with the modern Files.writeString, once with the classic FileWriter + try-with-resources, once decorated with BufferedWriter, and once with PrintWriter for formatted output. It then reads the file back twice — once with Files.readString (whole file, one call) and once with Files.lines as a Stream<String> filtered with a Predicate<String>. The example uses a system temp file so it works in any sandbox.
What to take from the run:
- The same file got written four different ways.
Files.writeStringis the shortest path for "drop this string in this file";FileWriteris the classic raw writer;BufferedWriteradds an in-memory buffer (cheap when you write many small chunks);PrintWriteraddsprintf. Each overwrote the previous content because the default open mode is "truncate then write" — you have to passStandardOpenOption.APPEND(covered in the writing chapter) to add to a file. - Every writer ran inside
try-with-resources. Skipping that on a buffered writer is the bug where the last few characters never reach the disk —close()is what flushes the tail buffer. Files.readStringreturned the whole file as oneString— fine for small files, the wrong choice for a 4 GB log.Files.linesreturned aStream<String>you can pipeline throughfilter,map, andcountwithout holding the whole file in memory. The requiredtry-with-resources on the stream is because the stream owns an open file handle.- The
Predicate<String> nameLine = l -> l.startsWith("name")line is the same vocabulary from Part 12 — aPredicatevalue, passed toStream.filter.Files.linesis where the streams API meets the I/O API. Files.deleteIfExistsis the no-exception delete: returnstrueif it removed the file,falseif it wasn't there. The legacyFile.delete()returns abooleanfor both "deleted" and "couldn't delete" —Filesdistinguishes the two by throwing.
What's next
Before the modern java.nio.file API takes over the rest of the part, the next chapter covers the class you'll meet first in any older codebase: Java File Class. It's the legacy path-and-metadata type — limited, but everywhere — and seeing what it can't do is what makes the case for Path and Files.