Advanced

I/O, Files & NIO.2

I/O moves bytes (binary) or characters (text, through an encoding). NIO.2, with Path and Files, is the modern, concise file API, and Files.list and Files.walk handle directories. Files.lines() returns a java.util.stream.Stream, which clears up the name clash between I/O "streams" and the Stream API. Text is bytes through a charset (UTF-8), so one character can take several bytes, and the wrong charset mangles it. Readers, writers and buffering batch the costly system calls, and serialization turns objects into bytes, with a security warning attached. Every behavior is shown with verified output.

Suggest an edit

I/O, Files & NIO.2 — Reading and Writing the World

A program that can't read or write anything outside itself is sealed off from the world. I/O (input/output) connects it to files, the network and the console. It comes in two flavours:

  • Byte I/O moves raw binary data.
  • Character I/O moves text: bytes interpreted through a charset (an encoding), such as UTF-8.

The modern file API, NIO.2 (java.nio.file, since JDK 7), is built on Path (a location) and Files (static operations), and it turns common tasks into one-liners. It also bridges to the Streams API: Files.lines() returns a java.util.stream.Stream<String>. That's the perfect moment to clear up a real source of confusion — the word "stream" means two different things in Java: an I/O byte stream, and the functional Stream pipeline.

Three more realities round out the picture. Text is bytes through an encoding, so one character can take several bytes. Buffering batches up the expensive system calls that I/O is made of. And serialization turns whole objects into bytes — with a security warning attached.

💡 The core idea.

  • I/O moves bytes (binary) or characters (text through a charset like UTF-8).
  • NIO.2 — Path and Files — is the modern, one-line file API.
  • Files.lines() returns a java.util.stream.Stream, clearing the "stream" name clash.
  • Text is bytes-through-an-encoding, and buffering batches the costly system calls.

This lesson uses streams and try-with-resources. Every output below was produced by compiling and running the code, and each program writes and reads files in its own working directory.

You'll be able to: read and write files and list directories with Path and Files, and predict where a missing file fails; choose between readAllLines and Files.lines, and close a file-backed stream; predict a file's byte and character counts, and what the wrong charset does to text; read the console and files line by line with a BufferedReader, and explain why buffering matters; serialize and deserialize an object, and predict what transient and a non-Serializable class do.

📘 How to read the Intuition boxes. Each one is built in three moves:

  1. The mechanism — what the compiler and the JVM do.
  2. A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
  3. The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.

Table of contents

  1. NIO.2: Path and Files
  2. Reading lines as text
  3. The Stream name clash
  4. Bytes vs characters
  5. Readers, writers, the console and buffering
  6. Directories: Files.list and Files.walk
  7. Serialization
  8. Mental-model summary
  9. Gotcha checklist
  10. Check yourself
  11. Sources

1. NIO.2: Path and Files

A Path names the location of a file or directory. The Files class holds static methods that operate on paths: writeString, readString, size, and dozens more [1]. What used to take many lines of open-a-stream-and-close-it ceremony becomes a single call.

Output:

Hello, file!
Second line.
size: 26 bytes

Analysis. Four calls, four jobs:

  • Path.of("greeting.txt") named a file.
  • Files.writeString created it and wrote the text — opening, writing and closing, all in one call.
  • Files.readString read it all back.
  • Files.size reported 26 bytes: the 24 visible characters, plus two newlines.

There are no streams to open or close by hand, because Files handles the resource management for you. The methods declare IOException, a checked exception, so main must handle or declare it.

A Path also has operations of its own, and none of them touches the disk [2]:

Output:

resolve:   /home/ada/projects/demo/../notes.txt
normalize: /home/ada/projects/notes.txt
fileName:  notes.txt
parent:    /home/ada/projects
relativize: ../photos/cat.png
absolute?  false
  • resolve joins one path onto another, and normalize removes the .. and . parts.
  • getFileName and getParent split a path apart, and relativize gives the path from one location to another.
  • None of these directories exists, yet nothing failed, because a Path is only a name. The output uses /, the separator on Linux and macOS.

