W3docs

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.* — the Path, Files, FileSystem, and WatchService classes. A friendlier replacement for java.io.File and a place for filesystem features java.io never 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 a byte[].
  • 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 Selector multiplex 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. position advances; limit == capacity.
  • Read mode: you get() bytes out. position advances; limit is 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.iojava.nio.fileWhy the replacement
FilePathImmutable, 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 failureFiles.delete(path) throwing IOExceptionFailures are visible, not silent
no equivalentFiles.walkFileTree, WatchService, symbolic-link API, file attribute viewsCapabilities 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.

java— editable, runs on the server

What to take from the run:

  • The loop printed the buffer state on every step. After a read(), position was the number of bytes read and limit was still capacity — that's "write mode": room left at the end. After flip(), position = 0 and limit = the number-just-read — that's "read mode": the bytes lie between 0 and limit. 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 write had drained it), clear() reset it back to "write mode" so the next read() could refill it. This is the channel pattern in miniature: refill, flip, drain, clear, repeat.
  • transferTo did the same copy in one line with no ByteBuffer involved at all. On Linux, that maps to a single sendfile() 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.writeString and the destination read back with Files.readString — both are java.nio.file one-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," transferTo or Files.copy is shorter and at least as fast.
  • The FileChannel.open(path, OPTION) constructor is the parallel to Files.newInputStream(path). The StandardOpenOption enum (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

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()`?