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 editI/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 —
PathandFiles— is the modern, one-line file API. Files.lines()returns ajava.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:
- The mechanism — what the compiler and the JVM do.
- A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of contents
- NIO.2:
PathandFiles - Reading lines as text
- The
Streamname clash - Bytes vs characters
- Readers, writers, the console and buffering
- Directories:
Files.listandFiles.walk - Serialization
- Mental-model summary
- Gotcha checklist
- Check yourself
- 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 bytesAnalysis. Four calls, four jobs:
Path.of("greeting.txt")named a file.Files.writeStringcreated it and wrote the text — opening, writing and closing, all in one call.Files.readStringread it all back.Files.sizereported26bytes: 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? falseresolvejoins one path onto another, andnormalizeremoves the..and.parts.getFileNameandgetParentsplit a path apart, andrelativizegives the path from one location to another.- None of these directories exists, yet nothing failed, because a
Pathis 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.txtPath.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
bananaAnalysis. 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:
- A java.io stream (
InputStream/OutputStream) is a flow of bytes. - A java.util.stream.Stream is the functional pipeline from Functional Java & the Streams API.
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: 3Analysis. 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: 4Analysis. "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. SoreadStringrefuses it withMalformedInputException.
💡 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 3The 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: nullAfter 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: nullA 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 msIt'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 insidedir.Files.walk(dir)givesdiritself, 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.javaAnalysis. 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.writeObjectwrites an object, and "Objects referenced by this object are written transitively" [11].ObjectInputStream.readObjectreads one back, as anObjectthat you then cast.- A field marked
transientis "not part of the persistent state of an object" [6]: it isn't written, and comes back with its default value. serialVersionUIDis 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? falseAnalysis. 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$PointMain$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:aandbtake one byte each, and€takes three.Files.readString(Path.of("nope.txt"))fails atreadString, withNoSuchFileException: nope.txt, becausePath.ofonly builds a name (§1).Files.linesholds an open file, so it needstry-with-resources.List.stream()reads a list that's already in memory, and holds nothing to close.
📚 Sources
java.nio.file.Files, Java SE 25 API (readStringand its UTF-8 default; "not intended for reading very large files";lines,listandwalk"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.htmljava.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- JEP 400: UTF-8 by Default (JDK 18) — https://openjdk.org/jeps/400
java.iopackage summary, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/package-summary.htmljava.io.Serializable, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/Serializable.html- The Java Language Specification, Java SE 25, §8.3.1.3 "
transientFields" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.3.1.3 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.htmljava.io.ObjectInputFilter, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/ObjectInputFilter.htmljava.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- 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
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.
- Predict the byte count and character count that
Files.writeString, followed byreadAllBytes/readString, report for the string"a€b"(the euro sign€is 3 bytes in UTF-8). - Predict whether
Files.readString(Path.of("nope.txt"))throws atPath.ofor atreadString, and which exception it throws. - Rewrite the §3 example to sum the numbers in the file with a stream, and work out why the
Files.linesstream needs atry-with-resources where aList.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.