Intuition. Mechanism. A Path is a value that describes a location; it never touches the disk. The Files methods are where the I/O actually happens. Each one performs the open-operate-close cycle, and translates operating-system errors into Java exceptions.

Concrete bite. Since a Path is only a name, operating on one that doesn't exist fails at the Files call, not at Path.of:

Output (a thrown exception):

Exception in thread "main" java.nio.file.NoSuchFileException: does-not-exist.txt

Path.of(...) succeeded, because it's only a name. The failure came when Files.readString tried to read a file that isn't there. Constructing a Path never throws for a missing file; the Files operation does.

💡 Earned rule. Use NIO.2 (Path + Files) for file work: it's concise, it manages resources for you, and it gives precise exceptions. Reach for raw streams only when you need fine-grained control.

The cost is handling IOException, since file I/O can fail — through missing files, permissions or full disks. The payoff is one-line reads and writes, instead of the open-read-close-in-finally boilerplate of the old java.io API.


2. Reading lines as text

Most files are line-oriented text. Files.write(path, List<String>) writes each element as a line, and Files.readAllLines reads them all back into a List<String>.

Output:

lines: 3
banana

Analysis. Files.write wrote three lines, adding a line separator after each one. Files.readAllLines read them into a List of three strings, so lines.get(1) is the second one, "banana". This is the everyday text-file pattern: write a list of lines, read back a list of lines. The encoding (UTF-8, for these methods) is handled for you, and so is closing the file.

Intuition. Mechanism. readAllLines reads the entire file into memory as a List<String>. It splits on line terminators, and decodes bytes into characters through the charset. It's eager: convenient for small and medium files, with everything in memory at once.

Concrete bite. "Everything in memory" is exactly the limit. readAllLines on a multi-gigabyte log loads the whole thing, and can exhaust the heap. The API says as much about readString: "It is not intended for reading very large files" [1]. For large or unbounded files, process them line by line without holding them all at once, as the next section shows.

💡 Earned rule. Use readAllLines/readString for files that fit comfortably in memory (configuration, small data), and the streaming Files.lines for large ones.

The cost of the eager methods is memory in proportion to the file's size. The payoff is simplicity: a whole file as a String or a List, whenever you can afford to hold it.


3. The Stream name clash

In Java, "stream" means two unrelated things, and that trips people up:

They're different types, from different packages. Files.lines() returns the second kind, so you can process a file with a stream pipeline.

Output:

lines > 2: 3

Analysis. Files.lines(file) returned a java.util.stream.Stream<String>: a lazy pipeline over the file's lines. So we could filter and count just as with any other stream (3, 4 and 5 are greater than 2).

Crucially, the stream sits in a try-with-resources. It's backed by an open file, so it must be closed — unlike the in-memory streams from the streams lesson. And because it reads lazily, it never holds the whole file in memory.

Intuition. Mechanism. Files.lines opens the file and exposes its lines as a lazy Stream<String>, reading on demand as the pipeline pulls. It holds an operating-system file handle, so it has to be closed. The API is explicit about this: "This method must be used within a try-with-resources statement or similar control structure to ensure that the stream's open file is closed promptly" [1]. The unrelated java.io byte streams (FileInputStream and friends) are a separate, lower-level world for binary data.

Concrete bite. The name clash causes real confusion. An InputStream is not a Stream: it has no map or filter, and the two aren't interchangeable. Forgetting try-with-resources on Files.lines leaks the file handle, whereas a list.stream() holds no resource at all. Same word, two meanings, two lifecycles.

💡 Earned rule. Read "stream" by its package. java.util.stream.Stream is the functional pipeline (map/filter/collect), while java.io streams are byte channels. Close Files.lines, and any other resource-backed stream, with try-with-resources.

The cost is the terminology overhead. The payoff is that Files.lines brings the whole Stream API to a file, processing even huge files lazily without loading them.


4. Bytes vs characters

