Guide contents

Lesson 57 of 71

TCP, UDP, Multicast, and the HTTP Client

This lesson covers the transport layer as Java exposes it: blocking TCP sockets, the socket options that matter for latency, UDP datagrams, multicast, and the HTTP client added in Java 11. It builds on Streams, Readers, and Files and NIO Channels and Selectors. After it, a reader can choose between TCP and UDP for a given job, set the options a low-latency service needs, and detect lost datagrams.

API or idea Purpose
ServerSocket, Socket Blocking TCP server and client
TCP_NODELAY Turns off Nagle's algorithm so small writes are sent at once
SO_SNDBUF, SO_RCVBUF Size of the kernel send and receive buffers
backlog Queue of connections the kernel completed but the program has not accepted
DatagramChannel UDP send and receive
join on DatagramChannel Multicast group membership
sequence numbers Detect loss and reordering over UDP
java.net.http.HttpClient HTTP/1.1 and HTTP/2 client, sync and async (Java 11)

Every server binds to 127.0.0.1 on port 0, which asks the kernel for any free port. Clients run in the same process, and no example prints a port number.

Property TCP UDP
Connection Yes, three-way handshake No
Delivery Reliable, in order May be lost, duplicated, or reordered
Unit Byte stream, no message boundaries Datagram, boundaries kept
Flow and congestion control Yes No
Typical use Order entry, HTTP, databases Market data feeds, multicast, discovery

Example 1: a blocking TCP echo server

A ServerSocket listens for connections, and accept blocks until one arrives and returns a Socket for it. TCP is a byte stream: the receiver sees bytes, not messages, so this example uses lines as the message boundary.

Java
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class TcpEcho {
    public static void main(String[] args) throws Exception {
        try (ServerSocket server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) {
            Thread serverThread = Thread.ofPlatform().start(() -> {
                try (Socket s = server.accept();
                     var in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
                     var out = new PrintWriter(s.getOutputStream(), true, StandardCharsets.UTF_8)) {
                    String line;
                    while ((line = in.readLine()) != null) {
                        out.println("echo: " + line);
                    }
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            });

            try (Socket client = new Socket(server.getInetAddress(), server.getLocalPort());
                 var in = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
                 var out = new PrintWriter(client.getOutputStream(), true, StandardCharsets.UTF_8)) {
                for (String msg : new String[] {"BUY 100 AAPL", "SELL 50 MSFT"}) {
                    out.println(msg);
                    System.out.println(in.readLine());
                }
                client.shutdownOutput();
            }
            serverThread.join();
            System.out.println("server done");
        }
    }
}
Output
echo: BUY 100 AAPL
echo: SELL 50 MSFT
server done

shutdownOutput sends a FIN, so the server's readLine returns null and its loop ends. The server serves one client on one thread. A real blocking server accepts in a loop and hands each Socket to its own thread; with virtual threads that model scales to many connections. NIO Channels and Selectors shows the single-thread alternative.

Example 2: TCP_NODELAY and kernel buffers

Nagle's algorithm holds a small segment back while earlier data is still unacknowledged, so that several small writes go out as one packet. Combined with delayed ACK on the receiver (the receiver waits briefly before acknowledging), it can add tens of milliseconds to a small request. TCP_NODELAY = true turns Nagle off; latency-sensitive services set it on every socket.

SO_SNDBUF and SO_RCVBUF request sizes for the kernel buffers that hold data waiting to be sent or read. The kernel may round, double, or cap the value (Linux doubles it and caps it at net.core.wmem_max and rmem_max), so the example prints checks, not sizes.

Java
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.StandardSocketOptions;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

public class SocketOptions {
    public static void main(String[] args) throws Exception {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
            try (SocketChannel client = SocketChannel.open()) {
                System.out.println("nodelay default: " + client.getOption(StandardSocketOptions.TCP_NODELAY));
                client.setOption(StandardSocketOptions.TCP_NODELAY, true);
                System.out.println("nodelay now: " + client.getOption(StandardSocketOptions.TCP_NODELAY));

                int before = client.getOption(StandardSocketOptions.SO_SNDBUF);
                client.setOption(StandardSocketOptions.SO_SNDBUF, 1 << 20);
                client.setOption(StandardSocketOptions.SO_RCVBUF, 1 << 20);
                int after = client.getOption(StandardSocketOptions.SO_SNDBUF);
                System.out.println("default sndbuf > 0: " + (before > 0));
                System.out.println("sndbuf after set > 0: " + (after > 0));
                System.out.println("rcvbuf > 0: " + (client.getOption(StandardSocketOptions.SO_RCVBUF) > 0));

                client.connect(server.getLocalAddress());
                try (SocketChannel accepted = server.accept()) {
                    System.out.println("connected: " + client.isConnected());
                    System.out.println("accepted nodelay: " + accepted.getOption(StandardSocketOptions.TCP_NODELAY));
                }
            }
        }
    }
}
Output
nodelay default: false
nodelay now: true
default sndbuf > 0: true
sndbuf after set > 0: true
rcvbuf > 0: true
connected: true
accepted nodelay: false

