Guide contents

Lesson 56 of 71

NIO Channels and Selectors

This lesson covers the java.nio.channels socket API: channels in blocking and non-blocking mode, and the Selector that lets one thread serve many connections. After it, a reader can write a single-threaded echo server, handle partial reads and writes, and explain how Netty builds on the same model. ByteBuffer (flip, compact, position and limit) is taught in ByteBuffer, Direct Buffers, and Memory-Mapped Files.

API Purpose
ServerSocketChannel Listens for and accepts TCP connections
SocketChannel One TCP connection, read and written with ByteBuffer
configureBlocking(false) Calls return at once instead of waiting
Selector Waits until any registered channel is ready
SelectionKey The registration of one channel: interest set, ready set, attachment

Every server binds to 127.0.0.1:0, so the operating system picks a free port, and the port is never printed. Each program drives itself with an in-process client and prints only after threads are joined, so the output does not depend on scheduling. TCP options and socket buffers in depth are the subject of TCP, UDP, Multicast, and the HTTP Client.

Example 1: blocking channels

A channel starts in blocking mode. accept waits for a client, read waits for at least one byte, and write waits until the whole buffer is handed to the kernel. One thread is busy per connection for the whole conversation.

Java
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;

public class BlockingEcho {
    public static void main(String[] args) throws Exception {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress("127.0.0.1", 0));
            System.out.println("server blocking: " + server.isBlocking());

            Thread serverThread = new Thread(() -> {
                try (SocketChannel conn = server.accept()) {
                    ByteBuffer buf = ByteBuffer.allocate(64);
                    conn.read(buf);
                    buf.flip();
                    conn.write(buf);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
            serverThread.start();

            String reply;
            try (SocketChannel client = SocketChannel.open(server.getLocalAddress())) {
                client.write(ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8)));
                ByteBuffer in = ByteBuffer.allocate(64);
                client.read(in);
                in.flip();
                reply = StandardCharsets.UTF_8.decode(in).toString();
            }
            serverThread.join();
            System.out.println("echo: " + reply);
        }
    }
}
Output
server blocking: true
echo: hello

server.getLocalAddress() returns the address with the port the system chose, so the client connects without the program knowing the number. One read returning all five bytes works here because the message is tiny and sent in one write; over a real network one message can arrive in several reads (Example 6).

Example 2: non-blocking mode

After configureBlocking(false), calls return at once. accept returns null when no client is waiting, read returns 0 when no data has arrived, and read returns -1 when the peer closed the connection.

Java
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

public class NonBlocking {
    public static void main(String[] args) throws Exception {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress("127.0.0.1", 0));
            server.configureBlocking(false);
            System.out.println("accept with no client: " + server.accept());

            try (SocketChannel client = SocketChannel.open(server.getLocalAddress())) {
                SocketChannel accepted = null;
                while (accepted == null) accepted = server.accept();   // the client is connected, so this ends
                try (SocketChannel conn = accepted) {
                    conn.configureBlocking(false);
                    ByteBuffer buf = ByteBuffer.allocate(16);
                    System.out.println("read with no data: " + conn.read(buf));

                    client.write(ByteBuffer.wrap(new byte[] {1, 2, 3}));
                    int n = 0;
                    while (n == 0) n = conn.read(buf);
                    System.out.println("read after write: " + n);

                    client.close();
                    int end = 0;
                    while (end == 0) end = conn.read(buf);
                    System.out.println("read after peer close: " + end);
                }
            }
        }
    }
}
Output
accept with no client: null
read with no data: 0
read after write: 3
read after peer close: -1

The while loops in this example spin and burn CPU while they wait. That is the problem a selector solves: instead of polling each channel, a thread asks the operating system which channels are ready and sleeps until one is.

Example 3: Selector and SelectionKey

A selector multiplexes many non-blocking channels: register adds a channel with an interest set (the operations to watch), and select blocks until at least one registered channel is ready for one of them. The result is a set of selection keys, one per ready channel, whose ready set says which operations can proceed.

Java
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