Underneath all text lie bytes. A String's characters become bytes through a charset, and in UTF-8, a non-ASCII character takes more than one byte. So a file's byte count and its character count can differ:

Output:

bytes: 5
chars: 4

Analysis. "Café" is 4 characters, but 5 bytes on disk. C, a and f take one byte each in UTF-8, while é takes two. readAllBytes sees the raw bytes (5), and readString decodes them back into characters (4).

Text is an interpretation of bytes, and the charset decides which bytes. The Files text methods use UTF-8 unless you pass a different charset [1]. Since JDK 18, UTF-8 is also Java's default charset everywhere else [3]. Before that, relying on the platform's default charset was a classic source of "works on my machine" bugs.

Intuition. Mechanism. Encoding turns characters into bytes, and decoding turns bytes back into characters. Both directions need the same charset. Decode with a different one, and the bytes get read as different characters.

Concrete bite. Write in one charset and read in another, and the text gets mangled — or rejected:

Output (prints three lines, then a thrown exception):

read as UTF-8:      Café
read as ISO-8859-1: Café
written as ISO-8859-1, read as UTF-8:
Exception in thread "main" java.nio.charset.MalformedInputException: Input length = 1
  • UTF-8's two bytes for é, read as ISO-8859-1, become two characters: é. There's no error; the text is just silently wrong.
  • ISO-8859-1 writes é as a single byte, 0xE9, which isn't valid UTF-8 on its own. So readString refuses it with MalformedInputException.

💡 Earned rule. For text, use character I/O with an explicit charset — UTF-8, unless a file format says otherwise. For binary data, use byte I/O (readAllBytes, InputStream).

The cost is being deliberate about encoding at every boundary. The payoff is text that doesn't silently change when it crosses to another machine or file format.


5. Readers, writers, the console and buffering

Underneath the one-line Files helpers sit four families of classes in java.io [4]:

Bytes Characters
In InputStream Reader
Out OutputStream Writer

A BufferedReader reads characters in large blocks, and hands them out a line at a time through readLine(), which returns null at the end of the input. A BufferedWriter collects writes in memory and sends them out in large blocks. Files.newBufferedReader and Files.newBufferedWriter open one of these on a file, using the charset you name:

Output:

read: event 1
read: event 2
read: event 3

The console. System.in is an InputStream, so it delivers bytes. An InputStreamReader decodes it into characters, and a BufferedReader wrapped around that reads whole lines. This is the classic alternative to the Scanner from the first chapter:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
 
public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
        System.out.print("Your name: ");
        String name = in.readLine();
        System.out.println("Hello, " + name + "!");
        System.out.println("next readLine: " + in.readLine());
    }
}

Output (when you type Ada and press Enter):

Your name: Hello, Ada!
next readLine: null

After Ada, the input ended, so the second readLine() returned null. The page doesn't show your keystrokes; in a terminal, you'd see Ada after the prompt. Here's the runnable twin: the same BufferedReader, reading from a fixed String instead of the keyboard:

Output:

Hello, Ada!
next readLine: null

A BufferedReader works the same way over any Reader: a file, the console, or a String.

Intuition. Mechanism. Each read or write on an unbuffered file stream can be a system call — a request to the operating system, which is slow. A buffer turns many small requests into a few large ones.

Concrete bite. Count the bytes of a 2 MB file one read() at a time, first without a buffer and then with one:

Output (illustrative — timings vary per machine; three runs on JDK 25 printed 977/20, 1018/20 and 973/20 ms):

unbuffered: 2000000 bytes in 977 ms
buffered:   2000000 bytes in 20 ms

It's the same two million read() calls, but about 50 times faster with a buffer. (read() returns -1 at the end of a stream, which is how the loop knows to stop.)

💡 Earned rule. Wrap raw file streams, readers and writers in their buffered forms, or use Files.newBufferedReader/newBufferedWriter. Read text line by line with readLine() until it returns null.

The cost is one more wrapper, plus remembering that a BufferedWriter holds onto data until it's flushed or closed (try-with-resources closes it). The payoff is I/O that doesn't crawl along one system call at a time.


