W3docs

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:

  1. java.io streams — the original Java 1.0 API: InputStream/OutputStream for bytes, Reader/Writer for characters, and the Buffered*, Data*, Print* decorators that wrap them.
  2. java.io.File — the legacy "this string is a path" class. Still everywhere in older code; superseded by java.nio.file.Path for new work.
  3. 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.
  4. 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() throws

You 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() returns false for "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.File API — its query methods, listing, and the limits that motivated java.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.io decorator 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.

java— editable, runs on the server

What to take from the run:

  • The same file got written four different ways. Files.writeString is the shortest path for "drop this string in this file"; FileWriter is the classic raw writer; BufferedWriter adds an in-memory buffer (cheap when you write many small chunks); PrintWriter adds printf. Each overwrote the previous content because the default open mode is "truncate then write" — you have to pass StandardOpenOption.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.readString returned the whole file as one String — fine for small files, the wrong choice for a 4 GB log. Files.lines returned a Stream<String> you can pipeline through filter, map, and count without holding the whole file in memory. The required try-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 — a Predicate value, passed to Stream.filter. Files.lines is where the streams API meets the I/O API.
  • Files.deleteIfExists is the no-exception delete: returns true if it removed the file, false if it wasn't there. The legacy File.delete() returns a boolean for both "deleted" and "couldn't delete" — Files distinguishes 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.

Practice

Practice
Why does `java.io` separate `InputStream`/`OutputStream` from `Reader`/`Writer`?
Why does `java.io` separate `InputStream`/`OutputStream` from `Reader`/`Writer`?
Was this page helpful?