skip to content
everything.io.networking

sockets and selectors

TCP, UDP, and one thread watching many connections.

chapter
chapter 10.06
reading time
9 min read

a socket is one end of a TCP connection, and from java it's just a pair of streams: bytes in, bytes out. a ServerSocket waits for connections and hands you a new Socket for each one.

the hard part is many connections. the classic answer was one thread per connection. the NIO answer is a Selector: one thread asks the OS which of thousands of sockets are ready, and only touches those. since java 21, virtual threads made the simple version scale again.

the picture

you know how a phone call needs both people to pick up before anyone talks, while a text just gets sent and maybe read? that's TCP and UDP. TCP sets up a connection first (a handshake), then guarantees everything arrives, in order, or tells you it couldn't. UDP sends single messages into the void, no promises.

a TCP connection is a phone line between two programs, each identified by an IP address and a port (the extension number). the server picks a well-known extension and waits. the client dials it.

client
·
packets
server
LISTEN
  1. new ServerSocket(8080)
  2. server.accept()
  3. new Socket("host", 8080)▶SYN
  4. ◀SYN-ACK
  5. constructor returns▶ACKaccept() returns a Socket
  6. out.write("hello\n")▶data: hello\n
  7. ◀ACKin.readLine() → "hello"
  8. in.readLine() → "HELLO"◀data: HELLO\nout.write("HELLO\n")
  9. socket.close()▶FIN
  10. ◀FINsocket.close()

the server binds port 8080 and tells the OS to start queueing connections for it. nothing is connected yet.

step 1/10
step through one connection's whole life. the left and right columns are the java calls; the middle is what goes over the wire. blue packets are caused by your code, grey ones the two kernels send on their own. the state under each name is what netstat would show.

here's that conversation for real, server and client in one program over localhost. port 0 asks the OS for any free port, so it can't collide with anything:

Main.java
import java.io.*;
import java.net.*;
 
void main() throws Exception {
    try (var server = new ServerSocket(0)) {             // port 0: any free port
        int port = server.getLocalPort();
 
        Thread.ofVirtual().start(() -> {                 // the server side
            try (Socket s = server.accept();
                 var in = new BufferedReader(new InputStreamReader(s.getInputStream()));
                 var out = new PrintWriter(s.getOutputStream(), true)) {
                String line;
                while ((line = in.readLine()) != null) out.println(line.toUpperCase());
            } catch (IOException e) { throw new UncheckedIOException(e); }
        });
 
        try (var s = new Socket("localhost", port);       // the client side
             var in = new BufferedReader(new InputStreamReader(s.getInputStream()));
             var out = new PrintWriter(s.getOutputStream(), true)) {
            for (String msg : new String[] { "hello", "tcp" }) {
                out.println(msg);
                IO.println(msg + " → " + in.readLine());
            }
        }
    }
}
output, from jdk 25
hello → HELLO
tcp → TCP

this one needs a real JVM (virtual threads), so it doesn't run in the browser. copy it into java Main.java to try it.

see the onion from i/o streams? getInputStream() is a raw byte stream; we wrapped it in a reader exactly like a file. PrintWriter(out, true) turns on autoflush, so println actually sends. forget that and both sides wait forever, each for the other to talk first.

TCP is a stream, not messages

the most common networking bug: assuming one write on one side is one read on the other. TCP only promises the bytes arrive in order. how they're chopped into reads is up to the network, the kernel and timing.

Main.java
import java.net.*;
 
void main() throws Exception {
    try (var server = new ServerSocket(0);
         var client = new Socket("localhost", server.getLocalPort());
         var conn = server.accept()) {
 
        client.getOutputStream().write("hello".getBytes());   // two separate writes
        client.getOutputStream().write("world".getBytes());
        Thread.sleep(100);                                     // let both arrive
 
        byte[] buf = new byte[100];
        int n = conn.getInputStream().read(buf);               // one read
        IO.println(n + " bytes in one read: " + new String(buf, 0, n));
    }
}
output, from jdk 25
10 bytes in one read: helloworld

this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.

two writes, one read. on a slow link it could just as easily be three reads of 3, 4 and 3 bytes. that's why every protocol has framing: a delimiter (HTTP/1's \r\n, the newline that readLine() looks for), or a length prefix (DataOutputStream.writeInt(len) then the bytes), so the reader knows where one message ends.

quick check
a client sends three 100-byte messages with three write() calls. the server calls read(buf) with a 1000-byte buffer. how many bytes can that first read return?

one thread per connection

the blocking API makes the server loop easy to write: accept, hand the socket to a thread, repeat. each thread reads, blocks until bytes arrive, handles them, writes back.

Server.java
try (var server = new ServerSocket(8080)) {
    while (true) {
        Socket s = server.accept();
        Thread.ofPlatform().start(() -> handle(s));   // one thread per client
    }
}

the problem is what those threads do all day: wait. a chat server with 10,000 connected users has 10,000 threads, almost all parked in read(), each holding its own stack (a megabyte or more reserved by default). the OS scheduler and the memory give out long before the CPU does.

selectors: one thread, many sockets

NIO's answer (java 1.4, 2002) flips it around. sockets become non-blocking SocketChannels, all registered with one Selector. one thread calls select(), the OS (epoll on linux, kqueue on macOS) wakes it with exactly the set of channels that are ready, the thread handles those, and goes back to select().

model =
conn 0quiet❚❚thread-0 parked in read()
conn 1quiet❚❚thread-1 parked in read()
conn 2quiet❚❚thread-2 parked in read()
conn 3quiet❚❚thread-3 parked in read()
conn 4quiet❚❚thread-4 parked in read()
conn 5quiet❚❚thread-5 parked in read()
conn 6quiet❚❚thread-6 parked in read()
conn 7quiet❚❚thread-7 parked in read()
threads = 8busy right now = 0parked doing nothing = 8messages handled = 0