6. Directories: Files.list and Files.walk

Files.createDirectories creates a directory, along with any missing parents. Two methods read a directory as a Stream<Path>, and both hold it open, so both belong in a try-with-resources [1]:

  • Files.list(dir) gives the entries directly inside dir.
  • Files.walk(dir) gives dir itself, then everything below it, at every depth.

Output:

list: README.md
list: src
walk: 
walk: README.md
walk: src
walk: src/app
walk: src/app/Main.java

Analysis. list saw the two entries at the top level: README.md and the directory src. walk saw five paths: the root itself (which, relative to itself, is the empty path), then every file and directory below it. The program sorts the names, because a directory's entries come back "in no specific order" [9].

💡 Earned rule. Use Files.list for one level and Files.walk for a whole tree, always inside try-with-resources, and sort whenever order matters.

The cost is that walk visits everything below the root, which could be millions of entries; pass a maximum depth, as in Files.walk(dir, 2), to limit it. The payoff is a directory tree as an ordinary stream, ready for filter and map.


7. Serialization

Serialization turns an object into a stream of bytes, and deserialization rebuilds an object from those bytes. "Serializability of a class is enabled by the class implementing the java.io.Serializable interface" [5]. Serializable has no methods; it's just a marker.

  • ObjectOutputStream.writeObject writes an object, and "Objects referenced by this object are written transitively" [11].
  • ObjectInputStream.readObject reads one back, as an Object that you then cast.
  • A field marked transient is "not part of the persistent state of an object" [6]: it isn't written, and comes back with its default value.
  • serialVersionUID is a version number for the class's serialized form. If you change the class in an incompatible way, bump it.

This program serializes into a byte array in memory, so it doesn't need a file:

Output:

serialized to 82 bytes
owner=Ada balance=120 sessionToken=null
same object? false

Analysis. The Account became 82 bytes, and then a new object with the same owner and balance. The transient token wasn't written, so it came back as null. readObject declares ClassNotFoundException, because the reading JVM must have the class in order to rebuild the object.

Non-example: a class that isn't Serializable. Every object in the graph must be serializable. Otherwise, writeObject throws:

Output (a thrown exception):

Exception in thread "main" java.io.NotSerializableException: Main$Point

Main$Point is the binary name of the nested class Point.

Edge: records. A record gets rebuilt through its canonical constructor: "During deserialization the record's canonical constructor is invoked to construct the record object" [7]. That means a record's validation runs again on the way in:

Output:

canonical constructor ran for x=3
canonical constructor ran for x=3
read back: Point[x=3, y=4]

The constructor ran twice: once for new Point(3, 4), and once during readObject. An ordinary class, by contrast, is rebuilt without its own constructor: "the no-arg constructor for the first non-serializable supertype is run" [10]. Its own checks never run, so carefully crafted bytes can bypass them.

⚠️ Security warning. The ObjectInputStream docs open with this warning: "Deserialization of untrusted data is inherently dangerous and should be avoided" [7]. readObject can build objects of any serializable class on the classpath, from bytes that an attacker controls. If you must read serialized data from outside, restrict the classes it may contain with an ObjectInputFilter [8]. For data that crosses a trust boundary, prefer an explicit format such as JSON, and validate it.

💡 Earned rule. Use Java serialization only between programs you trust, for classes you control. Mark fields that must not be saved as transient, declare a serialVersionUID, and prefer records, whose constructor checks run again on the way in.

The cost of serialization is its security and versioning traps. The payoff is a whole object graph saved and restored in just two calls.


8. Mental-model summary