public class SelectorBasics {
    public static void main(String[] args) throws Exception {
        System.out.println("OP_READ=" + SelectionKey.OP_READ + " OP_WRITE=" + SelectionKey.OP_WRITE
            + " OP_CONNECT=" + SelectionKey.OP_CONNECT + " OP_ACCEPT=" + SelectionKey.OP_ACCEPT);

        try (Selector selector = Selector.open();
             ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress("127.0.0.1", 0));
            server.configureBlocking(false);
            SelectionKey key = server.register(selector, SelectionKey.OP_ACCEPT);
            System.out.println("interest: " + key.interestOps());
            System.out.println("ready now: " + selector.selectNow());

            try (SocketChannel client = SocketChannel.open(server.getLocalAddress())) {
                int ready = selector.select();
                SelectionKey k = selector.selectedKeys().iterator().next();
                System.out.println("ready after connect: " + ready + ", acceptable = " + k.isAcceptable()
                    + ", same key = " + (k == key));
                server.accept().close();
                selector.selectedKeys().clear();
            }
        }
    }
}
Output
OP_READ=1 OP_WRITE=4 OP_CONNECT=8 OP_ACCEPT=16
interest: 16
ready now: 0
ready after connect: 1, acceptable = true, same key = true

The operation constants are bits (1, 4, 8, 16 in the JDK source), so an interest set combines them with |. selectNow returns at once; select blocks; select(timeout) blocks up to a limit; wakeup makes a blocked select return from another thread. The selector does not remove keys from selectedKeys(): the program must clear the set, or the same key is processed again on the next round.

Example 4: an echo server on one thread

This server runs one loop on one thread. The loop accepts connections, reads whatever arrived, and writes it back. Each connection's SelectionKey carries its own ByteBuffer as an attachment, an object stored on the key for the handler to find. Three client threads each send a message and read the reply.

Java
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;

public class EchoServer {
    static void serve(Selector selector, ServerSocketChannel server, int connections) throws IOException {
        int closed = 0;
        while (closed < connections) {
            selector.select();
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();
                if (key.isAcceptable()) {
                    SocketChannel ch = server.accept();
                    ch.configureBlocking(false);
                    ch.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(256));
                } else if (key.isReadable()) {
                    SocketChannel ch = (SocketChannel) key.channel();
                    ByteBuffer buf = (ByteBuffer) key.attachment();
                    if (ch.read(buf) == -1) {
                        key.cancel();
                        ch.close();
                        closed++;
                        continue;
                    }
                    buf.flip();
                    while (buf.hasRemaining()) ch.write(buf);   // small replies; Example 5 handles partial writes
                    buf.clear();
                }
            }
        }
    }

    static String client(SocketAddress addr, String msg) throws IOException {
        try (SocketChannel ch = SocketChannel.open(addr)) {
            byte[] out = msg.getBytes(StandardCharsets.UTF_8);
            ch.write(ByteBuffer.wrap(out));
            ByteBuffer in = ByteBuffer.allocate(out.length);
            while (in.hasRemaining() && ch.read(in) != -1) { }
            return new String(in.array(), StandardCharsets.UTF_8);
        }
    }

    public static void main(String[] args) throws Exception {
        try (Selector selector = Selector.open();
             ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress("127.0.0.1", 0));
            server.configureBlocking(false);
            server.register(selector, SelectionKey.OP_ACCEPT);
            SocketAddress addr = server.getLocalAddress();

            ExecutorService clients = Executors.newFixedThreadPool(3);
            List<Future<String>> replies = new ArrayList<>();
            for (String m : List.of("alpha", "bravo", "charlie")) {
                replies.add(clients.submit(() -> client(addr, m)));
            }
            serve(selector, server, 3);
            clients.shutdown();
            for (Future<String> f : replies) System.out.println("reply: " + f.get());
            System.out.println("server threads used: 1");
        }
    }
}
Output
reply: alpha
reply: bravo
reply: charlie
server threads used: 1

The server loop runs on main; the clients run on a pool. Replies are printed in submission order after the futures complete, so the output is fixed even though the server handles the clients in whatever order they arrive. The client reads until it has as many bytes as it sent, because TCP does not keep message boundaries. Removing each key from the iterator is the clear step from Example 3.

Example 5: partial writes and OP_WRITE

