Lesson 33 • Advanced
Java I/O and NIO
Every real program reads and writes data — config files, logs, uploads, exports. By the end of this lesson you'll read and write files the clean, modern way, stream huge files without running out of memory, and know exactly when to drop down to channels and buffers.
What You'll Learn in This Lesson
- ✓You'll be able to read and write text with Reader/Writer and buffered streams
- ✓You'll be able to auto-close streams with try-with-resources (no leaks)
- ✓You'll be able to read a whole file with Files.readString / readAllLines
- ✓You'll be able to write a file in one call with Files.write / writeString
- ✓You'll be able to stream a giant file safely with Files.lines
- ✓You'll know when to reach for channels, buffers, and non-blocking IO
Before You Start
You should already know Exception Handling (especially try-with-resources) and the Streams API, since the modern file methods return a Stream. A basic feel for the difference between bytes (raw data) and characters (text) helps too.
A Real-World Analogy
Files helpers) and the brigade runs for you.Keep that picture in mind: streams = the straw, buffers + channels = the brigade, and the Files class = the button that hides the brigade behind a one-liner.
1️⃣ Streams — the Classic Way (Reader, Writer, Buffered)
A stream is a one-way flow of data. Java splits them in two: InputStream/OutputStream move raw bytes (images, zip files), while Reader/Writer move characters (text). For files you'd use FileReader/FileWriter.
Reading one character at a time is slow, so you wrap a reader in a BufferedReader. "Buffered" means it grabs a big chunk into memory and hands it to you piece by piece — and it adds the handy readLine() method. The try (...) block below is try-with-resources: anything you open in those parentheses is closed automatically when the block ends.
Read the fully-commented example, then run it. Note how readLine() returns null at the end of the stream — that's your signal to stop the loop.
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
public class Main {
public static void main(String[] args) throws IOException {
// A "stream" is a one-way flow of data: bytes for binary,
// characters for text. Reader/Writer handle TEXT; you wrap
// them in Buffered* so you read/write a chunk at a time
// instead of one slow character at a time.
String config = "host=localhost\nport=8080\ndb=myapp";
// WRITE — String-backed Writer so this runs anywhere.
// With a real file you'd use new FileWriter("config.txt").
StringWriter sink = new StringWriter();
// try-with-resources auto-closes bw at the end of the block —
// no manual close(), no finally. The () after try holds the
// resource; anything AutoCloseable goes there.
try (BufferedWriter bw = new BufferedWriter(sink)) {
bw.write(config); // buffered: held in memory...
} // ...flushed + closed here
System.out.println("Wrote " + config.length() + " chars");
// READ line by line — readLine() returns null at end of stream.
try (BufferedReader br = new BufferedReader(new StringReader(sink.toString()))) {
String line; // declared outside the loop
int n = 1;
while ((line = br.readLine()) != null) { // null = no more lines
System.out.println("Line " + (n++) + ": " + line);
}
}
}
}Wrote 31 chars
Line 1: host=localhost
Line 2: port=8080
Line 3: db=myapp2️⃣ NIO.2 — the Modern Way (Path and Files)
Since Java 7, java.nio.file replaced most of that ceremony. A Path is simply a location on disk (the modern stand-in for the legacy File class). The Files class is a toolbox of static helpers that turn whole tasks into one line.
Files.readString(path) — read an entire small file into a String (Java 11+)
Files.readAllLines(path) — read every line into a List<String>
Files.writeString(path, s) — write a String, creating or overwriting the file
Files.write(path, bytes) — write raw bytes
Files.lines(path) — a lazy Stream<String>, safe for huge files
The example below writes a file, reads it three different ways, then deletes it. The key habit: always pass StandardCharsets.UTF_8 so the same code reads the same text on every machine.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) throws IOException {
// Path = a location on disk (the modern replacement for File).
// Files = a toolbox of static helpers that act on a Path.
// We make a real temp file so the example is self-contained.
Path file = Files.createTempFile("demo", ".log");
// WRITE a whole small file in ONE call (Java 11+).
// Always pass a charset so it behaves the same on every OS.
Files.writeString(file,
"ERROR boot failed\nINFO ready\nERROR disk full\nDEBUG tick\nERROR timeout",
StandardCharsets.UTF_8);
System.out.println("Wrote " + Files.size(file) + " bytes");
// READ it all back as one String (Java 11+) — small files only.
String text = Files.readString(file, StandardCharsets.UTF_8);
System.out.println("readString got " + text.length() + " chars");
// READ as a List of lines — handy when you want every line at once.
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
System.out.println("readAllLines got " + lines.size() + " lines");
// STREAM lines lazily — safe for HUGE files (one line in memory
// at a time). Files.lines holds an open file, so close it with
// try-with-resources.
try (Stream<String> stream = Files.lines(file, StandardCharsets.UTF_8)) {
long errors = stream.filter(l -> l.contains("ERROR")).count();
System.out.println("Found " + errors + " ERROR lines");
}
Files.delete(file); // tidy up
System.out.println("Cleaned up: " + !Files.exists(file));
}
}Wrote 67 bytes
readString got 67 chars
readAllLines got 5 lines
Found 3 ERROR lines
Cleaned up: true🎯 Your Turn #1 — Write Then Read a File
Finish the program: write three lines, read them back as a List, and print the count. Replace each ___ using the // 👆 hints. Check your work against the expected output in the comments.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class Main {
public static void main(String[] args) throws IOException {
// 🎯 YOUR TURN — fill in the blanks marked with ___
Path notes = Files.createTempFile("notes", ".txt");
// 1) Write three lines to the file using Files.writeString.
// Separate lines with \n and pass the UTF_8 charset.
Files.writeString(notes, "buy milk\nwalk dog\ncall mum", ___);
// 👆 the charset
// 2) Read every line back as a List<String>.
List<String> lines = Files.___(notes, StandardCharsets.UTF_8);
// 👆 the method that returns a List of lines
// 3) Print how many lines you read.
System.out.println("You have " + lines.___() + " notes");
// 👆 List method for the count
Files.delete(notes);
// ✅ Expected output:
// You have 3 notes
}
}🎯 Your Turn #2 — Stream and Filter a Log
Count the WARN lines in a log without loading the whole file into memory. Fill in the streaming method and the text to match, then compare with the expected output.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) throws IOException {
// 🎯 YOUR TURN — count the WARN lines without loading the
// whole file into memory. Fill in the blanks marked with ___
Path log = Files.createTempFile("app", ".log");
Files.writeString(log,
"INFO start\nWARN low disk\nINFO ok\nWARN slow query\nINFO done",
StandardCharsets.UTF_8);
// 1) Open a lazy Stream of the file's lines.
// Use try-with-resources so the file is closed for you.
try (Stream<String> lines = Files.___(log, StandardCharsets.UTF_8)) {
// 👆 the lazy streaming method
// 2) Keep only lines that contain "WARN", then count them.
long warnings = lines.filter(l -> l.contains("___")).count();
// 👆 the text to match
System.out.println("Warnings: " + warnings);
}
Files.delete(log);
// ✅ Expected output:
// Warnings: 2
}
}3️⃣ Under the Hood — Channels and Buffers
The convenient Files helpers are built on lower-level pieces you'll occasionally meet. A channel (FileChannel) is a two-way pipe to a file or socket. A buffer (ByteBuffer) is a fixed-size box of bytes that the channel fills from or drains into.
The one trick people forget is buffer.flip(): after you fill a buffer you must flip it to switch from "write mode" to "read mode" so the channel knows where your data ends. You rarely need this layer directly — but it's what powers fast, large-file work like FileChannel.transferTo() (zero-copy) and memory-mapped files.
Selectors a single thread can manage thousands of connections without one slow client blocking the rest — the foundation of high-throughput servers. For directory monitoring there's WatchService, which blocks waiting for filesystem events (so it can't run in an online sandbox).import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.channels.FileChannel;
public class Main {
public static void main(String[] args) throws IOException {
// A Channel is a two-way pipe to a file or socket. A ByteBuffer
// is a fixed-size box of bytes the channel reads into / writes
// from. This is the low-level engine Files.* uses for you — you
// rarely need it, but it powers high-performance code.
Path file = Files.createTempFile("channel", ".bin");
byte[] data = "Hello, NIO!".getBytes(StandardCharsets.UTF_8);
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.WRITE)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put(data); // fill the buffer (write mode)
buffer.flip(); // FLIP: switch to read mode so the
// channel knows where the data ends
int written = ch.write(buffer);
System.out.println("Wrote " + written + " bytes");
}
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate((int) ch.size());
ch.read(buffer); // fill the buffer from the file
buffer.flip(); // flip before reading it back out
String text = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.println("Read back: " + text);
}
Files.delete(file);
}
}Wrote 11 bytes
Read back: Hello, NIO!Hello, NIO!, then deletes the file.🧩 Mini-Challenge — Word Counter
No blanks this time — just an outline. Write a sentence to a temp file, read it back with Files.readString, split it on spaces, and print the word count. The starter has only comments; you write the code.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class Main {
public static void main(String[] args) throws IOException {
// 🎯 MINI-CHALLENGE: word counter
// 1. Create a temp file and write a sentence into it with
// Files.writeString (remember the UTF_8 charset).
// 2. Read it back with Files.readString.
// 3. Split the text on spaces: text.split(" ") gives a String[].
// 4. Print: "Word count: N" (an array's length is array.length).
// 5. Delete the temp file when you're done.
//
// ✅ Expected (text = "the quick brown fox"): Word count: 4
// your code here
}
}Common Errors (and the Fix)
- ❌ Not closing streams — leaving streams open leaks OS file handles until your program eventually throws
java.io.IOException: Too many open files. Fix: open every stream in atry (...)(try-with-resources) so it closes automatically. - ❌ Charset issues — relying on the platform default makes the same file read as
caf?on one OS andcaféon another, or throwsMalformedInputException. Fix: always passStandardCharsets.UTF_8. - ❌ Reading a huge file into memory —
Files.readAllLineson a multi-GB log dies withOutOfMemoryError: Java heap space. Fix: useFiles.lines(lazy stream) and process line by line. - ❌ Blocking on a slow source — a plain
read()waits forever for a slow file or network peer, freezing that thread. Fix: for high-concurrency network work use non-blocking channels withSelectorsinstead of one blocking thread per connection. - ❌ Forgetting
buffer.flip()— you fill aByteBufferthen write it and 0 bytes come out. Fix: callflip()after filling, before the channel reads from it.
📋 Quick Reference — Old IO vs NIO.2
| Task | Old way (java.io) | Modern way (java.nio.file) |
|---|---|---|
| A location on disk | new File("a.txt") | Path.of("a.txt") |
| Read whole file | BufferedReader loop | Files.readString(path) |
| Read all lines | readLine() until null | Files.readAllLines(path) |
| Write text | FileWriter + write() | Files.writeString(path, s) |
| Stream a huge file | manual readLine() loop | Files.lines(path) |
| Copy a file | read/write byte loop | Files.copy(src, dst) |
| Walk a directory tree | recursive File.listFiles() | Files.walk(dir) |
Frequently Asked Questions
What is the difference between IO and NIO in Java?
Classic IO (java.io) is stream-based: data flows one direction, blocking until each byte arrives. NIO.2 (java.nio.file) is the modern file API built on Paths, the Files toolbox, channels and buffers — it reads in blocks, can be non-blocking, and gives you convenient one-liners like Files.readString and Files.lines. For everyday file work, reach for the Files class first.
Do I still need to close streams if I use try-with-resources?
No — that is the whole point. Any resource declared in the try (...) parentheses is closed automatically when the block ends, even if an exception is thrown. Before try-with-resources (Java 7) you had to close streams by hand in a finally block, which was easy to get wrong and leaked file handles.
Should I use Files.readAllLines or Files.lines?
Use Files.readAllLines when the file is small and you want every line in a List at once. Use Files.lines for large files: it returns a lazy Stream that holds only one line in memory at a time, so a multi-gigabyte log will not blow up your heap. Files.lines keeps the file open, so wrap it in try-with-resources.
Why do I need to specify a charset like StandardCharsets.UTF_8?
If you do not pass a charset, older APIs use the platform default, which differs between machines and operating systems. The same code can then read different characters on Windows vs Linux and silently corrupt text. Always pass StandardCharsets.UTF_8 so your program behaves identically everywhere.
What does buffer.flip() actually do?
A ByteBuffer tracks a position and a limit. While you fill it (put), position moves forward. flip() sets the limit to the current position and resets position to 0, switching the buffer from write mode to read mode so the channel knows exactly which bytes to send. Forgetting flip() is the classic NIO bug — you end up writing zero bytes or garbage.
🎉 Lesson Complete!
Great work! You can now read and write files the classic way with buffered Reader/Writer, close them safely with try-with-resources, and do the same far more cleanly with the modern Path + Files API. You also know to stream huge files with Files.lines instead of loading them whole, and what channels, buffers, and non-blocking IO are for.
Next up: JDBC — connecting your Java programs to databases so your data outlives a single run.
Sign up for free to track which lessons you've completed and get learning reminders.