The option is per socket: the accepted socket on the server side keeps the default false until the server sets it too. SO_RCVBUF must be set before connect (on the server, before bind) to affect the TCP window scale negotiated in the handshake. When a receive buffer fills, TCP flow control stops the sender; when a send buffer fills, a blocking write blocks and a non-blocking one writes fewer bytes.

Example 3: the accept backlog

The kernel completes the TCP handshake without the program's help. Finished connections wait in the accept queue until accept takes them. The backlog argument of ServerSocket (or bind on a channel) sets the length of that queue; Linux also caps it at net.core.somaxconn.

Java
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;

public class Backlog {
    public static void main(String[] args) throws Exception {
        try (ServerSocket server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) {
            List<Socket> clients = new ArrayList<>();
            for (int i = 0; i < 3; i++) {
                clients.add(new Socket(server.getInetAddress(), server.getLocalPort()));
            }
            System.out.println("clients connected before any accept: "
                    + clients.stream().allMatch(Socket::isConnected));

            int accepted = 0;
            for (int i = 0; i < 3; i++) {
                try (Socket s = server.accept()) {
                    accepted++;
                }
            }
            System.out.println("accepted later: " + accepted);
            for (Socket c : clients) c.close();
        }
    }
}
Output
clients connected before any accept: true
accepted later: 3

All three connect calls returned before the server called accept. When the queue is full, Linux drops new SYN packets and the client retries, which shows up as connect latency of a second or more. A slow accept loop under a connection burst causes the same symptom.

Example 4: UDP with DatagramChannel

UDP sends independent datagrams: each send is one packet, and each receive returns one whole datagram or nothing. There is no connection, so the sender names the target address on every send.

Java
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.nio.charset.StandardCharsets;

public class UdpBasics {
    public static void main(String[] args) throws Exception {
        InetAddress lo = InetAddress.getLoopbackAddress();
        try (DatagramChannel receiver = DatagramChannel.open().bind(new InetSocketAddress(lo, 0));
             DatagramChannel sender = DatagramChannel.open().bind(new InetSocketAddress(lo, 0))) {
            SocketAddress target = receiver.getLocalAddress();
            for (String msg : new String[] {"tick AAPL 190.10", "tick MSFT 410.55"}) {
                sender.send(ByteBuffer.wrap(msg.getBytes(StandardCharsets.UTF_8)), target);
            }

            ByteBuffer buf = ByteBuffer.allocate(1500);
            for (int i = 0; i < 2; i++) {
                buf.clear();
                SocketAddress from = receiver.receive(buf);
                buf.flip();
                System.out.println(StandardCharsets.UTF_8.decode(buf)
                        + " (" + buf.limit() + " bytes, from sender: " + from.equals(sender.getLocalAddress()) + ")");
            }
        }
    }
}
Output
tick AAPL 190.10 (16 bytes, from sender: true)
tick MSFT 410.55 (16 bytes, from sender: true)

On loopback nothing is lost. On a real network a datagram larger than the path MTU (often 1500 bytes on Ethernet, minus 28 bytes of IP and UDP headers) is fragmented, and losing any fragment loses the whole datagram. Market data protocols keep datagrams below the MTU for that reason. If the receive buffer is smaller than the datagram, the rest of the datagram is discarded silently.

