Non-Blocking NIO Networking in Java: Selector, SocketChannel & ServerSocketChannel

Java NIO (New I/O) channels can operate in non-blocking mode, allowing a single thread to manage thousands of concurrent connections. The Selector multiplexes I/O readiness events (accept, read, write, connect) across all registered channels. This is the reactor pattern that powers Netty and many high-throughput Java servers.

Share

Blocking I/O vs Non-Blocking NIO

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

Core NIO Networking Classes

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)

Non-Blocking Echo Server

A complete single-threaded NIO server that handles unlimited concurrent connections:

Java
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.

Non-Blocking NIO Client

A client that connects asynchronously and waits for OP_CONNECT before sending data:

Java
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();
        }
    }
}

Handling Partial Reads & Writes

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.

Java
// 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.

Selector.select() Variants

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
Java
// 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();

Practical Tips & When to Use Netty Instead

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.