AlgoMaster Logo

NIO Package

Medium Priority15 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

The java.nio package is Java's second-generation I/O API. It was added in Java 1.4 to address two limitations of the original java.io streams: streams move data one byte (or one char) at a time, and most stream operations block the calling thread until the work finishes. NIO replaces that model with buffers (chunks of data you read and write in bulk) and channels (two-way connections to files, sockets, and pipes), plus optional non-blocking and asynchronous modes. This lesson is the overview: what NIO is, why it exists, the three core abstractions, how buffers track their state, and how to decide between java.io and java.nio for everyday code. The two chapters after this go deeper on Path/Files and on FileChannel.

Why NIO Exists

The java.io API you've used so far is stream-oriented. A FileInputStream produces bytes one at a time, a BufferedReader produces characters one at a time, and the calling thread is parked inside the read until the kernel has data to hand back. For a single file on local disk that's fine. The model starts to bend in three places:

  • Bulk transfers. Copying a 2 GB log file byte-by-byte through a Java InputStream is slow, even with a buffered wrapper, because each read() crosses the boundary between Java code and a native call. NIO can ask the OS to move a block of bytes straight from one channel to another, with no per-byte loop in Java.
  • One thread per connection. A traditional socket server uses one thread per client because every socket.read() blocks. A hundred idle clients means a hundred parked threads, each holding a 1 MB stack. NIO lets a single thread watch many channels with a Selector and only do work on the ones that are ready.
  • Random access on files. FileInputStream reads forward. To jump to byte 8192, you have to read and discard the first 8191 bytes (or use the awkward RandomAccessFile class). A FileChannel exposes a position you can set, plus memory-mapped views of regions of a file.

The release history matters because the docs still split along these lines:

ReleaseWhat was added
Java 1.4 (2002)Original NIO: Buffer, Channel, Selector, FileChannel, SocketChannel, charset encoders/decoders
Java 7 (2011)NIO.2: Path, Paths, Files, WatchService, AsynchronousFileChannel, file attribute APIs
Java 11+Convenience methods on Files and Path, small additions like Files.readString

People still casually say "NIO" for the 1.4 stuff and "NIO.2" for the Java 7 additions. The two live in the same java.nio.* package family and mix freely in code.

"NIO is faster than java.io" is folklore. For small files, Files.readAllBytes(path) and a wrapped FileInputStream perform within noise of each other. NIO wins clearly on large bulk transfers, on memory-mapped files, and on servers that hold many idle connections. Use it because of the model, not because of a generic speed promise.

The high-level picture below contrasts the two APIs.

The diagram is rough but captures the shift. With java.io, the app talks to a one-way stream, which pulls or pushes one byte at a time. With java.nio, the app fills (or drains) a buffer, then hands the whole buffer to a channel, which is a two-way pipe to the file or socket.

The Three Core Abstractions

NIO is built around three concepts. Two of them (channels and buffers) you'll use every time you touch the API. The third (selectors) is for non-blocking network servers, not file I/O.

AbstractionWhat it isWhere you'll meet it
ChannelA two-way connection to a file, socket, pipe, or other I/O sourceFileChannel, SocketChannel, etc.
BufferA fixed-size container of primitives that channels read into and write out ofByteBuffer, CharBuffer, IntBuffer, etc.
SelectorAn object that watches many non-blocking channels and tells you which are readySelector with SelectableChannel

The channel-buffer-process triangle is the one mental model to keep in mind. A channel never reads "from a file into nothing"; it always reads into a buffer. A channel never writes "out of nothing"; it always writes from a buffer. Your code is the third corner. It either prepares data in a buffer and asks the channel to drain it, or it asks the channel to fill a buffer and then reads from it.

The three arrows tell the whole story. Your code interacts with the buffer using put and get. The buffer interacts with the channel through the channel's read(buffer) and write(buffer) methods. The channel interacts with the operating system through native calls. Streams collapsed the middle two arrows into one; NIO splits them on purpose so the buffer can be reused, shared, or memory-mapped.