Example 5: sequence numbers and gap detection

Because UDP can lose and reorder datagrams, feeds put a sequence number in every message. The receiver expects last + 1; a higher number means a gap, and a lower one means a duplicate or a late packet. This example drops every third datagram in code, so the loss is the same on every run.

Java
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;

public class UdpGaps {
    public static void main(String[] args) throws Exception {
        InetAddress lo = InetAddress.getLoopbackAddress();
        try (DatagramChannel receiver = DatagramChannel.open().bind(new InetSocketAddress(lo, 0));
             DatagramChannel sender = DatagramChannel.open()) {
            var target = receiver.getLocalAddress();
            int sent = 0;
            for (long seq = 1; seq <= 10; seq++) {
                if (seq % 3 == 0) continue;               // simulated loss
                ByteBuffer out = ByteBuffer.allocate(16).putLong(seq).putLong(seq * 100);
                sender.send(out.flip(), target);
                sent++;
            }

            long expected = 1;
            ByteBuffer in = ByteBuffer.allocate(16);
            for (int i = 0; i < sent; i++) {
                in.clear();
                receiver.receive(in);
                in.flip();
                long seq = in.getLong();
                long price = in.getLong();
                if (seq > expected) {
                    System.out.println("gap: missing " + expected + ".." + (seq - 1));
                } else if (seq < expected) {
                    System.out.println("duplicate or late: " + seq);
                    continue;
                }
                System.out.println("seq " + seq + " price " + price);
                expected = seq + 1;
            }
        }
    }
}
Output
seq 1 price 100
seq 2 price 200
gap: missing 3..3
seq 4 price 400
seq 5 price 500
gap: missing 6..6
seq 7 price 700
seq 8 price 800
gap: missing 9..9
seq 10 price 1000

On a gap, a receiver has three choices: request a retransmission (many exchanges run a separate TCP recovery service for this), wait for a snapshot of full state, or, for data where only the latest value matters, skip ahead. The byte layout used here (two long values in big-endian order) is covered in Binary Protocols and Encoding.

Example 6: multicast groups

Multicast sends one datagram to a group address (IPv4 224.0.0.0 to 239.255.255.255), and the network copies it to every host that joined the group. Exchanges publish market data this way. DatagramChannel.join(group, interface) joins a group on a network interface; MulticastSocket.joinGroup is the older blocking API for the same thing.

Java
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.net.StandardProtocolFamily;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.nio.channels.MembershipKey;
import java.nio.charset.StandardCharsets;

public class MulticastDemo {
    public static void main(String[] args) throws Exception {
        NetworkInterface lo = NetworkInterface.getByInetAddress(InetAddress.getLoopbackAddress());
        InetAddress group = InetAddress.getByName("239.1.2.3");
        try (DatagramChannel receiver = DatagramChannel.open(StandardProtocolFamily.INET);
             DatagramChannel sender = DatagramChannel.open(StandardProtocolFamily.INET)) {
            receiver.setOption(StandardSocketOptions.SO_REUSEADDR, true);
            receiver.bind(new InetSocketAddress(0));
            MembershipKey key = receiver.join(group, lo);
            System.out.println("joined: " + key.isValid());
            int port = ((InetSocketAddress) receiver.getLocalAddress()).getPort();

            sender.setOption(StandardSocketOptions.IP_MULTICAST_IF, lo);
            sender.setOption(StandardSocketOptions.IP_MULTICAST_TTL, 1);
            sender.send(ByteBuffer.wrap("snapshot ready".getBytes(StandardCharsets.UTF_8)),
                    new InetSocketAddress(group, port));

            receiver.configureBlocking(false);
            ByteBuffer buf = ByteBuffer.allocate(1500);
            for (int i = 0; i < 200 && receiver.receive(buf) == null; i++) {
                Thread.sleep(10);
            }
            buf.flip();
            System.out.println("received: " + StandardCharsets.UTF_8.decode(buf));
            key.drop();
            System.out.println("still joined: " + key.isValid());
        }
    }
}
Output
joined: true
received: snapshot ready
still joined: false