nobody has data. 8 threads, all parked. that's the shape of most servers most of the time.

step 1/7
the same 6 rounds of traffic, two ways to serve it. switch models and step. with threads, every quiet connection still costs a parked thread; with a selector, one thread only ever touches the ready connections.
Main.java
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
 
void main() throws Exception {
    Selector selector = Selector.open();
    ServerSocketChannel server = ServerSocketChannel.open();
    server.bind(new InetSocketAddress("localhost", 0));
    server.configureBlocking(false);
    server.register(selector, SelectionKey.OP_ACCEPT);
 
    Thread.ofPlatform().daemon().start(() -> {           // the whole server: one thread
        try {
            while (true) {
                selector.select();                       // sleep until some channel is ready
                var it = selector.selectedKeys().iterator();
                while (it.hasNext()) {
                    SelectionKey key = it.next();
                    it.remove();
                    if (key.isAcceptable()) {
                        SocketChannel c = server.accept();
                        c.configureBlocking(false);
                        c.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(256));
                    } else if (key.isReadable()) {
                        var c = (SocketChannel) key.channel();
                        var buf = (ByteBuffer) key.attachment();
                        if (c.read(buf) == -1) { c.close(); continue; }
                        buf.flip();
                        c.write(buf);                    // echo it back
                        buf.clear();
                    }
                }
            }
        } catch (Exception e) { throw new RuntimeException(e); }
    });
 
    var clients = new SocketChannel[3];
    for (int i = 0; i < 3; i++) clients[i] = SocketChannel.open(server.getLocalAddress());
 
    for (int i = 2; i >= 0; i--) {                       // any order: nobody is blocked on a client
        clients[i].write(ByteBuffer.wrap(("client " + i).getBytes()));
        var reply = ByteBuffer.allocate(256);
        clients[i].read(reply);
        IO.println("echo: " + new String(reply.array(), 0, reply.position()));
    }
    for (var c : clients) c.close();
}
output, from jdk 25
echo: client 2
echo: client 1
echo: client 0

this one needs a real JVM (catching a runtime exception), so it doesn't run in the browser. copy it into java Main.java to try it.

three connections, one server thread, served in whatever order they talk. the ByteBuffer attached to each key is that connection's state (that's the flip/clear loop from buffers and channels). the catch is visible right there in the code: every bit of per-connection progress lives in your own state objects, because no thread's call stack remembers "where was i with client 7". that's why people use Netty (built on exactly this loop) instead of writing it by hand.

and then virtual threads happened

since java 21 (virtual threads) the thread-per-connection code from above scales too, with one change: Thread.ofVirtual(). a blocked virtual thread isn't parked on an OS thread. under the hood, read() on a socket registers with the JDK's own poller (a selector, again) and unmounts the virtual thread until bytes arrive. you write blocking code, the JDK runs a selector for you.

modelthreads at 10,000 connectionscode style
platform thread per connection10,000 OS threadssimple, blocking
selector loop (NIO, Netty)a handfulcallbacks and state machines
virtual thread per connection10,000 virtual, a few OS carrierssimple, blocking

this only works because the old Socket API was rewritten on top of NIO in java 13 (JEP 353), with DatagramSocket following in 15 (JEP 373). the old implementation dated from 1996 and couldn't cooperate with virtual threads at all.

UDP: datagrams

UDP is the text message. no connection, no handshake, no ordering, no retries. each send is one datagram, and each receive gets exactly one datagram, whole, so message boundaries survive (the opposite of TCP). it can be lost, duplicated or arrive out of order, and nobody tells you.

Main.java
import java.net.*;
import java.nio.charset.StandardCharsets;
 
void main() throws Exception {
    try (var receiver = new DatagramSocket(0, InetAddress.getLoopbackAddress());
         var sender = new DatagramSocket()) {
        byte[] msg = "ping".getBytes(StandardCharsets.UTF_8);
        sender.send(new DatagramPacket(msg, msg.length, receiver.getLocalSocketAddress()));
 
        var buf = new byte[1500];
        var packet = new DatagramPacket(buf, buf.length);
        receiver.setSoTimeout(2000);                  // never wait forever on UDP
        receiver.receive(packet);
        IO.println(new String(buf, 0, packet.getLength(), StandardCharsets.UTF_8)
            + " (" + packet.getLength() + " bytes, one datagram)");
    }
}
output, from jdk 25
ping (4 bytes, one datagram)

this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.

DNS, video calls, games and QUIC (the transport under HTTP/3) use UDP, because for them a late packet is worse than a lost one, or because they build their own reliability on top.

one level deeper:what select() is actually doing+

Selector is a thin wrapper over the OS's readiness API. on jdk 25 on a mac, SelectorProvider.provider() is sun.nio.ch.KQueueSelectorProvider; on linux it's EPollSelectorProvider. registering a channel adds its file descriptor to a kernel-side interest set. select() makes one syscall that sleeps until any of them has data (or buffer space, or a pending connection), and comes back with the list. the cost of select() scales with the number of ready channels, not the number registered, which is the whole trick: 10,000 idle connections cost the kernel some bookkeeping and cost you nothing per round. the same machinery, a Poller, is what lets a blocked virtual thread give up its carrier.

keylines
  • a socket is a pair of byte streams. ServerSocket.accept() hands you one per client.
  • TCP: connected, ordered, reliable, a stream with no message boundaries. UDP: single datagrams, no promises.
  • one write is not one read. frame your messages.
  • a Selector lets one thread serve thousands of sockets by only touching the ready ones.
  • virtual threads (21) make blocking thread-per-connection code scale; the JDK runs the selector for you.
  • every blocking call needs a timeout.