Selectors aren't part of file I/O at all. A Selector lets one thread block on many network channels at once and wake up when any of them has bytes ready. That's the foundation of high-concurrency servers (Netty, async HTTP libraries, the JDK's own HTTP/2 client). File channels are always blocking on regular files, so selectors don't apply there. Selectors are mentioned here only so the picture is complete.

Channels: Two-Way Pipes

A Channel is the NIO replacement for an InputStream or OutputStream. The differences:

  • A channel can be read from and written to through the same object (when the underlying resource supports it). InputStream and OutputStream are separate, one-way types.
  • A channel transfers data in chunks via a `ByteBuffer`. There is no "read one byte" method.
  • A channel can be non-blocking (for sockets) or run asynchronously (for files), in addition to the default blocking mode.

The five channel types in the JDK cover most needs:

ChannelBacked byDirectionSelectable for non-blocking?
FileChannelA fileRead and writeNo (files are always blocking)
SocketChannelA TCP client connectionRead and writeYes
ServerSocketChannelA TCP listening portAcceptYes
DatagramChannelA UDP socketRead and writeYes
AsynchronousFileChannelA file (Java 7+)Read and write, callback-styleN/A (uses completion handlers)

Channels are usually created via a static factory or by opening another resource:

FileChannel.open returns an open channel that the try-with-resources block will close. The StandardOpenOption values control whether the file is created, truncated, opened for read or write, and so on. Here we're only noting that a channel exists and that you open one before doing anything with NIO file I/O.

SocketChannel and friends are the network side of the same model. A SocketChannel.read(buffer) fills a buffer from a TCP connection. The Networking section covers them in detail.

Buffers: Where Data Actually Sits

A Buffer is a fixed-size container of one primitive type. Channels do not read into "memory" abstractly; they read into a specific buffer that you allocated and handed to them.

The buffer hierarchy mirrors the primitive types:

TypeHoldsCommon use
ByteBufferbyteAlmost always. Everything underneath is bytes.
CharBuffercharDecoded text, paired with a CharsetDecoder.
ShortBuffershortBinary protocols with 16-bit fields.
IntBufferintBinary protocols, image pixel data.
LongBufferlongBinary protocols with 64-bit fields.
FloatBufferfloatGraphics, scientific data.
DoubleBufferdoubleScientific data.

ByteBuffer is the workhorse. You usually allocate a ByteBuffer, hand it to a channel, and then either interpret the bytes yourself or call asCharBuffer() / asIntBuffer() to view the same memory as another type.

ByteBuffer.allocate(64) reserves 64 bytes on the Java heap. The buffer starts empty (position 0) with its limit equal to capacity, meaning "the whole thing is writeable".

Position, Limit, Capacity, and Mark

Every buffer carries four integers that describe its state. Getting the model right makes the rest of NIO obvious; getting it wrong is the most common source of NIO bugs.

FieldMeaning
capacityTotal number of elements the buffer holds. Fixed at allocation. Never changes.
limitThe first index that should not be read or written. Always <= capacity.
positionThe next index that will be read or written. Always <= limit.
markA remembered position you can return to with reset(). Starts undefined.

The invariant the JDK enforces is 0 <= mark <= position <= limit <= capacity. Every operation that bumps the position checks against the limit, and any violation throws BufferOverflowException (on writes) or BufferUnderflowException (on reads).

The visual model is a tape with a write head:

The green cells (indexes 0 to 2) have been written. Position points at index 3, the next slot the buffer will write into. Limit is 8, so writes can continue through index 7. After three put calls, the state is exactly this.

One operation is central to using buffers: flip(). The idea is that a buffer has two modes: write mode (you're putting data in) and read mode (you're getting data out). The same buffer is used for both, and flip() is what switches gears.

Before flip(), write mode:

  • position = how much you've written
  • limit = capacity (you could keep writing until the end)

After flip(), read mode:

  • limit = old position (only read up to where you stopped writing)
  • position = 0 (start reading from the beginning)

In write mode, the buffer holds five bytes of data and three empty slots; position is 5, limit is 8. In read mode after flip(), position is rewound to 0 and limit is pulled in to 5. Now get() calls read the five data bytes and stop. Without flip(), a read would walk into the uninitialized bytes 5, 6, 7.

The full life cycle of a buffer usually looks like this:

That trace is the canonical buffer lifecycle. allocate sets up an empty 16-byte buffer in write mode. put("Hello".getBytes(...)) writes five bytes, pushing position to 5. flip() swings the limit down to 5 and rewinds position to 0. The while (hasRemaining()) loop reads until position catches up to limit. clear() resets to write mode for reuse.

A note on terminology: clear() does not zero out the data. It only resets the three integers. The bytes "Hello" are still physically in the buffer; the next put will overwrite them. If you actually need zeros, you have to write them yourself or allocate a fresh buffer.

A few related methods round out the API:

MethodWhat it does
flip()Switch from write mode to read mode. limit = position; position = 0.
clear()Switch from read mode to write mode, throwing away unread data. position = 0; limit = capacity.
rewind()Re-read from the beginning. position = 0, limit unchanged.
compact()Like clear(), but copies unread data to the front first. Useful when a read was partial.
mark()Save the current position into mark.
reset()Set position = mark. Throws if mark is undefined.
hasRemaining()true if position < limit.
remaining()limit - position.

Each put and get is O(1) and does no allocation. The allocation happens once at allocate(capacity). The buffer is a fixed-size piece of memory; resizing means creating a new, larger buffer and copying.

Direct vs Heap Buffers

When you call ByteBuffer.allocate(n), the buffer's backing memory is a regular Java byte[] on the heap. Garbage collection moves it around, and the JVM owns it. When you call ByteBuffer.allocateDirect(n), the JVM allocates a region of memory outside the Java heap and gives the buffer a pointer to it. That memory is not subject to GC and can be passed straight to the OS without an intermediate copy.

PropertyHeap buffer (allocate)Direct buffer (allocateDirect)
Backing memoryJava byte[] on the heapNative memory, outside the heap
GC moves it?YesNo
Allocation costCheapHigher (system call to reserve native memory)
Per-byte accessFastSimilar, sometimes slightly slower
I/O costJava may copy to a native staging buffer before the syscallOS reads/writes the buffer directly
Best forShort-lived, small buffersLong-lived buffers used for many transfers

The reason direct buffers exist is that operating system I/O calls cannot safely target heap memory. The garbage collector might relocate the array mid-syscall. So when you ask a FileChannel to read into a heap-backed ByteBuffer, the JVM may first copy the data into an internal direct buffer and then copy out to your heap buffer. A direct buffer skips that intermediate copy.

The heap buffer has a backing byte[] you can get at via array(). The direct buffer doesn't; the memory is off-heap and there's no Java array view of it.

Direct buffers are expensive to allocate, so allocating one per request is wasteful. The recommended pattern is to pool them: allocate a few large ones at startup and reuse them across many channel operations. Heap buffers are fine to create and throw away; direct buffers should be treated like file handles.

A guideline: allocate direct buffers when you'll use the same buffer for many large I/O operations (a server's read buffer, a log-writer's chunk buffer). Allocate heap buffers when the data is small, the buffer is short-lived, or you need a byte[] view to interop with other Java APIs.

Charsets and the Bytes/Chars Boundary

NIO buffers are typed (ByteBuffer, CharBuffer, etc.), so the bytes-to-chars conversion that used to be hidden inside InputStreamReader is explicit. The java.nio.charset package provides Charset, CharsetEncoder, and CharsetDecoder for that conversion.

StandardCharsets.UTF_8 is a constant for the most common encoding. encode takes a CharSequence and returns a ByteBuffer already flipped into read mode. decode takes a ByteBuffer in read mode and returns a CharBuffer. For one-shot conversions, those two methods are all you need; for streaming conversions (decode as bytes arrive from a network), use newDecoder() / newEncoder() and feed them chunks.

When you read bytes from a channel and want a string, you go through a charset. There's no equivalent of Reader in the channel API. You read into a ByteBuffer, flip it, and decode it to a CharBuffer or a String. Files.readString(path) hides this for you in the everyday case; the explicit path is what's happening underneath.

Blocking vs Non-Blocking I/O (Conceptual)

By default, channels block just like streams do. A FileChannel.read(buffer) parks the thread until bytes are available, exactly like FileInputStream.read(). The difference is that socket-like channels can be switched into a non-blocking mode where read returns immediately with whatever bytes are ready (possibly zero), instead of waiting.

Moderead() behaviorWhere it applies
BlockingWaits until at least one byte is availableAll channels by default
Non-blockingReturns immediately; result is bytes read so far, possibly 0Socket-like channels only
AsynchronousReturns immediately; you receive a Future or a callback when it completesAsynchronousFileChannel, AsynchronousSocketChannel

Non-blocking I/O is what makes high-concurrency servers possible. One thread can register many sockets with a Selector, call selector.select(), and be woken up only when one of them has data. That replaces the one-thread-per-connection model with one thread per CPU core for tens of thousands of connections.

FileChannel itself can't be set to non-blocking on a regular file (the OS doesn't expose that knob), but AsynchronousFileChannel provides the equivalent for files via callbacks. A price feed that streams updates into a file could use AsynchronousFileChannel.write(buffer, position, attachment, handler) and let the callback fire when the write completes, while the calling thread moves on.

