AspectBlocking I/O (java.net)Non-Blocking NIO (java.nio)
Thread modelOne thread per connectionOne thread for many connections
ScalabilityLimited by thread count (~thousands)Scales to 100k+ connections per thread
ComplexitySimple (sequential code)Complex (state machine per connection)
LatencyLow for few connectionsSlightly higher per-message latency
Idle connection cost1 thread per idle connectionNear zero
Use whenLow concurrency or simple protocolsHigh connection counts, proxy servers
ClassRole
ServerSocketChannelNon-blocking equivalent of ServerSocket; calls accept() which returns null instead of blocking
SocketChannelNon-blocking equivalent of Socket; read/write return 0 instead of blocking
SelectorMultiplexer — select() blocks until at least one channel is ready
SelectionKeyToken representing a channel + selector registration; carries interest ops and attachment
ByteBufferFixed-size buffer used for all channel reads/writes

Selection key interest ops (bitfield constants on SelectionKey):

ConstantMeaning
OP_ACCEPTServer ready to accept a new connection
OP_CONNECTClient connection finished (non-blocking connect)
OP_READChannel has data available to read
OP_WRITEChannel 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.
MethodBehaviour
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:

LibraryWhy use it over raw NIO
NettyPipeline handlers, codecs, HTTP/2, TLS, zero-copy, battle-tested
Java 21 Virtual ThreadsWrite blocking code; JVM maps to carrier threads — no NIO complexity
Vert.xEvent loop + reactive toolkit built on top of Netty
Project Reactor / WebFluxReactive 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.