14. Networking in Java
Chapter 13 moved bytes between a program and its own machine. Networking moves them between machines that share no memory, no clock, and no guarantee of staying reachable, which turns every method call into something that can be slow, reordered, or silently lost. This chapter covers the addressing model Java exposes, the TCP and UDP socket APIs, the modern HTTP client, and the non-blocking machinery and protocol design decisions that serious network code depends on.
<a id="141-networking-foundations"></a>
14.1 Networking Foundations
This subchapter covers the mental model underneath every later API: how Java maps onto the TCP/IP stack, what an address and a port actually identify, and how names become routable numbers.
The Java Networking Model
A program confined to one machine can treat its data as instantly available and permanently correct. A networked program cannot. Between the caller and the callee sit cables, routers, firewalls, and another operating system that may be rebooting. Every assumption that holds for a method call fails here, which is why the classic fallacies of distributed computing are worth internalising before writing a line of socket code.
| Fallacy | What actually happens |
|---|---|
| The network is reliable | Packets are dropped, duplicated, reordered |
| Latency is zero | A round trip is millions of times slower than a method call |
| Bandwidth is infinite | Large payloads queue and congest |
| The network is secure | Anything unencrypted is readable and forgeable |
| Topology does not change | Addresses move, load balancers reroute |
| There is one administrator | Firewalls and proxies you do not control interfere |
| Transport cost is zero | Serialisation and syscalls dominate small messages |
Java does not hide any of this. It gives you a thin, honest wrapper over the operating system's TCP/IP stack, and the layers line up almost one-to-one with the types you will use.
LAYER PROTOCOLS JAVA TYPES
-----------------------------------------------------------------
Application HTTP, DNS, SMTP HttpClient, URL, URLConnection
Transport TCP UDP Socket/ServerSocket DatagramSocket
Internet IP InetAddress, Inet4Address, Inet6Address
Link Ethernet, Wi-Fi NetworkInterface
-----------------------------------------------------------------
^ the OS implements everything below the Java column
Addressing: what identifies whom. Two numbers are needed to deliver data to a specific program on a specific machine.
- An IP address identifies a network interface on a host. It gets the data to the right machine.
- A port is a 16-bit number identifying one endpoint inside that machine. It gets the data to the right process.
The building analogy is exact. The IP address is the street address of an apartment block, and the port is the apartment number. The postal service routes on the first and the doorman routes on the second.
A socket is the endpoint object your program holds. What uniquely identifies an established TCP connection, though, is not the socket but a four-part tuple.
(source IP, source port, destination IP, destination port)
203.0.113.9:51514 -> 198.51.100.4:443 browser tab 1
203.0.113.9:51515 -> 198.51.100.4:443 browser tab 2
198.51.100.7:40122 -> 198.51.100.4:443 a different client
^ differing source ports and IPs keep these three apart
This is why one server listening on a single port serves thousands of clients at once: each connection differs in at least one element of the tuple. A server does not need a port per client.
Ports fall into three ranges by convention, and the first range is enforced.
| Range | Name | Notes |
|---|---|---|
| 0 - 1023 | Well-known | Requires privilege on Unix; HTTP 80, HTTPS 443, SSH 22 |
| 1024 - 49151 | Registered | Assigned to applications; 8080, 5432, 6379 |
| 49152 - 65535 | Ephemeral | Handed out automatically to outbound connections |
Binding to port 0 asks the operating system for a free ephemeral port, which
is the correct way to write a test that must not collide with anything.
The one decision that shapes everything: TCP or UDP.
| Aspect | TCP | UDP |
|---|---|---|
| Connection | Established by a three-way handshake | None; each datagram is standalone |
| Reliability | Acknowledged and retransmitted | Lost packets stay lost |
| Ordering | Delivered in the order sent | Any order, or not at all |
| Message boundaries | None; it is a byte stream | Preserved; one send, one receive |
| Flow control | Yes, and congestion control | None; you can flood a link |
| Header overhead | 20 bytes, plus handshake round trips | 8 bytes, no setup |
| Java types | Socket, ServerSocket | DatagramSocket, DatagramPacket |
| Typical uses | HTTP, databases, SSH, file transfer | DNS, video, gaming, telemetry |
One row in that table causes more bugs than all the others combined. TCP has
no message boundaries. It guarantees that the bytes you sent arrive in order,
and nothing else. Ten calls to write may arrive as one read, or as thirty.
SENDER writes three logical messages
[ "LOGIN ada" ] [ "GET /x" ] [ "QUIT" ]
|
v TCP sees only a byte stream
RECEIVER may read
[ "LOGIN adaGET /" ] [ "xQUIT" ] <- perfectly legal, and very common
Recovering the messages is the application's job, which is exactly what 14.4 covers under protocol framing. UDP has the opposite property: boundaries are preserved, but the datagram may never arrive.
The execution model is blocking by default. A call to read on a socket
parks the calling thread until bytes arrive, a timeout fires, or the peer
disconnects. That simplicity is why the classic design is one thread per
connection, and why Chapter 12's virtual threads changed the economics so
dramatically: a blocked virtual thread costs almost nothing, so the simple
blocking style now scales to where only non-blocking code could previously go.
Finally, a small convenience. Network protocols specify big-endian byte
order, often called network byte order, and Java is big-endian on every
platform. DataOutputStream.writeInt therefore already emits the bytes the wire
expects, with no swapping, which is one fewer thing to get wrong than in C.
Key Takeaways
- The network is unreliable, slow, insecure, and changeable, and Java does not hide any of it.
- Java's types map layer by layer onto the TCP/IP stack the operating system implements.
- An IP address selects a machine, a port selects a process on it, and the two together form a socket endpoint.
- A TCP connection is identified by a four-tuple, which is how one listening port serves thousands of clients.
- Port
0asks the operating system for a free ephemeral port, which is ideal in tests.- TCP is reliable and ordered but is a byte stream with no message boundaries.
- UDP preserves message boundaries but offers no delivery, ordering, or duplication guarantees.
- Socket operations block the calling thread, which virtual threads make cheap again.
- Java is big-endian, so its primitives are already in network byte order.
🧪 Practice
- For each of these, choose TCP or UDP and justify it in one sentence: a chat application, a DNS lookup, a live video call, a bank transfer, a metrics agent reporting every second.
- Explain how a web server on port 443 keeps two connections from the same browser apart. Write out both four-tuples.
- Write a program that binds a
ServerSocketto port0and prints the port the operating system assigned. Explain when this is preferable to a hardcoded port.- Interview: A colleague reports that their TCP client sometimes receives two messages joined together and sometimes half a message. Is this a bug in TCP, and what must change? (Hint: what exactly does TCP promise to deliver in order?)
InetAddress and Host Resolution
Routers forward packets using numeric IP addresses. People and configuration
files use names like api.example.com. The Domain Name System is the
distributed directory that translates between them, and InetAddress is Java's
representation of the result.
The analogy is a phone book, with one important wrinkle: the lookup itself travels over the network, so it can be slow, can fail, and its answer can change between one call and the next.
InetAddress has no public constructor. Every way of obtaining one is a static
factory, because most of them may trigger a DNS query, and a constructor that
silently performs network I/O would be a poor design.
| Factory | Performs a DNS lookup? | Returns |
|---|---|---|
InetAddress.getByName("host") | Yes | The first address for the name |
InetAddress.getAllByName("host") | Yes | Every address, for load balancing |
InetAddress.getByName("93.184.216.34") | No | Parses the literal directly |
InetAddress.getByAddress(byte[]) | No | An address with no reverse lookup |
InetAddress.getLocalHost() | Usually | This machine's primary address |
InetAddress.getLoopbackAddress() | No | 127.0.0.1 or ::1 |
InetAddress one = InetAddress.getByName("example.com"); // may throw UnknownHostException
System.out.println(one.getHostAddress()); // 93.184.216.34, no lookup
System.out.println(one.getHostName()); // cached from the forward lookup
// A busy service usually resolves to several addresses
for (InetAddress addr : InetAddress.getAllByName("example.com")) {
System.out.println(addr); // hostname/ip, as toString() formats it
}
InetAddress local = InetAddress.getLoopbackAddress(); // never touches DNS
The failure mode is a checked UnknownHostException, which is an IOException.
Treat it as an ordinary network failure rather than a programming error: the
name may be fine and the DNS server merely unreachable.
Two methods that look symmetric are not. getHostAddress formats the
numeric address and costs nothing. getHostName may perform a reverse lookup,
which is a second network round trip and often fails or returns something
unhelpful. Worse, reverse DNS records are controlled by whoever owns the address
block, so a hostname obtained this way is a claim, not a proof.
// In a server, this line can add hundreds of milliseconds per connection
String who = socket.getInetAddress().getHostName(); // reverse DNS, avoid in hot paths
// This is free, and is what belongs in a log line
String ip = socket.getInetAddress().getHostAddress();
Never make an authorisation decision based on a reverse-resolved hostname.
Address families. Inet4Address and Inet6Address are the two concrete
subclasses. IPv6 addresses are 128 bits, written in hexadecimal groups, and may
carry a scope identifier for link-local use.
IPv4 93.184.216.34 32 bits, four octets
IPv6 2606:2800:220:1:248:1893:25c8:1946 128 bits, eight groups
IPv6 ::1 loopback, shortened form
Dual 0:0:0:0:0:ffff:5db8:d822 an IPv4 address mapped into IPv6
Classification methods answer questions about an address without any network
traffic: isLoopbackAddress, isSiteLocalAddress, isMulticastAddress,
isAnyLocalAddress.
Pairing an address with a port gives InetSocketAddress, which is what the
socket APIs actually consume.
InetSocketAddress resolved = new InetSocketAddress("example.com", 443); // resolves now
InetSocketAddress lazy = InetSocketAddress.createUnresolved("example.com", 443);
InetSocketAddress any = new InetSocketAddress(8080); // wildcard: bind all interfaces
resolved.isUnresolved(); // false; the constructor already did the DNS work
The unresolved form matters when a proxy will do the resolving, or when you want to defer a lookup that might fail.
DNS caching is a JVM-level setting with operational consequences. The JVM caches successful lookups, and historically cached them forever when a security manager was installed. Two properties control it.
| Property | Controls | Sensible value |
|---|---|---|
networkaddress.cache.ttl | Seconds to cache a success | 30 to 60 in cloud environments |
networkaddress.cache.negative.ttl | Seconds to cache a failure | 0, so outages recover quickly |
In an environment where a service's address changes, a long cache means your process keeps dialling a machine that no longer exists. This is a common cause of "everything recovered except one instance".
Reachability testing is best-effort. isReachable tries ICMP when the
process has permission and otherwise attempts TCP port 7, both of which
firewalls routinely block. A false result means "no answer", not "host down".
boolean maybe = InetAddress.getByName("example.com").isReachable(2000); // 2s timeout
For the local side, NetworkInterface enumerates the machine's own adapters,
which is how a program discovers which addresses it can bind to.
for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) {
if (nic.isUp() && !nic.isLoopback()) {
nic.getInterfaceAddresses()
.forEach(a -> System.out.println(nic.getName() + " -> " + a.getAddress()));
}
}
Key Takeaways
- DNS turns names into addresses over the network, so resolution is slow, fallible, and time-varying.
InetAddressis created only through static factories, because most of them perform I/O.getAllByNamereveals every address behind a name, which is how simple load balancing is exposed.getByAddressbuilds an address with no lookup at all, forward or reverse.getHostAddressis free;getHostNamemay trigger a reverse lookup that is slow and untrustworthy.- Never authorise anything based on a reverse-resolved hostname.
UnknownHostExceptionis anIOExceptionand usually signals a network problem, not a bad name.InetSocketAddresspairs an address with a port and can be deliberately left unresolved.- JVM DNS caching is controlled by two TTL properties, and a long positive TTL breaks failover.
isReachablereturningfalsemeans no reply, not necessarily a dead host.
🧪 Practice
- Print every address
InetAddress.getAllByNamereturns for a large site, and run it again after a minute. Note any difference and explain it.- Time
getHostAddressagainstgetHostNamefor an address built withgetByAddress, and explain the gap.- Write a utility that takes a hostname and prints, for each resolved address, whether it is IPv4 or IPv6, loopback, and site-local.
- List the machine's non-loopback interfaces and their addresses using
NetworkInterface, and identify which one a server would bind by default.- Interview: A service behind a load balancer fails for one JVM long after the balancer moved to new machines, while a restart fixes it. What would you investigate first? (Hint: what does the JVM remember about a name, and for how long?)
<a id="142-socket-programming"></a>
14.2 Socket Programming
This subchapter covers the two transport APIs directly: the connection-oriented
Socket pair, the architectures for serving many clients at once, and the
connectionless DatagramSocket.
TCP Communication with Socket and ServerSocket
A TCP connection is a two-way byte pipe between two programs. Java splits the roles across two classes, and understanding the split removes most of the confusion beginners have.
ServerSocketis not a connection. It is a receptionist: it binds to a port, listens, and hands you a connection each time one arrives.Socketis one end of an actual connection. Both sides hold one, and the two are symmetric once established.
The critical consequence is that accept() returns a new Socket for every
client. The ServerSocket itself never carries data.
SERVER CLIENT
------ ------
new ServerSocket(8080) bind + listen
|
accept() ---- blocks here ---->
| new Socket("host", 8080)
| <---- TCP handshake ----> |
returns a NEW Socket <==== connection ====> that Socket
| |
getInputStream() <-------- bytes --------> getOutputStream()
getOutputStream() --------- bytes --------> getInputStream()
| |
close() close()
The ServerSocket stays open and loops back to accept() for the next client.
Once you hold a Socket, networking becomes Chapter 13. getInputStream and
getOutputStream return ordinary java.io streams, so every decorator from
13.1 applies unchanged: buffer them, bridge to characters with an explicit
charset, and close them with try-with-resources.
Here is a complete client.
try (Socket socket = new Socket("localhost", 8080);
BufferedReader in = new BufferedReader(new InputStreamReader(
socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(new OutputStreamWriter(
socket.getOutputStream(), StandardCharsets.UTF_8), true)) { // autoflush
socket.setSoTimeout(5000); // never wait forever for a reply
out.println("PING"); // autoflush sends it; without it, deadlock
String reply = in.readLine(); // null if the peer closed the connection
System.out.println("server said: " + reply);
}
And the matching single-connection server.
try (ServerSocket server = new ServerSocket(8080)) {
System.out.println("listening on " + server.getLocalPort());
try (Socket client = server.accept(); // blocks until someone connects
BufferedReader in = new BufferedReader(new InputStreamReader(
client.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {
String line;
while ((line = in.readLine()) != null) { // null means orderly disconnect
out.println("PONG: " + line);
}
}
}
Four details cause most first-attempt failures.
Forgetting to flush deadlocks both sides. A buffered writer holds your request
in memory while you block reading a response the server has not been asked for.
Nothing is broken and nothing times out; both processes simply wait. Either
enable autoflush, as above, or call flush() explicitly after each message.
A read returning -1, or readLine returning null, is not an error. It
means the peer closed its end and no more data will ever arrive. Treat it as
end-of-input and stop the loop.
Closing anything closes everything. Closing the socket closes both streams, and closing either stream closes the socket. Listing all three in a try-with-resources is harmless, but you cannot close one stream and keep using the other.
Without a timeout, a read can block forever. If the peer's machine loses power it never sends a FIN, so your thread waits indefinitely on a connection that will never produce another byte. This is the single most common production networking bug.
Socket socket = new Socket();
socket.connect(new InetSocketAddress("example.com", 80), 3000); // connect timeout
socket.setSoTimeout(5000); // read timeout, throws SocketTimeoutException
The two timeouts are separate, and the constructor form new Socket(host, port)
sets neither, so it can hang during connection establishment as well.
Socket options worth knowing.
| Option | Method | Why it matters |
|---|---|---|
| Read timeout | setSoTimeout(ms) | Turns an infinite hang into an exception |
| Connect timeout | connect(addr, ms) | Bounds establishment, not just reads |
TCP_NODELAY | setTcpNoDelay(true) | Disables Nagle's algorithm; essential for small request-response messages |
SO_KEEPALIVE | setKeepAlive(true) | Detects dead peers on idle connections, slowly |
SO_REUSEADDR | setReuseAddress(true) | Lets a server rebind while old connections sit in TIME_WAIT |
SO_LINGER | setSoLinger(...) | Controls whether close waits for unsent data |
| Receive buffer | setReceiveBufferSize(n) | Throughput on high-latency links |
Nagle's algorithm deserves a sentence of explanation, because its effect looks
like a mysterious delay. To avoid flooding the network with tiny packets, TCP
may hold a small write briefly, hoping to combine it with the next one.
Combined with delayed acknowledgements on the other side, this can add a visible
pause to a chatty protocol. Request-response protocols usually set
TCP_NODELAY.
Half-close is occasionally essential. shutdownOutput() sends a FIN,
telling the peer "I have finished sending", while leaving you able to read their
reply. Protocols that stream a request and then read until end-of-stream depend
on it.
out.flush();
socket.shutdownOutput(); // peer now sees end-of-stream on its reader
String response = readAll(in); // we can still read
Encryption is a factory swap. SSLSocketFactory produces a Socket
subclass that performs a TLS handshake transparently, so the surrounding code is
unchanged.
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
try (SSLSocket socket = (SSLSocket) factory.createSocket("example.com", 443)) {
socket.startHandshake(); // certificate validation happens here
// identical stream code from this point on
}
Key Takeaways
ServerSocketlistens and accepts; it never transfers data itself.- Each
accept()returns a distinctSocketfor one client, while the listener continues.- Socket streams are ordinary
java.iostreams, so Chapter 13's decorators apply directly.- Unflushed buffered output deadlocks both peers silently.
readreturning-1orreadLinereturningnullsignals an orderly close, not a failure.- Closing the socket or either stream closes all three.
- Connect timeout and read timeout are separate, and the simple constructor sets neither.
TCP_NODELAYremoves Nagle-induced latency in request-response protocols.shutdownOutputhalf-closes, signalling end-of-request while keeping the reply readable.- TLS is obtained by swapping in
SSLSocketFactory, leaving stream code untouched.
🧪 Practice
- Build the echo client and server above, run them, and confirm the exchange. Then remove the autoflush and describe exactly what happens and why.
- Start the server, connect a client, and kill the server process. Observe what the client's
readLinedoes with and withoutsetSoTimeout.- Write a client that sends a request, half-closes with
shutdownOutput, and reads the response until end-of-stream. Explain why the server can detect the end of the request without a length prefix.- Convert the client to TLS against a public HTTPS host and print the peer's certificate subject.
- Interview: A server sends a short reply and both sides then hang. The network is fine and neither process has crashed. Name two distinct causes and how you would tell them apart. (Hint: one is in a buffer, and the other is a missing option.)
Building a Multi-Client Server
The server above has a fatal limitation: it handles one client, then exits. Even
looping around accept() only moves the problem, because a single thread that
is busy reading from one client cannot accept another.
SINGLE-THREADED LOOP
accept A --> serve A (10 seconds) --> accept B --> serve B ...
^ B, C and D wait in the kernel's backlog queue,
and are refused once it fills
The fix is to separate accepting from serving. The accept loop must do nothing but hand each new socket to something else. Four architectures do that, and they differ in what they cost per connection.
| Architecture | Cost per connection | Ceiling | Complexity |
|---|---|---|---|
| Thread per connection (platform) | ~1 MB of stack | Thousands | Low |
| Fixed thread pool | Shared threads | Pool size, not connections | Low |
| Thread per connection (virtual) | A few hundred bytes | Millions | Low |
| Non-blocking with a selector | One buffer | Millions | High (see 14.4) |
The fixed pool has a trap worth stating plainly: with a pool of 200 threads, the 201st client is not slow, it is not served at all until someone finishes. A handful of slow or malicious clients holding connections open exhausts the pool entirely. This is why the classic advice was to use a pool anyway and accept the limit, and why virtual threads changed the recommendation.
Since Java 21, the default choice is a virtual thread per connection: it keeps the simple blocking code of the previous topic while removing the memory ceiling.
try (ServerSocket server = new ServerSocket(8080, 200); // 200 = backlog
ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor()) {
while (!Thread.currentThread().isInterrupted()) {
Socket client = server.accept(); // the ONLY thing this loop does
workers.submit(() -> handle(client)); // cheap: a new virtual thread
}
} catch (SocketException e) {
// server.close() from another thread lands here: this is the shutdown signal
}
ExecutorService implements AutoCloseable from Java 19, so the
try-with-resources block waits for in-flight handlers on exit.
The handler owns the socket's whole lifetime, including closing it.
private static void handle(Socket client) {
try (client; // Java 9+: an existing resource
BufferedReader in = new BufferedReader(new InputStreamReader(
client.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {
client.setSoTimeout(30_000); // do not let one client hang a thread
String line;
while ((line = in.readLine()) != null) {
out.println(process(line));
}
} catch (IOException e) {
log.warn("client {} failed: {}", client.getInetAddress(), e.getMessage());
} // exception NEVER reaches the accept loop
}
That last point is structural. If a handler's exception propagates into the accept loop, one malformed client takes down the entire server. Catching inside the handler is not defensive clutter; it is the boundary that keeps one connection's failure local.
Shared state across connections. A server that only echoes needs none, but anything collaborative does. Chapter 12's concurrent collections are the right tools, because handlers run on different threads simultaneously.
// Every connected client's writer, safe for concurrent iteration and mutation
private static final Set<PrintWriter> clients = ConcurrentHashMap.newKeySet();
private static void broadcast(String message, PrintWriter sender) {
for (PrintWriter w : clients) { // safe: no ConcurrentModificationException
if (w != sender) {
w.println(message); // a slow client here slows this loop
}
}
}
The comment on the last line hints at a real design issue. Writing directly to every client from one handler means the slowest client throttles everyone. A production chat server gives each connection an outbound queue and a dedicated writer, so a stalled socket cannot block the broadcast.
Operational checklist for an accept loop.
| Concern | What to do |
|---|---|
| Backlog | Pass a second argument to ServerSocket; the default is 50 |
| Hung clients | Set setSoTimeout on every accepted socket |
| Resource leaks | Close the client socket in the handler, with try-with-resources |
| Handler failures | Catch everything inside the handler |
| Overload | Bound concurrent connections with a semaphore, and reject politely |
| Shutdown | Close the ServerSocket; the blocked accept throws SocketException |
Graceful shutdown is worth spelling out, because accept() cannot be
interrupted directly. Closing the ServerSocket from another thread is the
supported way to break the loop, and the resulting SocketException is the
signal rather than a fault.
Key Takeaways
- The accept loop must do nothing except accept and dispatch.
- A single-threaded server queues other clients in the kernel backlog and then refuses them.
- A fixed pool caps concurrent connections, not just concurrent work, so slow clients can starve it entirely.
- A virtual thread per connection keeps blocking code simple and removes the memory ceiling.
ExecutorServiceisAutoCloseablesince Java 19, so try-with-resources awaits in-flight handlers.- Each handler owns its socket's lifetime and must close it.
- A handler must never let an exception reach the accept loop.
- Shared state across connections belongs in concurrent collections from Chapter 12.
- Broadcasting directly to every client lets the slowest one throttle all of them; per-connection queues fix that.
- Closing the
ServerSocketis how you unblockacceptfor shutdown.
🧪 Practice
- Extend the echo server to serve many clients with a virtual thread executor, then connect three clients at once and confirm they are independent.
- Replace the executor with
newFixedThreadPool(2), connect three clients that each sleep for thirty seconds, and describe what the third one experiences.- Build a chat server that broadcasts each line to all other connections. Handle disconnection by removing the writer from the shared set.
- Add graceful shutdown: a thread that closes the
ServerSocketafter ten seconds, with the accept loop exiting cleanly and in-flight handlers finishing.- Add a
Semaphorethat caps concurrent connections at five and sendsBUSYto anyone beyond that, then verify the behaviour with six clients.- Interview: Before virtual threads, why did servers use a thread pool rather than a thread per connection, and what exactly changed? (Hint: compare what a blocked thread costs in each model.)
UDP Communication with DatagramSocket
TCP's guarantees are not free. Establishing a connection costs a round trip before any data moves, and retransmitting a lost packet means everything behind it waits. For some workloads that trade is wrong.
Consider a live voice call. A packet of audio that was lost 300 milliseconds ago is worthless even if TCP eventually delivers it, and waiting for it stalls the audio that did arrive. Consider a DNS query: one small question, one small answer, where a handshake would triple the cost. Both want UDP.
If TCP is a phone call, UDP is a stack of postcards. Each carries its own address, each is independent, any may be lost, and they may arrive in a different order than they were sent. Nobody confirms delivery.
Java models this with two classes whose roles are easy to mix up.
DatagramSocketis the mailbox: it binds to a local port and can send and receive.DatagramPacketis the envelope: it holds the payload bytes and the address, either the destination when sending or the sender when receiving.
SENDING RECEIVING
packet = payload + destination packet = an EMPTY buffer
| |
socket.send(packet) ~~~~~~~~~~~> socket.receive(packet) blocks
(may be lost) |
packet now holds the data,
plus who sent it and from which port
A sender:
try (DatagramSocket socket = new DatagramSocket()) { // any free local port
byte[] payload = "PING".getBytes(StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(
payload, payload.length,
InetAddress.getByName("localhost"), 9999); // destination on the envelope
socket.send(packet); // returns immediately; no confirmation of anything
}
A receiver:
try (DatagramSocket socket = new DatagramSocket(9999)) { // bind to a known port
byte[] buffer = new byte[1500]; // must fit the largest datagram
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
while (true) {
packet.setLength(buffer.length); // RESET before every receive, see below
socket.receive(packet); // blocks until a datagram arrives
String text = new String(packet.getData(), packet.getOffset(),
packet.getLength(), StandardCharsets.UTF_8);
System.out.println(text + " from " + packet.getSocketAddress());
}
}
Three traps are specific to datagrams.
An undersized buffer silently truncates. If a 900-byte datagram arrives into a 512-byte buffer, you get 512 bytes and the rest is discarded with no error. Size the buffer for the largest message your protocol allows.
A reused packet shrinks. receive sets the packet's length to the bytes
actually received. On the next call, that smaller length becomes the capacity,
so a long message is truncated by a previous short one. Calling setLength
before each receive is not optional.
Payload size affects reliability. The theoretical maximum is 65507 bytes, but anything above the path MTU is fragmented at the IP layer, and losing any one fragment destroys the entire datagram. Keeping payloads at or under about 1472 bytes on Ethernet avoids fragmentation altogether.
Ethernet MTU 1500 bytes
minus IPv4 header -20
minus UDP header -8
------------------------------
safe UDP payload 1472 bytes (use ~1200 to survive tunnels and IPv6)
connect on a DatagramSocket does not connect. It records a default peer,
so you can call send without an address and the kernel filters out datagrams
from anyone else. No handshake happens and no reliability is added.
Rebuilding reliability is your job, and usually a warning sign. If a protocol needs acknowledgements, retransmission timers, sequence numbers, and duplicate detection, it is re-implementing TCP, generally worse. UDP is the right choice when you genuinely do not need those, or when you need only one of them and can do it more cheaply for your specific case.
Multicast is UDP's other distinctive capability: one send, many receivers, with the network doing the duplication.
try (MulticastSocket socket = new MulticastSocket(4446)) {
InetSocketAddress group = new InetSocketAddress(InetAddress.getByName("230.0.0.1"), 4446);
NetworkInterface nic = NetworkInterface.getByName("eth0");
socket.joinGroup(group, nic); // now receives everything sent to the group
// ... receive as usual ...
socket.leaveGroup(group, nic);
}
Side by side, the two APIs make the differences concrete.
| Task | TCP | UDP |
|---|---|---|
| Create an endpoint | new Socket(host, port) | new DatagramSocket() |
| Accept clients | serverSocket.accept() | Not applicable; one socket serves all |
| Send | Write to an OutputStream | send(DatagramPacket) |
| Receive | Read from an InputStream | receive(DatagramPacket) |
| Know the peer | Implicit in the connection | Read it off each received packet |
| Message boundaries | You must frame them yourself | Preserved automatically |
| Delivery | Guaranteed or the connection fails | No guarantee, ever |
Key Takeaways
- UDP trades delivery, ordering, and duplicate suppression for low latency and no setup cost.
DatagramSocketis the mailbox andDatagramPacketis the envelope carrying both payload and address.- A received packet tells you who sent it, so one socket serves every peer.
- Datagram boundaries are preserved exactly, unlike TCP's byte stream.
- An undersized receive buffer truncates silently, with no exception.
setLengthmust be reset before everyreceiveon a reused packet.- Keep payloads under the path MTU, since losing one fragment loses the whole datagram.
connecton a datagram socket only sets a default peer and filters senders.- Rebuilding acknowledgements and retransmission usually means TCP was the right answer.
- Multicast lets one send reach a whole group, with the network duplicating.
🧪 Practice
- Write a UDP ping client and server, then run the client with no server listening. Explain why
sendstill succeeds.- Send a 2000-byte datagram into a 512-byte receive buffer and print what arrives. Then fix it and explain the failure mode.
- Reuse one
DatagramPacketacross several receives without callingsetLength, send a long message after a short one, and explain the result.- Add sequence numbers to your UDP client and detect loss and reordering across a thousand datagrams sent in a tight loop.
- Interview: A team built a file transfer over UDP and added acknowledgements and retransmission to make it reliable. What would you tell them, and when would you be wrong? (Hint: what are they now maintaining, and what does a real-time deadline change?)
<a id="143-working-with-http"></a>
14.3 Working with HTTP
This subchapter covers the protocol almost every service actually speaks: how Java models addresses for it, the legacy API you will still meet in old code, and the modern client you should write against today.
URL, URI, and Legacy HttpURLConnection
Raw sockets carry bytes. HTTP adds the conventions that let unrelated programs agree on what those bytes mean: a method, a path, headers, a status code, and a body. Java has offered three generations of HTTP support, and the first two left two classes you must be able to read even though you should rarely write them.
URI and URL are not the same thing, and the difference has teeth.
A URI identifies a resource. It is pure syntax, in the same way that
Chapter 13's Path is pure syntax: it parses, splits, and compares text without
touching the network. A URL is a URI that also describes how to locate the
resource, and java.net.URL can therefore open a connection to it.
| Aspect | java.net.URI | java.net.URL |
|---|---|---|
| Purpose | Parse, compose, compare | Locate and open a connection |
| Network access | Never | openConnection(), openStream() |
| Validates syntax | Strictly, at construction | Loosely |
equals | Pure string comparison | Performs DNS resolution and blocks |
| Relative references | resolve, relativize | Limited |
That equals row is a genuine hazard. Comparing two URL objects, or putting
them in a HashSet or HashMap, can trigger a blocking DNS lookup, and two
different names resolving to one address compare equal. The rule is simple: use
URI for everything, and convert to URL only at the moment you open a
connection.
URI uri = URI.create("https://api.example.com:8443/v1/orders?status=open#top");
URL url = uri.toURL(); // only when you actually need to fetch it
The anatomy is worth memorising, because every component has a getter.
https://ada@api.example.com:8443/v1/orders?status=open&limit=10#top
\___/ \_/ \_____________/ \__/ \_______/ \__________________/ \_/
| | | | | | |
scheme userInfo host port path query fragment
\_______________________________/
authority
URI u = URI.create("https://ada@api.example.com:8443/v1/orders?status=open#top");
u.getScheme(); // https
u.getHost(); // api.example.com
u.getPort(); // 8443, or -1 when absent
u.getPath(); // /v1/orders
u.getQuery(); // status=open (decoded; getRawQuery() keeps the escapes)
u.getFragment(); // top
Like Path, a URI can combine and subtract references.
URI base = URI.create("https://api.example.com/v1/");
URI full = base.resolve("orders/42"); // https://api.example.com/v1/orders/42
URI back = base.relativize(full); // orders/42
URI tidy = URI.create("https://h/a/../b").normalize(); // https://h/b
Encoding is where people get hurt. URLEncoder implements
application/x-www-form-urlencoded, which is the format for HTML form bodies
and query strings. It encodes a space as +, which is correct there and
wrong in a path segment, where a space must be %20.
// Correct: building a query string
String q = "status=" + URLEncoder.encode("in progress", StandardCharsets.UTF_8);
// status=in+progress
// WRONG: URLEncoder on a path segment produces a '+' that means a literal plus
// Correct: let the multi-argument URI constructor escape each component
URI safe = new URI("https", "api.example.com", "/v1/orders/in progress", null, null);
// https://api.example.com/v1/orders/in%20progress
HttpURLConnection is the legacy client. It works, it ships with every JDK
including 8, and its design is a product of 1997.
URL url = URI.create("https://api.example.com/v1/orders").toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
try {
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setConnectTimeout(3000);
conn.setReadTimeout(5000);
conn.setDoOutput(true); // required before writing a body
try (OutputStream out = conn.getOutputStream()) {
out.write(body.getBytes(StandardCharsets.UTF_8));
}
int status = conn.getResponseCode(); // this is when the request is actually sent
// Success and failure come from DIFFERENT streams:
InputStream source = status < 400 ? conn.getInputStream() : conn.getErrorStream();
try (BufferedReader in = new BufferedReader(
new InputStreamReader(source, StandardCharsets.UTF_8))) {
// read the body fully, or the connection cannot be reused
}
} finally {
conn.disconnect();
}
The awkwardness is not accidental; it follows from the API being one mutable object that is simultaneously the configuration, the request, and the response.
| Problem | How HttpClient addresses it |
|---|---|
| One mutable object for request and response | Immutable HttpRequest and HttpResponse |
getInputStream throws on status 400 and up | The body is always on the response |
Order-dependent setters like setDoOutput | A builder that validates on build() |
| HTTP/1.1 only | HTTP/2 by default, with fallback |
| No asynchronous support | sendAsync returning a CompletableFuture |
| Not thread safe, awkward to reuse | One thread-safe, reusable client |
| Manual body reading to keep connections alive | Handled by the client |
Write HttpURLConnection only when you are stuck on Java 8 and cannot add a
dependency. Everywhere else, the next topic is the answer.
Key Takeaways
- A
URIidentifies and parses; aURLlocates and can open a connection.URL.equalsperforms a blocking DNS lookup, so never putURLobjects in hash-based collections.- Use
URIthroughout and convert withtoURL()only at the point of fetching.URIsupportsresolve,relativize, andnormalize, mirroringPath.URLEncoderis for form and query encoding, and its+for space is wrong in a path segment.- The multi-argument
URIconstructor escapes each component correctly.HttpURLConnectionmixes configuration, request, and response into one mutable object.- It serves error bodies from
getErrorStream, notgetInputStream.- The response body must be fully read for the connection to be reused.
🧪 Practice
- Parse a URL with a port, query, and fragment, and print every component from both
getQueryandgetRawQueryfor a query containing a space.- Build
https://api.example.com/v1/search?q=hello world&lang=encorrectly. Do it once withURLEncoderand once with the multi-argumentURIconstructor, and explain which belongs where.- Use
HttpURLConnectionto fetch a URL that returns 404 and print the response body. Explain why the obvious version throws.- Interview: Why is it dangerous to use
java.net.URLas aHashMapkey? (Hint: what does computing its hash code have to do with the network?)
The Modern HttpClient API
Java 11 introduced java.net.http, a client designed around three immutable
pieces with one responsibility each.
| Type | Role | Lifetime |
|---|---|---|
HttpClient | Connection pool, configuration, execution | Long-lived, shared, thread-safe |
HttpRequest | One request: method, URI, headers, body | Immutable, cheap, per call |
HttpResponse<T> | Status, headers, and a typed body | Returned per call |
BodyHandler<T> | Decides how the body becomes a T | Chosen per call |
The division mirrors a postal system. The client is the postal service you set up once, the request is an individual letter, and the body handler is your standing instruction for what to do with the reply: read it aloud, file it on disk, or hand it over a page at a time.
Create one client and reuse it. It owns the connection pool, so building one per request throws away connection reuse and HTTP/2 multiplexing.
private static final HttpClient CLIENT = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // negotiates, falls back to 1.1
.connectTimeout(Duration.ofSeconds(5)) // connection setup only
.followRedirects(HttpClient.Redirect.NORMAL) // follow, but not HTTPS -> HTTP
.build(); // thread-safe and immutable
A GET is then three lines.
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.example.com/v1/orders"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10)) // whole-request timeout, distinct from connect
.GET()
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
response.statusCode(); // 200
response.body(); // the body, ALWAYS present, whatever the status
response.headers().firstValue("Content-Type").orElse("unknown");
Note the contrast with the legacy API: a 500 response is a perfectly normal
return value here, not an exception. Exceptions are reserved for genuine
transport failures such as ConnectException or HttpTimeoutException.
A POST differs only in the body publisher.
HttpRequest post = HttpRequest.newBuilder(URI.create("https://api.example.com/v1/orders"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
Publishers and handlers are the extension points. They keep the client itself free of any opinion about content.
Sending: BodyPublishers | Receiving: BodyHandlers |
|---|---|
ofString(s) | ofString() |
ofByteArray(b) | ofByteArray() |
ofFile(path) | ofFile(path) writes straight to disk |
ofInputStream(supplier) | ofInputStream() streams lazily |
noBody() | ofLines() gives a Stream<String> |
discarding() ignores the body |
ofFile on both sides matters for large payloads: it never materialises the
content in the heap, which is the difference between streaming a 2 GB download
and an OutOfMemoryError.
Asynchronous requests return immediately with a CompletableFuture, the
type from Chapter 12.
CompletableFuture<String> future = CLIENT
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body) // runs when the response arrives
.exceptionally(e -> "failed: " + e.getMessage());
// Fire many in parallel and wait for all of them
List<CompletableFuture<HttpResponse<String>>> calls = urls.stream()
.map(u -> CLIENT.sendAsync(get(u), HttpResponse.BodyHandlers.ofString()))
.toList();
CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();
With virtual threads available, the blocking send inside a virtual thread is
often simpler than the asynchronous chain and performs comparably, so reach for
sendAsync when you genuinely want composition rather than merely concurrency.
Timeouts come in two kinds and you want both. connectTimeout on the client
bounds establishing the connection. timeout on the request bounds the entire
exchange. Neither is set by default, so a request with no timeouts can hang for
as long as the peer keeps the socket open.
Authentication and headers.
String basic = Base64.getEncoder()
.encodeToString(("user:pass").getBytes(StandardCharsets.UTF_8));
HttpRequest authed = HttpRequest.newBuilder(uri)
.header("Authorization", "Basic " + basic) // or "Bearer " + token
.build();
A client-level authenticator(...) also exists, but it responds only to a 401
challenge, so an API expecting a bearer token on the first request needs the
header set explicitly.
HTTP/2 is the default and is mostly invisible, with one visible benefit: several concurrent requests to the same host share one TCP connection through multiplexing, instead of opening one connection each. The client negotiates and silently falls back to HTTP/1.1 against older servers.
HTTP/1.1 HTTP/2
conn 1: [req A ...][resp A] conn 1: [A][B][C] interleaved on one connection
conn 2: [req B ...][resp B] [resp B][resp A][resp C]
conn 3: [req C ...][resp C]
three connections, head-of-line one connection, concurrent streams
A realistic call with error handling ties it together.
try {
HttpResponse<String> r = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (r.statusCode() == 200) {
return parse(r.body());
} else if (r.statusCode() == 429) {
Duration wait = r.headers().firstValue("Retry-After")
.map(s -> Duration.ofSeconds(Long.parseLong(s)))
.orElse(Duration.ofSeconds(1));
return retryAfter(wait);
} else {
throw new ApiException(r.statusCode(), r.body()); // body is available here
}
} catch (HttpTimeoutException e) {
throw new ApiException("timed out", e); // transport failure
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag, Chapter 12
throw new ApiException("request failed", e);
}
send declares InterruptedException, so the interrupt flag must be restored
in the pattern Chapter 12 established.
Key Takeaways
HttpClient,HttpRequest, andHttpResponseare immutable and single-purpose, unlikeHttpURLConnection.- Build one client and share it, because it owns the connection pool and is thread-safe.
- Any status code is a normal return; only transport failures throw.
- The response body is always available, including for error statuses.
BodyPublishersandBodyHandlersdecide how bodies are produced and consumed, with file variants that avoid the heap.sendAsyncreturns aCompletableFuture, while a blockingsendon a virtual thread is often simpler.- Connect timeout and request timeout are separate, and neither is set by default.
- A client-level authenticator answers 401 challenges but does not pre-emptively send a token.
- HTTP/2 multiplexes concurrent requests over one connection and falls back automatically.
senddeclaresInterruptedException, so restore the interrupt flag when catching it.
🧪 Practice
- Fetch a public JSON endpoint and print the status, the
Content-Typeheader, and the first hundred characters of the body.- Request a URL that returns 404 and print the body. Compare the code with the
HttpURLConnectionversion from the previous topic.- Download a large file with
BodyHandlers.ofFileand explain whyofStringwould be the wrong choice.- Issue twenty requests concurrently, once with
sendAsyncandallOf, and once with blockingsendcalls on a virtual thread executor. Compare the total time and the amount of code.- Write a wrapper that retries on status 429 and 503, honouring
Retry-Afterand giving up after three attempts.- Interview: Why does
HttpClientreturn a 500 response normally instead of throwing, whenHttpURLConnectionthrows fromgetInputStream? (Hint: is a server's error message a failure of the HTTP exchange?)
<a id="144-non-blocking-io-and-protocol-design"></a>
14.4 Non-Blocking IO and Protocol Design
This subchapter covers the machinery that served enormous connection counts before virtual threads existed, and the decisions that turn a byte pipe into a protocol two programs can actually agree on.
Non-Blocking Networking with Channels and Selectors
Thread per connection has a hard arithmetic limit. Ten thousand connections
means ten thousand platform threads, and at roughly a megabyte of stack each
that is ten gigabytes of memory for threads that are, at any instant, almost all
idle. This was known as the C10k problem, and it drove the design of
java.nio.
The insight is that a blocked thread is pure waste. If ninety-nine percent of connections have no data right now, you do not need a thread parked on each one. You need a way to ask the operating system a single question: which of these ten thousand sockets can be read without blocking?
BLOCKING, THREAD PER CONNECTION NON-BLOCKING, ONE THREAD
thread 1 -> conn 1 (blocked) +-----------+
thread 2 -> conn 2 (blocked) | Selector | <- "who is ready?"
thread 3 -> conn 3 (has data!) +-----------+
... | conn 3, conn 977 are ready
thread N -> conn N (blocked) v
one thread services exactly those
N stacks of memory, mostly idle one stack, one buffer per connection
Three types implement this.
| Type | Role | Analogy |
|---|---|---|
Buffer | Fixed-size block of memory; the unit of transfer | A bucket you fill and empty |
Channel | Bidirectional conduit to a socket or file; can be non-blocking | The tap and the drain |
Selector | Watches many channels and reports which are ready | A switchboard operator |
ByteBuffer is the part that confuses everyone, because one object is used
for both filling and draining, and a single pointer tracks both. Three numbers
define its state: position (where the next operation happens), limit (where
operations must stop), and capacity (fixed at allocation).
allocate(8) pos=0 limit=8 cap=8
[ _ _ _ _ _ _ _ _ ]
^pos ^limit
put("Hi") pos=2 limit=8 writing moves position
[ H i _ _ _ _ _ _ ]
^pos ^limit
flip() pos=0 limit=2 "stop writing, start reading"
[ H i | _ _ _ _ _ ]
^pos ^limit
get() twice pos=2 limit=2 drained
clear() pos=0 limit=8 ready to write again (data ignored)
compact() unread bytes moved to the front, position after them
The cycle is always the same: fill, flip, drain, clear. Forgetting flip
after writing is the classic NIO bug, and it presents as reading zero bytes from
a buffer you just filled.
ByteBuffer buffer = ByteBuffer.allocate(1024); // heap buffer
channel.read(buffer); // FILL: position advances
buffer.flip(); // limit = position, position = 0
while (buffer.hasRemaining()) {
process(buffer.get()); // DRAIN
}
buffer.clear(); // ready for the next read
allocateDirect places the buffer outside the heap so the operating system can
use it without an intermediate copy. It is faster for large, long-lived buffers
and slower to allocate, so it suits pooled buffers rather than per-message ones.
A Selector multiplexes. Channels register with it, declaring which
operations they care about, and select() blocks until at least one is ready.
| Interest op | Meaning |
|---|---|
OP_ACCEPT | A server channel has a pending connection |
OP_CONNECT | A client channel finished connecting |
OP_READ | Data is available to read without blocking |
OP_WRITE | The socket can accept more outgoing data |
Here is a complete single-threaded echo server.
try (Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false); // REQUIRED before registering
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // blocks until something is ready
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove(); // MUST remove, or it repeats forever
if (!key.isValid()) continue;
if (key.isAcceptable()) {
SocketChannel client = server.accept();
client.configureBlocking(false);
// attach per-connection state directly to the key
client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
} else if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buf = (ByteBuffer) key.attachment();
int n = client.read(buf);
if (n == -1) { // peer closed cleanly
key.cancel();
client.close();
continue;
}
buf.flip();
client.write(buf); // may write only PART of it
buf.compact(); // keep whatever was not written
}
}
}
}
Four mistakes are near-universal in hand-written selector code.
Not removing the key from selectedKeys. The set is not cleared for you, so
the key is reported ready again on the next iteration, forever.
Registering permanent interest in OP_WRITE. A socket is writable almost
always, so a channel registered for OP_WRITE with nothing to send makes
select() return immediately every time, spinning a core at 100%. Register
OP_WRITE only while output is pending, and deregister when the queue drains.
Assuming write writes everything. A non-blocking write returns how many
bytes it accepted, which may be zero. Unwritten bytes must be retained and
retried when the channel next signals writable.
Doing slow work in the loop. Any blocking call, database query, or long computation in the selector thread stalls every other connection. Slow work must be handed to a pool, which reintroduces the threading you were avoiding.
So when should you write this? Honestly, rarely, and Java 21 sharpened the answer considerably.
| Approach | Memory per connection | Code complexity | Good for |
|---|---|---|---|
| Blocking, platform threads | ~1 MB | Low | Hundreds of connections |
| Blocking, virtual threads | Hundreds of bytes | Low | The default choice today |
| Non-blocking with a selector | One buffer | High | Frameworks, extreme connection counts, fine-grained buffer control |
AsynchronousSocketChannel | One buffer | High | Callback-driven designs |
Virtual threads deliver most of NIO's scalability while keeping the readable blocking style, and they are implemented on top of exactly this machinery. The reasons to reach for selectors directly are now narrow: you are writing a framework such as Netty, you need precise control over buffering and backpressure, or you must understand what the layer beneath you is doing. That last reason is why this topic exists.
Key Takeaways
- Thread per platform thread hits a memory wall, which is the C10k problem NIO was built for.
- A selector asks the operating system which channels are ready, so one thread can serve thousands.
ByteBuffertracks position, limit, and capacity, and the cycle is always fill, flip, drain, clear.- Forgetting
flipafter filling is the classic NIO bug.allocateDirectavoids a copy and suits pooled, long-lived buffers.- A channel must be set non-blocking before it can be registered.
- Keys must be removed from
selectedKeys, or they are reported ready forever.- Permanent
OP_WRITEinterest spins the CPU, so register it only when output is pending.- Non-blocking
writecan accept only part of a buffer, so retain the remainder.- Virtual threads now cover most cases selectors were used for, at far lower complexity.
🧪 Practice
- Write a program that fills a
ByteBuffer, prints position and limit after each operation, and demonstrates what happens ifflipis omitted.- Build the selector echo server above and connect three clients. Add a counter proving one thread served all of them.
- Deliberately register
OP_READ | OP_WRITEfor a connection with nothing to send, observe processor usage, and then fix it.- Change
compact()toclear()in the echo server and construct an input that exposes the difference.- Write the same echo server twice, once with a selector and once with blocking sockets on virtual threads. Compare line counts and explain when the extra complexity would be justified.
- Interview: A non-blocking server works in testing but pins a core at 100% in production with idle connections. What would you look at first? (Hint: which readiness signal is almost always true?)
Designing Protocols and Exchanging Objects
A socket gives you an ordered stream of bytes. It does not tell you where one message ends and the next begins, what those bytes mean, or what to do when the peer sends something unexpected. Deciding all of that is protocol design, and 14.1's warning now becomes a concrete engineering task.
Framing is the first problem: where does a message end? Because TCP has no message boundaries, the receiver must be able to determine the end of a message from the bytes themselves. Three strategies exist.
1. DELIMITER "LOGIN ada\n" "GET /x\n"
read until '\n' + trivial for text, human-debuggable
- delimiter must never appear in the data
- an endless line with no '\n' exhausts memory
2. LENGTH PREFIX [00 00 00 09]["LOGIN ada"]
read 4, then N + works for any bytes, no escaping needed
- the length MUST be bounded, or it is a memory bomb
3. FIXED SIZE [........16 bytes........]
read exactly N + simplest possible
- only works when every message is genuinely identical
Length prefixing is the general answer for binary protocols. The guard on the length is not optional.
private static final int MAX_FRAME = 1 << 20; // 1 MB ceiling
static byte[] readFrame(DataInputStream in) throws IOException {
int length = in.readInt(); // big-endian, the network order
if (length < 0 || length > MAX_FRAME) {
throw new ProtocolException("bad frame length: " + length); // reject, do not allocate
}
byte[] payload = new byte[length];
in.readFully(payload); // readFully loops; read() alone may return fewer bytes
return payload;
}
static void writeFrame(DataOutputStream out, byte[] payload) throws IOException {
out.writeInt(payload.length);
out.write(payload);
out.flush(); // otherwise the peer waits for a message you are holding
}
Two lines there carry disproportionate weight. Without the length check, a peer
sending 0x7FFFFFFF makes you attempt a two-gigabyte allocation, which is a
one-packet denial of service. And readFully exists because a plain read may
return fewer bytes than requested even mid-message.
The second problem is what goes inside the frame, and here Chapter 13.2's warning applies at full force. Java serialisation over a socket looks irresistible:
// TEMPTING, AND A REMOTE CODE EXECUTION VULNERABILITY IF THE PEER IS UNTRUSTED
ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream());
ObjectInputStream in = new ObjectInputStream(socket.getInputStream());
out.writeObject(order);
Order received = (Order) in.readObject(); // runs attacker-chosen code first
The bytes arriving from the network are exactly the untrusted input that 13.2
said never to deserialise. Between two processes you control, on an
authenticated channel, with an ObjectInputFilter allowlist installed, it is
defensible. Anywhere else, use a data format.
If you do use it, one mechanical detail causes a mysterious hang:
ObjectOutputStream's constructor writes a stream header immediately, and
ObjectInputStream's constructor blocks reading it. If both sides construct the
input stream first, both block forever.
DEADLOCK CORRECT
A: new ObjectInputStream(...) A: new ObjectOutputStream(...) then Input
B: new ObjectInputStream(...) B: new ObjectOutputStream(...) then Input
both wait for a header each header is sent, then each is read
that neither has sent
The payload formats from 13.2 apply unchanged, with one networking-specific consideration.
| Format | Debuggable on the wire | Size | Schema evolution | Fit |
|---|---|---|---|---|
| Line-based text | Excellent, use telnet | Large | Manual | Simple, low-volume protocols |
| JSON | Excellent | Large | Good | Public APIs, service-to-service |
| Protocol Buffers | No, needs tooling | Small | Excellent | High-volume internal traffic |
| Java serialisation | No | Large | Fragile | Avoid across a trust boundary |
Beyond framing and encoding, six decisions separate a toy protocol from a usable one.
Correlate requests with responses. The moment a client may have two requests in flight, responses must carry an identifier, or the client cannot tell which reply belongs to which request.
-> {"id":1,"op":"GET","key":"a"}
-> {"id":2,"op":"GET","key":"b"}
<- {"id":2,"result":"beta"} arrives first, and that is fine
<- {"id":1,"result":"alpha"}
Version from the first release. A version field or a negotiation step costs almost nothing now and is nearly impossible to retrofit once other people's software depends on you.
Detect dead peers explicitly. A peer whose machine loses power never sends a
FIN, so its connection looks healthy indefinitely. A periodic heartbeat with a
response deadline is the only reliable detection, and it belongs in the protocol
rather than relying on SO_KEEPALIVE, whose default interval is measured in
hours.
Make retries safe. Any networked call that times out may have succeeded, with only the response lost. Operations that clients will retry should be idempotent, or carry a client-supplied identifier the server can deduplicate against.
Bound everything. Maximum frame size, maximum concurrent connections, read and
write timeouts, and a cap on queued outbound messages. An unbounded queue turns
a slow consumer into an OutOfMemoryError.
Define the error path. Every protocol needs a way to say "I did not understand that" that leaves both sides agreeing on the connection's state. Silence is the worst possible answer, because the peer cannot distinguish it from a network partition.
A small text protocol showing several of these together:
REQUEST <id> <verb> <argument...>\n
RESPONSE <id> OK <payload...>\n
<id> ERR <code> <message...>\n
-> 1 PUT user:7 {"name":"Ada"}
<- 1 OK stored
-> 2 GET user:9
<- 2 ERR 404 no such key
-> 3 PING
<- 3 OK pong
Rules: UTF-8, one message per line, maximum line 8 KB, ids are per-connection
and monotonic, an unrecognised verb yields ERR 400 without closing.
That specification fits in a paragraph and already answers framing, correlation, errors, size limits, and encoding. Writing it down before the code is the point.
Security is not a layer you add afterwards. Use TLS for anything crossing a network you do not physically own, validate every field on arrival rather than trusting the peer's client code, never deserialise untrusted objects, and treat every length, count, and index from the wire as hostile until checked.
Key Takeaways
- TCP delivers a byte stream, so the application must define where messages end.
- Delimiter framing is readable but needs escaping and a maximum length.
- Length-prefix framing is general, and the length must be bounded before allocating.
readFullyis required becausereadmay return fewer bytes than asked for mid-message.- Unflushed output stalls a request-response protocol with no error.
- Java serialisation over a socket feeds untrusted bytes to
readObject, which is the vulnerability from 13.2.- Both peers must create
ObjectOutputStreambeforeObjectInputStream, or they deadlock on the stream header.- Concurrent requests need correlation identifiers to match responses.
- A dead peer looks healthy until a heartbeat with a deadline proves otherwise.
- Timed-out calls may have succeeded, so retried operations must be idempotent.
- Bound frame size, connection count, timeouts, and queue depth, and define an explicit error response.
🧪 Practice
- Implement length-prefixed framing over a socket, with a maximum frame size, and prove that a frame claiming a two-gigabyte length is rejected rather than allocated.
- Build a client and server for the line protocol specified above, including the
ERRpath for an unrecognised verb.- Write a client that sends three requests without waiting, and use correlation identifiers to match the out-of-order responses.
- Construct the
ObjectInputStreamdeadlock deliberately, observe the hang, then fix it and explain the stream header's role.- Add a heartbeat: the client sends
PINGevery five seconds and the server closes any connection silent for fifteen. Test by suspending the client process rather than killing it, and explain why that distinction matters.- Interview: A client times out on a payment request and retries, and the customer is charged twice. The network team says no packets were lost. What went wrong and how would you fix it in the protocol? (Hint: what does a timeout actually tell the client about the server's state?)
<a id="15-professional-practice-and-tooling"></a>