NIO Core: Buffer, Channel, Selector
NIO consists of three core components: Buffer (data container), Channel (data conduit), and Selector (multiplexer). This article delves into the inner workings of these three components and common pitfalls when using them.
Buffer
Three Key Pointers
The core of a Buffer is a byte array along with three pointers:
Initial state (capacity 10, empty)
capacity = 10
position = 0
limit = 10
│◄─────────── capacity = 10 ──────────────►│
│ 0 1 2 3 4 5 6 7 8 9 │
│▲ │
│position=0 │
│ ▲│
│ limit=10│
After writing 5 bytes:
position = 5
limit = 10
│ d1 d2 d3 d4 d5 ░ ░ ░ ░ ░ │
│ ▲ │
│ position=5 │
flip() (switch to read mode):
position = 0
limit = 5
│ d1 d2 d3 d4 d5 ░ ░ ░ ░ ░ │
│▲ ▲ │
│position=0 limit=5 │
clear() after reading (reset to write mode):
position = 0
limit = capacity = 10
| Pointer | Description |
|---|---|
capacity |
Total capacity of the Buffer. It cannot be changed after creation. |
position |
Current read/write position. It advances automatically. |
limit |
The boundary of valid data (Write mode: = capacity; Read mode: = amount of written data). |
Key Methods
ByteBuffer buf = ByteBuffer.allocate(10);
// Write data
buf.put((byte) 'H');
buf.put((byte) 'i');
// position = 2, limit = 10
// Switch to read mode
buf.flip();
// position = 0, limit = 2
// Read data
while (buf.hasRemaining()) {
byte b = buf.get(); // position++ after each read
}
// Reset (Clear the buffer to write again)
buf.clear();
// position = 0, limit = 10 (Data is still there, but pointers are reset)
// Or compact(): Move unread data to the front, and continue writing
buf.compact();
Common Pitfalls
// ❌ Error: Forgetting to flip(), reading empty data
ByteBuffer buf = ByteBuffer.allocate(10);
buf.put("hi".getBytes());
channel.write(buf); // Writes 0 bytes! No data between position and limit.
// ✅ Correct
buf.flip(); // position=0, limit=2
channel.write(buf); // Writes "hi"
Direct Buffer vs Heap Buffer
// Heap buffer (Commonly used)
ByteBuffer heap = ByteBuffer.allocate(1024);
// Direct buffer (Zero-copy)
ByteBuffer direct = ByteBuffer.allocateDirect(1024);
| Type | Allocation Location | GC | Applicable Scenarios |
|---|---|---|---|
| Heap Buffer | JVM Heap | Managed by GC | General scenarios |
| Direct Buffer | OS Memory | Not managed by GC | Frequent I/O, Netty |
A Direct Buffer eliminates one data copy from "kernel space → user space," resulting in better I/O performance. However, its allocation and deallocation costs are higher, making it suitable for long-lived Buffers.
Channel
Characteristics of a Channel
- Bidirectional: Can be both read from and written to (though some Channels are unidirectional).
- Non-blocking: Can be configured in non-blocking mode.
- Works with Buffer: A Channel does not directly manipulate data; it transfers data through a Buffer.
Common Channel Types
| Channel | Purpose |
|---|---|
FileChannel |
File read/write. Does not support non-blocking mode. |
SocketChannel |
TCP client. |
ServerSocketChannel |
TCP server (listens for connections). |
DatagramChannel |
UDP communication. |
FileChannel Usage Example
// Read file
try (FileChannel channel = FileChannel.open(Path.of("input.txt"))) {
ByteBuffer buf = ByteBuffer.allocate(4096);
while (channel.read(buf) != -1) {
buf.flip();
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
buf.clear();
}
}
// Write file
try (FileChannel channel = FileChannel.open(
Path.of("output.txt"),
StandardOpenOption.WRITE,
StandardOpenOption.CREATE)) {
ByteBuffer buf = ByteBuffer.wrap("Hello NIO".getBytes());
channel.write(buf);
}
Channel Zero-Copy: transferTo
// Zero-copy: File data is transferred directly from the kernel page cache, bypassing user space
try (FileChannel src = FileChannel.open(Path.of("src.txt"));
FileChannel dest = FileChannel.open(Path.of("dest.txt"),
StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
src.transferTo(0, src.size(), dest);
// Underlying call to sendfile()/sendfile64(), data bypasses user-space Buffer
}
The Significance of Zero-Copy:
Standard file transfer (with user-space copy):
Disk → Kernel Buffer → User Buffer → Kernel Socket Buffer → NIC (4 copies)
transferTo (Zero-copy):
Disk → Kernel Buffer → NIC (2 copies, or 1 copy if DMA Gather is supported)
Netty's FileRegion and Kafka's network transmissions both utilize the principles of zero-copy.
Selector
Workflow
1. Create a Selector
Selector selector = Selector.open();
2. Register the Channel (Non-blocking)
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
3. Block and wait for readiness
int readyCount = selector.select(); // Blocks until at least one Channel is ready
4. Iterate over ready Channels
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey k = iter.next();
iter.remove(); // ⚠️ Must be manually removed
if (k.isReadable()) { ... }
}
Three select() Methods
// Blocks until at least one Channel is ready
selector.select();
// Blocks, waiting up to `timeout` milliseconds
selector.select(1000);
// Non-blocking, returns immediately, may return 0
selector.selectNow();
SelectionKey Deep Dive
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
// The interested events (specified during registration)
int interestOps = key.interestOps();
// The ready events (obtained after select())
int readyOps = key.readyOps();
// Direct checking methods
key.isAcceptable(); // OP_ACCEPT is ready
key.isConnectable(); // OP_CONNECT is ready
key.isReadable(); // OP_READ is ready
key.isWritable(); // OP_WRITE is ready
// Retrieve the Channel
SocketChannel channel = (SocketChannel) key.channel();
// Attached object (often used to bind business objects)
key.attach(mySession); // Bind
MySession session = (MySession) key.attachment(); // Retrieve
Why Must selectedKeys Be Manually Removed?
// ❌ Consequences of not removing
while (iter.hasNext()) {
SelectionKey key = iter.next();
// Do not remove key
process(key);
}
// Next time select() returns, the previous key is still in selectedKeys (even if the event was processed)
// → Results in duplicate processing
// ✅ Correct: Immediately remove after processing
iter.remove();
The Selector's selectedKeys is a set of ready events. The select() method is only responsible for adding to it, not clearing it. Therefore, we must manually remove the keys.
Complete NIO Server Example
public class NioServer {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("NIO Server started on 8080");
while (true) {
selector.select(); // Block and wait for readiness
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove(); // Must be removed
try {
if (key.isAcceptable()) {
handleAccept(server, selector);
} else if (key.isReadable()) {
handleRead(key);
}
} catch (IOException e) {
key.cancel();
key.channel().close();
}
}
}
}
static void handleAccept(ServerSocketChannel server, Selector selector)
throws IOException {
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
System.out.println("New connection: " + client.getRemoteAddress());
}
static void handleRead(SelectionKey key) throws IOException {
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = channel.read(buf);
if (n == -1) {
// Client disconnected
channel.close();
return;
}
buf.flip();
byte[] data = new byte[buf.remaining()];
buf.get(data);
System.out.println("Received: " + new String(data));
// Echo back
buf.rewind();
channel.write(buf);
}
}