Principle Consequence
Path names a location; Files performs the I/O Path.of never throws for a missing file; the Files operation does (NoSuchFileException)
readAllLines/readString load the whole file into memory Fine for small files; a huge file can exhaust the heap
Files.lines() returns a java.util.stream.Stream over lines Lazy line processing — but it's resource-backed, so close it (try-with-resources)
"stream" means two things: java.io bytes vs java.util.stream pipeline They're unrelated types; an InputStream has no map/filter
Text is bytes through a charset; UTF-8 is variable-width "Café" is 4 chars but 5 bytes; the wrong charset mangles it or throws MalformedInputException
InputStream/OutputStream move bytes; Reader/Writer move characters BufferedReader.readLine() reads lines from a file or the console, null at the end
Each unbuffered read() can be a system call Buffering made 2 MB of one-byte reads about 50 times faster
Files.list is one level; Files.walk is the whole tree Both are resource-backed streams in unspecified order
Serialization writes a Serializable object graph as bytes transient fields come back as defaults; deserializing untrusted data is dangerous

9. Gotcha checklist

Symptom Likely cause Fix
NoSuchFileException the file does not exist, or the path is wrong creating a Path does not create a file; check the path, or write the file first
OutOfMemoryError reading a file readAllLines/readString loaded it all Files.lines, or a BufferedReader, to read it line by line
A file stays open, or "too many open files" a Files.lines, list or walk stream was never closed try-with-resources
InputStream has no filter or map an I/O stream is not a java.util.stream.Stream Files.lines for a line stream
Non-ASCII text shows as é UTF-8 bytes decoded with another charset read with the charset the file was written in
MalformedInputException: Input length = 1 the file is not valid UTF-8 pass the file's real charset to readString
A written file is empty or cut short a BufferedWriter was not flushed or closed try-with-resources closes and flushes it
I/O is very slow unbuffered one-byte reads or writes a Buffered… wrapper
Files.walk output in a different order on another machine directory order is not specified sorted()
NotSerializableException an object in the graph does not implement Serializable implement it, or mark the field transient
A field is null after deserialization it is transient recompute it after reading

✅ Check yourself

One check per objective. Answer before you open anything.

The 🧪 box below: "a€b"; a missing file; summing the numbers in a file.

Output:

bytes: 5
chars: 3
sum: 14
  • "a€b" is 3 characters but 5 bytes: a and b take one byte each, and € takes three.
  • Files.readString(Path.of("nope.txt")) fails at readString, with NoSuchFileException: nope.txt, because Path.of only builds a name (§1).
  • Files.lines holds an open file, so it needs try-with-resources. List.stream() reads a list that's already in memory, and holds nothing to close.

📚 Sources

  1. java.nio.file.Files, Java SE 25 API (readString and its UTF-8 default; "not intended for reading very large files"; lines, list and walk "must be used within a try-with-resources statement") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Files.html
  2. java.nio.file.Path, Java SE 25 API (resolve, normalize, relativize) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Path.html
  3. JEP 400: UTF-8 by Default (JDK 18) — https://openjdk.org/jeps/400
  4. java.io package summary, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/package-summary.html
  5. java.io.Serializable, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/Serializable.html
  6. The Java Language Specification, Java SE 25, §8.3.1.3 "transient Fields" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.3.1.3
  7. java.io.ObjectInputStream, Java SE 25 API (the deserialization warning; records rebuilt through the canonical constructor) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/ObjectInputStream.html
  8. java.io.ObjectInputFilter, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/ObjectInputFilter.html
  9. java.nio.file.DirectoryStream, Java SE 25 API ("The elements returned by the iterator are in no specific order") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/DirectoryStream.html
  10. Java Object Serialization Specification, Java SE 25, §3.1 "The ObjectInputStream Class" — https://docs.oracle.com/en/java/javase/25/docs/specs/serialization/input.html
  11. java.io.ObjectOutputStream, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/ObjectOutputStream.html

🧪 Predict, then check.

  1. Predict the byte count and character count that Files.writeString, followed by readAllBytes/readString, report for the string "a€b" (the euro sign € is 3 bytes in UTF-8).
  2. Predict whether Files.readString(Path.of("nope.txt")) throws at Path.of or at readString, and which exception it throws.
  3. Rewrite the §3 example to sum the numbers in the file with a stream, and work out why the Files.lines stream needs a try-with-resources where a List.stream() wouldn't.

Your Turn

Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.

Mark as readBook versionfd26460