The takeaway is that NIO enables non-blocking and async models; it doesn't force you into them. For ordinary file I/O on local disk, blocking channels are the right default and behave very much like streams.

NIO.2: What Java 7 Added

Java 7 layered a second wave of APIs on top of the original NIO. The most common pieces:

Type / MethodWhat it does
PathAn abstract file or directory path. Replaces File for new code.
Paths / Path.ofFactories that build a Path from a string
FilesUtility class with readString, write, copy, delete, walk, etc.
WatchServiceLets you subscribe to filesystem events (create, modify, delete)
AsynchronousFileChannelFile channel that reads and writes via callbacks or Future
FileSystemPluggable filesystem abstraction (zip files appear as a filesystem)
File attribute viewsTyped access to permissions, owner, timestamps

The shift NIO.2 introduced is that you rarely need to drop down to channels and buffers for everyday file work. A method like Files.readString(Path) reads a small text file in one line, internally using a channel and a buffer. That's the API for most everyday work. Channels remain there for the cases where you need streaming reads, memory mapping, or precise control over how data moves.

A preview of what the NIO.2 side looks like:

No channel, no buffer, no flip. The Files class wraps both for you. That's the everyday face of NIO.

When to Choose java.io vs java.nio

A rough decision table to consider. None of these are absolute, but they capture how teams pick.