The receiver binds the wildcard address, because a socket bound to 127.0.0.1 does not receive datagrams sent to the group address. IP_MULTICAST_IF picks the outgoing interface, and IP_MULTICAST_TTL = 1 keeps packets on the local subnet. Multicast on loopback works on this Linux machine; some containers and cloud networks do not route multicast at all, in which case the receive loop above ends with nothing read. The receiver polls in non-blocking mode only to bound the wait.

Example 7: java.net.http.HttpClient, sync and async

java.net.http.HttpClient (Java 11) supports HTTP/1.1 and HTTP/2, synchronous and asynchronous calls, and WebSocket. Since Java 21 it implements AutoCloseable. The example starts the JDK's built-in com.sun.net.httpserver.HttpServer on a free port, so it runs without network access.

Java
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;

public class HttpClientDemo {
    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/price", exchange -> {
            String symbol = exchange.getRequestURI().getQuery().split("=")[1];
            byte[] body = ("{\"symbol\":\"" + symbol + "\",\"price\":190}")
                    .getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add("Content-Type", "application/json");
            exchange.sendResponseHeaders(200, body.length);
            exchange.getResponseBody().write(body);
            exchange.close();
        });
        server.start();
        String base = "http://127.0.0.1:" + server.getAddress().getPort();

        try (HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(2))
                .build()) {
            HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/price?s=AAPL"))
                    .timeout(Duration.ofSeconds(2))
                    .GET()
                    .build();
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
            System.out.println(response.statusCode());
            System.out.println(response.headers().firstValue("content-type").orElse("none"));
            System.out.println(response.body());

            List<CompletableFuture<String>> futures = List.of("MSFT", "IBM").stream()
                    .map(s -> client.sendAsync(
                                    HttpRequest.newBuilder(URI.create(base + "/price?s=" + s)).build(),
                                    HttpResponse.BodyHandlers.ofString())
                            .thenApply(HttpResponse::body))
                    .toList();
            for (CompletableFuture<String> f : futures) {
                System.out.println(f.join());
            }
        } finally {
            server.stop(0);
        }
    }
}
Output
200
application/json
{"symbol":"AAPL","price":190}
{"symbol":"MSFT","price":190}
{"symbol":"IBM","price":190}

send blocks the calling thread; sendAsync returns a CompletableFuture at once, so both async requests are in flight together. The results print in list order because the loop joins the futures in that order, not in completion order. One HttpClient holds a connection pool and should be shared, not built per request. headers().firstValue matches header names without regard to case. Without connectTimeout and a request timeout, a call to a stalled server can wait indefinitely.

Interview points

  • TCP is a reliable ordered byte stream with no message boundaries; framing is the application's job. UDP keeps datagram boundaries but can lose, duplicate, and reorder.
  • Nagle's algorithm plus delayed ACK can delay small writes by tens of milliseconds; TCP_NODELAY = true turns Nagle off and is set per socket on both sides.
  • SO_SNDBUF/SO_RCVBUF are requests; Linux doubles and caps them. Set the receive buffer before connecting so window scaling uses it.
  • The backlog is the accept queue of completed handshakes. A full queue drops SYNs and causes multi-second connect delays.
  • UDP datagrams above the path MTU are fragmented and lost as a whole if any fragment is lost.
  • Feeds over UDP carry sequence numbers; on a gap the receiver requests retransmission, waits for a snapshot, or skips.
  • Multicast needs a group join on a specific interface; many cloud networks do not support it.
  • HttpClient is thread-safe, pooled, and should be shared; always set connect and request timeouts.

Summary

  • ServerSocket.accept and Socket streams give a blocking TCP server and client.
  • TCP_NODELAY defaults to false on a new socket.
  • Kernel buffer sizes vary by kernel and settings; programs check them rather than assume values.
  • Connections complete in the kernel and wait in the accept queue until accept.
  • DatagramChannel.send and receive move one whole datagram each.
  • A gap in sequence numbers reveals lost datagrams; a lower number reveals duplicates or reordering.
  • DatagramChannel.join returns a MembershipKey; drop leaves the group.
  • HttpClient (Java 11, AutoCloseable since Java 21) offers send and sendAsync.