On a non-blocking channel, write copies only as many bytes as fit in the kernel's send buffer and returns that count, which can be 0. A correct server keeps the unsent bytes, adds OP_WRITE to the interest set, and writes again when the selector reports the channel writable. It removes OP_WRITE once the buffer is empty, because a socket with free buffer space is writable almost all the time and would wake the selector on every loop.

Java
import java.net.InetSocketAddress;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.*;

public class PartialWrites {
    public static void main(String[] args) throws Exception {
        final int total = 4 * 1024 * 1024;
        try (Selector selector = Selector.open();
             ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress("127.0.0.1", 0));
            SocketChannel reader = SocketChannel.open(server.getLocalAddress());
            SocketChannel conn = server.accept();
            conn.configureBlocking(false);
            conn.setOption(StandardSocketOptions.SO_SNDBUF, 16 * 1024);

            ByteBuffer out = ByteBuffer.allocate(total);
            int firstWrite = conn.write(out);
            System.out.println("first write partial: " + (firstWrite < total));

            SelectionKey key = conn.register(selector, SelectionKey.OP_WRITE);
            long[] received = {0};
            Thread drain = new Thread(() -> {
                ByteBuffer in = ByteBuffer.allocate(64 * 1024);
                try (reader) {
                    int n;
                    while ((n = reader.read(in)) != -1) { received[0] += n; in.clear(); }
                } catch (Exception e) { throw new RuntimeException(e); }
            });
            drain.start();

            int writeWakeups = 0;
            while (out.hasRemaining()) {
                selector.select();
                selector.selectedKeys().clear();
                if (key.isWritable()) { writeWakeups++; conn.write(out); }
            }
            key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
            System.out.println("needed more than one OP_WRITE wakeup: " + (writeWakeups > 1));
            System.out.println("interest after drain: " + key.interestOps());
            conn.close();
            drain.join();
            System.out.println("bytes received: " + received[0]);
        }
    }
}
Output
first write partial: true
needed more than one OP_WRITE wakeup: true
interest after drain: 0
bytes received: 4194304

The first write of 4 MiB returns a smaller count, and the rest goes out over many writable events while the reader thread drains the other end. The exact counts depend on kernel buffer sizes, so the program prints checks instead of numbers. Reads have the same issue in the other direction: one read can return part of a message or parts of two messages, which Example 6 handles.

Example 6: framing partial reads

TCP delivers a byte stream, not messages. A frame is one message with a known boundary; here each message ends with \n. The handler appends incoming bytes to a per-connection buffer, extracts every complete line, and uses compact to keep the incomplete tail for the next read.

Java
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class LineFramer {
    private final ByteBuffer acc = ByteBuffer.allocate(1024);   // in write mode between calls

    List<String> onBytes(ByteBuffer chunk) {
        acc.put(chunk);
        acc.flip();
        List<String> lines = new ArrayList<>();
        int start = acc.position();
        for (int i = acc.position(); i < acc.limit(); i++) {
            if (acc.get(i) == '\n') {
                byte[] line = new byte[i - start];
                acc.get(start, line);
                lines.add(new String(line, StandardCharsets.UTF_8));
                start = i + 1;
            }
        }
        acc.position(start);
        acc.compact();
        return lines;
    }

    public static void main(String[] args) {
        LineFramer f = new LineFramer();
        String[] reads = {"PI", "NG\nTI", "ME\nQUIT", "\n"};
        for (String r : reads) {
            List<String> out = f.onBytes(ByteBuffer.wrap(r.getBytes(StandardCharsets.UTF_8)));
            System.out.println("read " + r.replace("\n", "\\n") + " -> " + out);
        }
    }
}
Output
read PI -> []
read NG\nTI -> [PING]
read ME\nQUIT -> [TIME]
read \n -> [QUIT]

The four reads simulate how three messages can be split by the network. The framer emits a message only when its terminator has arrived, and the second read yields PING while keeping TI for later. In a selector server, one LineFramer is the key's attachment, and onBytes runs after each read. Length-prefixed frames, used by binary protocols, follow the same pattern (see Binary Protocols and Encoding).

Example 7: the Netty model in short

