Java NIO Overview
An introduction to Java NIO and NIO.2 — channels, buffers, selectors, and the java.nio.file package.
Java NIO Overview
The fifteen chapters before this one were java.io — streams, Reader/Writer, File, Serializable. That API was Java's original I/O, and it's still in heavy use. NIO is the family of APIs Java added later to cover what java.io couldn't. It comes in two parts that share a package prefix and not much else:
- NIO (Java 1.4, 2002) —
java.nio.*— channels, buffers, selectors. A different shape for I/O: byte-buffer based, optionally non-blocking, designed for high-throughput servers. - NIO.2 (Java 7, 2011) —
java.nio.file.*— thePath,Files,FileSystem, andWatchServiceclasses. A friendlier replacement forjava.io.Fileand a place for filesystem featuresjava.ionever had (symbolic links, extended attributes, asynchronous file I/O, directory watching).
You've been using bits of NIO.2 since the start of this part: Path, Files.newBufferedReader, Files.newInputStream are all java.nio.file. This chapter zooms out and shows where those pieces fit, and what the rest of the package is for.
Stream vs channel: two different shapes
InputStream.read() returns one byte. OutputStream.write(int) writes one byte. The mental model is a one-byte-at-a-time pipe. Buffered decorators make it fast, but the abstraction is sequential and one-directional.
A channel (java.nio.channels.Channel) is bidirectional, byte-buffer-oriented, and supports operations that InputStream can't express:
- Read into and write from a
ByteBuffer— not abyte[]. - Memory-map a file region into RAM and read/write it as a buffer.
- Scatter a read into multiple buffers (header → one, payload → another).
- Gather a write from multiple buffers (single
write()produces a contiguous output). - Mark a channel non-blocking and let a
Selectormultiplex thousands of them on one thread.
The trade is verbosity. Channel code reads and writes through a ByteBuffer with explicit flip() and position() calls; java.io hides all of that behind read(byte[]). For typical file reading, prefer the java.io/Files APIs. Drop to channels when you need one of the channel-only features.
// channel-shaped read into a 1 KB buffer
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = ch.read(buf); // fills the buffer; updates position
buf.flip(); // switch to "read what was just written"
while (buf.hasRemaining()) {
process(buf.get());
}
}The flip() step is the moment people learn that ByteBuffer has its own little state machine.
ByteBuffer: position, limit, capacity
A ByteBuffer is a fixed-size byte[] (or a chunk of off-heap memory) plus three indices:
position— the next byte to be read or written.limit— the index past the last byte you're allowed to touch.capacity— the buffer's fixed size; can't change.
0 ─────── position ─────── limit ─────── capacity
(consumed) (active region) (untouchable / empty)The buffer is in one of two modes by convention:
- Write mode (default): you
put(byte)s into it.positionadvances;limit == capacity. - Read mode: you
get()bytes out.positionadvances;limitis wherever you stopped writing.
flip() switches from write to read: it sets limit = position (mark where the data ends) and resets position = 0 (start reading from the beginning). clear() switches back to write (position = 0, limit = capacity). Mistakes here are the most common source of "I read zero bytes; why?" frustration.
Off-heap buffers (ByteBuffer.allocateDirect(n)) bypass the JVM heap and let the OS read/write them directly without an extra copy. They're slower to allocate, faster to do I/O with, and the right choice for hot-path I/O code only.
Selectors: one thread, many channels
Before virtual threads (Java 21), serving thousands of concurrent network connections in Java meant either thousands of OS threads (one per connection — expensive) or a single thread multiplexing with a Selector:
Selector sel = Selector.open();
serverChannel.register(sel, SelectionKey.OP_ACCEPT);
while (true) {
sel.select(); // blocks until any channel is ready
for (SelectionKey k : sel.selectedKeys()) {
if (k.isAcceptable()) accept(k);
if (k.isReadable()) read(k);
}
}The OS notifies the JVM when any registered channel can make progress; the JVM hands you the ready set; you do a non-blocking read or write and go back to select(). The framework code under Netty, gRPC, and Spring WebFlux is shaped like this.
With virtual threads (Thread.startVirtualThread(...)), the simpler "one thread per request" pattern scales to the same connection counts without the Selector choreography — virtual threads park on blocking I/O essentially for free. For new application code on Java 21+, the selector loop is increasingly a library concern; you usually don't write it by hand. For library code and pre-Loom JVMs, it's the standard pattern.
java.nio.file: the modern file API
This is the half of NIO you'll touch in everyday code. It replaces java.io.File and most of the file-related parts of java.io:
java.io | java.nio.file | Why the replacement |
|---|---|---|
File | Path | Immutable, OS-agnostic, no built-in I/O methods |
File.list() | Files.list(Path), Files.walk(Path) | Stream<Path>; closeable; respects symbolic links |
new FileInputStream(...) | Files.newInputStream(path) | Charset-aware variants for text; one consistent open API |
file.delete() returning false on failure | Files.delete(path) throwing IOException | Failures are visible, not silent |
| no equivalent | Files.walkFileTree, WatchService, symbolic-link API, file attribute views | Capabilities java.io never had |
The two upcoming chapters cover Path and Files in depth. The rule of thumb: for file work in 2024+ Java, reach for java.nio.file. java.io.File is still around because old code uses it, but new code should default to Path.
A worked example: round-trip a file via a channel and a buffer
The program below copies a small text file the channel-and-buffer way to make position/limit/flip concrete. It opens the source as a FileChannel, reads into a ByteBuffer, flips, writes to a destination FileChannel, and prints the buffer state at each step so you can see how the indices move.
What to take from the run:
- The loop printed the buffer state on every step. After a
read(),positionwas the number of bytes read andlimitwas stillcapacity— that's "write mode": room left at the end. Afterflip(),position = 0andlimit = the number-just-read— that's "read mode": the bytes lie between 0 andlimit. The two indices encode "where the data lives" without copying it. - The buffer was 16 bytes; the file was 44. The loop ran three iterations: 16, 16, 12. Once the buffer was empty (after
writehad drained it),clear()reset it back to "write mode" so the nextread()could refill it. This is the channel pattern in miniature: refill, flip, drain, clear, repeat. transferTodid the same copy in one line with noByteBufferinvolved at all. On Linux, that maps to a singlesendfile()syscall — the bytes travel kernel-to-kernel without crossing the JVM. When you're moving data between two channels and don't need to look at it, this is the right tool.- Notice that the source file was created with
Files.writeStringand the destination read back withFiles.readString— both arejava.nio.fileone-liners that hide channels and buffers entirely. The detailed channel loop in the middle is what you'd write only when you need direct buffer access (custom binary parsing, memory mapping, scatter/gather). For "copy a file,"transferToorFiles.copyis shorter and at least as fast. - The
FileChannel.open(path, OPTION)constructor is the parallel toFiles.newInputStream(path). TheStandardOpenOptionenum (READ, WRITE, CREATE, APPEND, TRUNCATE_EXISTING, ...) is what controls open behaviour — there's only one place to look. That open-options enum keeps recurring through the next chapter.
What's next
This chapter named the pieces — channels, buffers, selectors, java.nio.file. The next chapter, Java Path Class, goes into the friendliest of those pieces — Path — and the methods (resolve, relativize, normalize) you'll use every time you touch a filesystem path.
Practice
You read 10 bytes from a channel into a `ByteBuffer` of capacity 1024. You want to write those 10 bytes to another channel. What do you have to do between the `read()` and the `write()`?