SituationPickWhy
Read or write a small text or binary fileFiles.readString / Files.writeOne line, no boilerplate
Walk a directory treeFiles.walk (NIO.2)Lazy stream, follows symlinks if asked
Watch a directory for changesWatchService (NIO.2)OS-level events instead of polling
Read a multi-GB file sequentiallyEither, often Files.lines or a BufferedReader over a streamBoth work; pick what reads cleanest
Copy a large file fastFileChannel.transferToOS-level transfer, often zero-copy
Memory-map a file (random access into a huge file)FileChannel.mapOnly NIO supports this
Write a binary protocol with fixed-width fieldsByteBuffer directlyTyped put/get for primitives, endian control
Build a non-blocking network serverSelector + SocketChannelThe whole point of selectors
Quick one-off scripts and CLI toolsEitherThe convenience methods on Files usually win
You already have an InputStream from a libraryjava.ioDon't fight the API you were handed

The summary in one sentence: use Path and Files for everyday file work, drop down to channels and buffers when you need streaming or memory-mapped I/O, and use selectors for non-blocking network servers. Mixing them is fine; Files.newByteChannel(path, ...) returns a SeekableByteChannel you can use with either API.

A Tiny End-to-End Example: Read a File via a Channel

To make the channel-buffer interaction concrete, consider a minimal program that reads a small file using FileChannel directly. In normal code you'd use Files.readString; this example shows all the moving pieces of NIO in one place.

Reading from beginning to end:

  1. Path.of("welcome.txt") builds a NIO Path for the file.
  2. Files.writeString creates the file with our text. This is a NIO.2 convenience that internally opens a channel, writes a buffer, and closes the channel.
  3. ByteBuffer.allocate(64) reserves 64 bytes on the heap. Position is 0, limit is 64.
  4. FileChannel.open(file, READ) opens a read-only channel inside try-with-resources, so it closes automatically.
  5. channel.read(buffer) fills the buffer with bytes from the file. It returns 22 and advances position to 22 inside the buffer.
  6. buffer.flip() switches to read mode: position back to 0, limit set to 22.
  7. StandardCharsets.UTF_8.decode(buffer) decodes the 22 bytes into a CharBuffer, then .toString() turns it into a String.

Every NIO file program is some variation of this six-step shape: open a channel, fill or drain a buffer, decode or interpret the bytes, close the channel. The full power lives in what step 5 can do: positional reads, scatter-gather, memory mapping, and zero-copy transfers.

Quiz

NIO Package Quiz

10 quizzes