| Aspect | Blocking I/O (java.net) | Non-Blocking NIO (java.nio) |
| Thread model | One thread per connection | One thread for many connections |
| Scalability | Limited by thread count (~thousands) | Scales to 100k+ connections per thread |
| Complexity | Simple (sequential code) | Complex (state machine per connection) |
| Latency | Low for few connections | Slightly higher per-message latency |
| Idle connection cost | 1 thread per idle connection | Near zero |
| Use when | Low concurrency or simple protocols | High connection counts, proxy servers |
| Class | Role |
ServerSocketChannel | Non-blocking equivalent of ServerSocket; calls accept() which returns null instead of blocking |
SocketChannel | Non-blocking equivalent of Socket; read/write return 0 instead of blocking |
Selector | Multiplexer — select() blocks until at least one channel is ready |
SelectionKey | Token representing a channel + selector registration; carries interest ops and attachment |
ByteBuffer | Fixed-size buffer used for all channel reads/writes |
Selection key interest ops (bitfield constants on SelectionKey):
| Constant | Meaning |
OP_ACCEPT | Server ready to accept a new connection |
OP_CONNECT | Client connection finished (non-blocking connect) |
OP_READ | Channel has data available to read |
OP_WRITE | Channel has space in send buffer (usually ready immediately) |
A complete single-threaded NIO server that handles unlimited concurrent connections:
import java.io.IOException;
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
public class NioEchoServer {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
// 1. Open server channel and configure non-blocking
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(9000));
// 2. Register with selector for ACCEPT events
ssc.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("NIO echo server on port 9000");
while (true) {
// 3. Block until at least one channel is ready
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // MUST remove to avoid reprocessing
if (!key.isValid()) continue;
if (key.isAcceptable()) {
accept(key, selector);
} else if (key.isReadable()) {
read(key);
}
}
}
}
private static void accept(SelectionKey key, Selector selector) throws IOException {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel client = ssc.accept(); // non-blocking: never null here
if (client == null) return;
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
System.out.println("Accepted: " + client.getRemoteAddress());
}
private static void read(SelectionKey key) throws IOException {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buf = (ByteBuffer) key.attachment();
buf.clear();
int bytesRead = client.read(buf);
if (bytesRead == -1) {
// client closed connection
System.out.println("Closing: " + client.getRemoteAddress());
key.cancel();
client.close();
return;
}
// echo back
buf.flip();
client.write(buf); // in practice loop until buf is drained
}
}
Always call it.remove() after processing a key. The selectedKeys set is NOT automatically cleared between select() calls — failing to remove a key causes it to be re-processed on every iteration.
A client that connects asynchronously and waits for OP_CONNECT before sending data:
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
public class NioClient {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
// initiates connection; returns false if not yet complete
boolean connected = channel.connect(new InetSocketAddress("localhost", 9000));
if (connected) {
channel.register(selector, SelectionKey.OP_READ);
} else {
channel.register(selector, SelectionKey.OP_CONNECT);
}
while (true) {
selector.select();
for (SelectionKey key : selector.selectedKeys()) {
if (key.isConnectable()) {
channel.finishConnect(); // complete the handshake
key.interestOps(SelectionKey.OP_READ);
ByteBuffer msg = ByteBuffer.wrap("Hello NIO!".getBytes());
channel.write(msg);
} else if (key.isReadable()) {
ByteBuffer buf = ByteBuffer.allocate(256);
channel.read(buf);
buf.flip();
System.out.println(new String(buf.array(), 0, buf.limit()));
channel.close();
selector.close();
return;
}
}
selector.selectedKeys().clear();
}
}
}
A non-blocking write(buf) may only write part of the buffer if the OS send buffer is full. You must loop until the buffer is drained or register OP_WRITE to be notified when space is available.
// Write loop — drain the buffer completely
private static void writeAll(SocketChannel channel, ByteBuffer buf) throws IOException {
while (buf.hasRemaining()) {
channel.write(buf);
}
}
// Alternative: register OP_WRITE and come back when ready
private static void scheduleWrite(SelectionKey key, ByteBuffer buf) {
key.attach(buf);
key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
}
// In the select loop, handle OP_WRITE
if (key.isWritable()) {
SocketChannel ch = (SocketChannel) key.channel();
ByteBuffer buf = (ByteBuffer) key.attachment();
ch.write(buf);
if (!buf.hasRemaining()) {
// done writing — stop watching for WRITE (always ready, wastes CPU)
key.interestOps(SelectionKey.OP_READ);
}
}
Do NOT leave OP_WRITE registered permanently. The write channel is almost always ready, so the selector would spin at 100% CPU. Register it only when you have data queued to write, and deregister once the buffer is drained.
| Method | Behaviour |
select() | Blocks indefinitely until at least one channel is ready |
select(timeout) | Blocks up to timeout milliseconds; returns 0 on timeout |
selectNow() | Returns immediately with 0 if no channel is ready (non-blocking poll) |
wakeup() | Causes a blocked select() to return immediately from another thread |
// Wake up the selector from another thread (e.g. to register a new channel)
new Thread(() -> {
selector.wakeup(); // unblocks the select() in main thread
}).start();
Raw NIO is verbose and error-prone. Use it when you have minimal dependencies and need fine-grained control. Otherwise prefer:
| Library | Why use it over raw NIO |
| Netty | Pipeline handlers, codecs, HTTP/2, TLS, zero-copy, battle-tested |
| Java 21 Virtual Threads | Write blocking code; JVM maps to carrier threads — no NIO complexity |
| Vert.x | Event loop + reactive toolkit built on top of Netty |
| Project Reactor / WebFlux | Reactive streams over Netty for Spring applications |
Java 21 virtual threads make blocking I/O scalable again. For new projects, consider Executors.newVirtualThreadPerTaskExecutor() with plain Socket instead of raw NIO.