Netty is a networking library built on the same selector loop. Its two main ideas are the event loop, one thread that owns a selector and a set of channels and runs all their I/O and tasks, and the pipeline, an ordered list of handlers through which each connection's inbound bytes and outbound messages pass. The program below models a pipeline in plain Java: a decoder turns bytes into lines, a business handler makes a reply, and an encoder turns the reply back into bytes.

Java
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

public class MiniPipeline {
    interface Handler extends Function<Object, List<Object>> { }

    static List<Object> run(List<Handler> pipeline, Object input) {
        List<Object> current = List.of(input);
        for (Handler h : pipeline) {
            List<Object> next = new ArrayList<>();
            for (Object o : current) next.addAll(h.apply(o));
            current = next;
        }
        return current;
    }

    public static void main(String[] args) {
        LineFramer framer = new LineFramer();
        Handler decoder = o -> new ArrayList<>(framer.onBytes((ByteBuffer) o));
        Handler business = o -> List.of(switch ((String) o) {
            case "PING" -> "PONG";
            case "TIME" -> "T=0";
            default -> "ERR " + o;
        });
        Handler encoder = o -> List.of(ByteBuffer.wrap((o + "\n").getBytes(StandardCharsets.UTF_8)));
        List<Handler> pipeline = List.of(decoder, business, encoder);

        for (String chunk : new String[] {"PI", "NG\nTIME\nFO", "O\n"}) {
            List<Object> out = run(pipeline, ByteBuffer.wrap(chunk.getBytes(StandardCharsets.UTF_8)));
            List<String> text = out.stream()
                .map(b -> StandardCharsets.UTF_8.decode((ByteBuffer) b).toString().replace("\n", "\\n"))
                .toList();
            System.out.println(chunk.replace("\n", "\\n") + " -> " + text);
        }
    }

    static class LineFramer {
        private final ByteBuffer acc = ByteBuffer.allocate(1024);
        List<String> onBytes(ByteBuffer chunk) {
            acc.put(chunk);
            acc.flip();
            List<String> lines = new ArrayList<>();
            int start = acc.position();
            for (int i = acc.position(); i < acc.limit(); i++) {
                if (acc.get(i) == '\n') {
                    byte[] line = new byte[i - start];
                    acc.get(start, line);
                    lines.add(new String(line, StandardCharsets.UTF_8));
                    start = i + 1;
                }
            }
            acc.position(start);
            acc.compact();
            return lines;
        }
    }
}
Output
PI -> []
NG\nTIME\nFO -> [PONG\n, T=0\n]
O\n -> [ERR FOO\n]

In Netty the same roles are named ByteToMessageDecoder, ChannelInboundHandler, and MessageToByteEncoder, and a server uses a "boss" group that accepts connections and a "worker" group of event loops, each serving many channels. A channel stays on one event loop for its whole life, so its handlers run on one thread and need no locks. The consequence of that design is the main rule of event-loop code: a handler must not block, because a blocked event loop stalls every channel it owns. Slow work goes to a separate executor. Netty itself is not on this course's class path, so it is described here rather than run.

Choosing a model

Model Threads Fits
Blocking, thread per connection One per connection Few connections, simple code
Blocking on virtual threads One virtual thread per connection Many connections, sequential code (see Virtual Threads)
Selector loop (NIO, Netty) A few event loops Many connections, control over buffers and latency

Interview points

  • Non-blocking accept returns null, read returns 0 when no data and -1 at end of stream, and write can write fewer bytes than asked, including 0.
  • A selector waits for readiness of many channels; the interest set says what to watch, the ready set says what happened.
  • selectedKeys() is not cleared by the selector; remove each key after handling it.
  • Register OP_WRITE only while there is unsent data, otherwise the loop spins.
  • TCP has no message boundaries; use framing (delimiter or length prefix) and keep partial data per connection.
  • Netty: one event loop thread per group of channels, handlers in a pipeline, never block the event loop.

Summary

  • Channels start in blocking mode; configureBlocking(false) is required before register.
  • SelectionKey constants: OP_READ=1, OP_WRITE=4, OP_CONNECT=8, OP_ACCEPT=16.
  • Per-connection state lives in the key's attachment.
  • Partial writes are finished on later OP_WRITE events; partial reads are assembled with compact.
  • wakeup unblocks select from another thread.
  • Netty's event loop and pipeline are a structured version of the selector loop in Example 4.