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.
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");
}
}
}echo: BUY 100 AAPL
echo: SELL 50 MSFT
server doneshutdownOutput 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.
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));
}
}
}
}
}nodelay default: false
nodelay now: true
default sndbuf > 0: true
sndbuf after set > 0: true
rcvbuf > 0: true
connected: true
accepted nodelay: falseThe 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.
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();
}
}
}clients connected before any accept: true
accepted later: 3All 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.
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()) + ")");
}
}
}
}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.
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;
}
}
}
}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 1000On 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.
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());
}
}
}joined: true
received: snapshot ready
still joined: falseThe 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.
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);
}
}
}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 = trueturns Nagle off and is set per socket on both sides. SO_SNDBUF/SO_RCVBUFare 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.
HttpClientis thread-safe, pooled, and should be shared; always set connect and request timeouts.
Summary
ServerSocket.acceptandSocketstreams give a blocking TCP server and client.TCP_NODELAYdefaults tofalseon 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.sendandreceivemove one whole datagram each.- A gap in sequence numbers reveals lost datagrams; a lower number reveals duplicates or reordering.
DatagramChannel.joinreturns aMembershipKey;dropleaves the group.HttpClient(Java 11,AutoCloseablesince Java 21) offerssendandsendAsync.
