本节目标:能写一个可停止的阻塞 Echo 服务器,并说清 accept、read、write 各自阻塞在哪里。
适用版本:JDK 25(Temurin 25.0.5+8);java.net.ServerSocket、java.net.Socket。
承接的问题
2.3 解决了「地址怎么表示」:InetAddress 拿到 IP,端口是一个 0–65535 的整数,NetworkInterface 能列出本机网卡。但地址本身不会接收连接,必须把它绑定到一个监听套接字上。
linkd 的第一版入口就停在这个缺口上。LinkdMain 能读取启动参数里的端口,能打印本机地址,却没有任何东西在 9090 上等待连接。客户端执行 nc 127.0.0.1 9090 会立刻得到 Connection refused,因为内核在这个端口上找不到监听者。
这一节先把最小的阻塞 Echo 跑起来:ServerSocket 负责 bind、listen、accept,Socket 负责读写字节。它不处理半包,也不处理超时,这两件事分别留给第 4 章和 3.2。目标不是写出生产服务器,而是把每个阻塞点看清楚,后面换 NIO 时才知道自己替换掉了什么。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:ServerSocket 的 bind、listen 与 accept
new ServerSocket(port) 一行就完成了 bind 和 listen,但它不给你在 bind 之前设置选项的机会。要控制 SO_REUSEADDR、backlog 和绑定地址,用无参构造加 bind:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
public class ServerSocketBindDemo {
public static void main(String[] args) throws IOException {
try (ServerSocket server = new ServerSocket()) {
server.setReuseAddress(true);
server.bind(new InetSocketAddress("127.0.0.1", 9091), 128);
System.out.println("listening=" + server.getLocalSocketAddress());
System.out.println("reuseAddress=" + server.getReuseAddress()
+ " receiveBuffer=" + server.getReceiveBufferSize());
}
}
}
本机输出:
listening=/127.0.0.1:9091
reuseAddress=true receiveBuffer=131072
三个参数各有边界:
- 地址:
127.0.0.1只接受本机连接;0.0.0.0或new InetSocketAddress(port)绑定所有网卡。开发机图省事写后者,等于把端口暴露到局域网。linkd默认绑 loopback,需要对外时由配置显式覆盖。 - backlog:
bind的第二个参数是「等待 accept 的连接队列长度」的请求值,不是并发连接总数,也不是硬上限。队列具体是否包含尚未完成握手的半连接,由内核实现决定。本机sysctl kern.ipc.somaxconn是 128,内核会按自己的上限截断。把 backlog 写成 10000 不会让内核真的排 10000 个。 - reuseAddress:必须在
bind之前设置,bind 之后再调用无效。本机 JDK 25 上new ServerSocket()的默认值就是true,但这是平台相关行为,代码里显式写出来是给读代码的人看的,不是冗余。
accept() 是第一个真正的阻塞点。它从队列里取出一个已完成握手的连接,返回一个新的 Socket;原来的 ServerSocket 继续监听。返回的 Socket 有独立的文件描述符和独立的输入输出流,和监听套接字不是同一个东西。
try (Socket connection = server.accept()) {
System.out.println("accepted=" + connection.getRemoteSocketAddress()
+ " local=" + connection.getLocalSocketAddress());
}
本机输出:
accepted=/127.0.0.1:59650 local=/127.0.0.1:9091
远端地址是客户端的临时端口,本地地址是监听地址。一个 ServerSocket 可以先后 accept 成千上万个连接,但同一时刻只处理一个,除非把处理逻辑交给别的线程。阻塞模式下,accept 返回之前,调用线程什么也做不了。
核心机制二:Socket 的输入输出流与阻塞点
Socket 没有 read 和 write 方法,读写都走 getInputStream() 和 getOutputStream()。输入流是 InputStream,输出流是 OutputStream,语义和文件流一样,只是数据来自内核的接收缓冲区、写到内核的发送缓冲区。
先看一个最小的 Echo 处理:
package com.plume.linkd.app;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public final class SocketEchoHandler {
public static void handle(Socket socket) throws IOException {
socket.setTcpNoDelay(true);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
byte[] buffer = new byte[1024];
int n;
while ((n = in.read(buffer)) != -1) {
out.write("echo: ".getBytes(StandardCharsets.UTF_8));
out.write(buffer, 0, n);
out.flush();
}
}
}
这段代码有四个必须理解的细节:
read(byte[])返回的是本次读到的字节数,不是消息长度。 客户端一次写 5 个字节,服务端可能一次读到 5 个,也可能分两次读到 2 和 3。循环必须写到-1为止,-1才表示对端关了输出方向(发送了 FIN)。返回0只在传入长度为 0 的数组时出现,不能拿它当 EOF。read在没有数据时阻塞。 它阻塞到至少有一个字节可读、对端关闭、或者设置了读超时。3.2 会专门处理最后一种。write返回不代表对端收到。 它只表示数据被复制进了内核发送缓冲区。缓冲区满时write会阻塞,这一点在 3.2 用线程栈证明。flush()在SocketOutputStream上是空操作。 套接字流本身不带用户态缓冲;只有套了BufferedOutputStream才需要 flush。上面的代码保留 flush 是为了让意图明确,去掉不影响结果。
跑一次完整往返:
try (Socket client = new Socket()) {
client.connect(new InetSocketAddress("127.0.0.1", 9091), 2000);
client.getOutputStream().write("ping\n".getBytes(StandardCharsets.UTF_8));
byte[] buffer = new byte[64];
int n = client.getInputStream().read(buffer);
System.out.println(new String(buffer, 0, n, StandardCharsets.UTF_8).trim());
}
本机输出:
echo: ping
这里客户端只读了一次就假定拿到了完整响应。Echo 服务器把 echo: 和 ping\n 分两次 write,内核可能合并成一个段,也可能分成两个段;客户端一次 read 可能只拿到 echo: pi。示例能跑通不代表协议正确,第 4 章会用长度域和分隔符把消息边界定下来。
核心机制三:backlog、SO_REUSEADDR 与 TIME_WAIT
backlog 和 SO_REUSEADDR 经常被混在一起讲,其实解决的是两个阶段的问题。
backlog 管的是「连接已经建立、还没被 accept」的排队长度。队列满了之后,新连接能不能挤进来由内核决定:有的实现直接拒绝,有的丢掉 SYN 让客户端重传。Java 层看不到这个队列,只能通过 bind 的参数给内核一个建议值。压测时看到连接建立慢、客户端超时,但服务端 CPU 不高,要怀疑 accept 循环太慢导致队列堆积,而不是先调大 backlog。
SO_REUSEADDR 管的是「监听套接字能不能 bind 一个仍被旧连接占用的本地端口」。最典型的场景是服务重启:旧进程退出后,已建立的连接会在服务端留下 TIME_WAIT,默认持续 2MSL。新进程如果直接 bind 同一端口,可能得到 Address already in use。
本机做一个对照实验。服务端先关连接,让 9093 端口进入 TIME_WAIT,然后分别用 reuseAddress=false 和 true 重新 bind:
ServerSocket first = new ServerSocket();
first.setReuseAddress(true);
first.bind(new InetSocketAddress("127.0.0.1", 9093), 50);
// 建立一条连接,服务端先 close,客户端随后 close,制造 TIME_WAIT
try (Socket client = new Socket("127.0.0.1", 9093);
Socket accepted = first.accept()) {
accepted.close();
Thread.sleep(200);
client.close();
}
first.close();
Thread.sleep(200);
try (ServerSocket second = new ServerSocket()) {
second.setReuseAddress(false);
second.bind(new InetSocketAddress("127.0.0.1", 9093), 50);
System.out.println("rebind reuse=false: ok");
} catch (BindException e) {
System.out.println("rebind reuse=false: " + e.getMessage());
}
try (ServerSocket third = new ServerSocket()) {
third.setReuseAddress(true);
third.bind(new InetSocketAddress("127.0.0.1", 9093), 50);
System.out.println("rebind reuse=true: ok");
}
本机输出:
rebind reuse=false: Address already in use
rebind reuse=true: ok
TIME_WAIT 不是 bug,它是 TCP 防止旧连接的迟到报文被新连接误收的机制。SO_REUSEADDR 让新监听者可以复用这个端口,但不会让两个同时活跃的监听者共享同一端口;那是 SO_REUSEPORT 的职责,JDK 标准 API 不直接暴露,不在本节范围。
服务端跑起来后,用 macOS 的 netstat 确认监听状态:
netstat -anv -p tcp | grep '\.9094'
本机输出:
tcp4 0 0 127.0.0.1.9094 *.* LISTEN 0 0 131072 131072 java:8171 00000 00000006 0000000002b853e3 00000000 00000800 1 0 000000
LISTEN 表示套接字在等连接;两列 0 是接收队列和发送队列的当前字节数;131072 是接收和发送缓冲区大小;最后一列是进程。macOS 的可视化连接工具和 Linux 不同,连接诊断就用 netstat、lsof 和 tcpdump,不要照抄 Linux 教程里的命令。
在 linkd 里落地
把上面的片段拼成 linkd 的最小入口,拆成两个类:BlockingEchoServer 管生命周期,SocketEchoHandler 管单连接读写。这个版本只用于第 3 章验证阻塞模型,第 6 章会被 Selector 版本替换。
package com.plume.linkd.app;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class BlockingEchoServer {
private final int port;
private final Set<Socket> connections = ConcurrentHashMap.newKeySet();
private volatile boolean running;
private ServerSocket server;
public BlockingEchoServer(int port) {
this.port = port;
}
public int start() throws IOException {
server = new ServerSocket();
server.setReuseAddress(true);
server.bind(new InetSocketAddress("127.0.0.1", port), 128);
running = true;
Thread acceptor = new Thread(this::acceptLoop, "linkd-accept");
acceptor.start();
return server.getLocalPort();
}
private void acceptLoop() {
while (running) {
try {
Socket socket = server.accept();
connections.add(socket);
Thread handler = new Thread(() -> {
try (socket) {
SocketEchoHandler.handle(socket);
} catch (IOException e) {
System.getLogger("linkd").log(System.Logger.Level.WARNING,
"connection failed: " + socket.getRemoteSocketAddress(), e);
} finally {
connections.remove(socket);
}
}, "linkd-echo");
handler.start();
} catch (IOException e) {
if (running) {
System.getLogger("linkd").log(System.Logger.Level.ERROR, "accept failed", e);
}
}
}
}
public void stop() {
running = false;
try {
if (server != null) {
server.close();
}
} catch (IOException ignored) {
// close 只用于唤醒 accept,失败不影响退出
}
}
}
start() 返回实际端口,是为了测试时能传 0 让内核分配临时端口。acceptLoop 在 server.close() 之后会从 accept 抛出 SocketException: Socket closed;因为 running 已经变成 false,这个异常被静默吞掉,这是正常的停止路径,不是故障。
stop() 只关闭监听套接字,已接受的连接还在 connections 里。3.3 会把「排空写队列、半关闭、关闭连接」补全;本节先保证 accept 能停下来。每接受一个连接就起一个平台线程,这个模型在连接数上千时会耗尽线程栈,第 6 章换 Selector 的动机就来自这里。
LinkdMain 只需要把端口和关闭钩子接上:
package com.plume.linkd.app;
public final class LinkdMain {
public static void main(String[] args) throws Exception {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 9090;
BlockingEchoServer server = new BlockingEchoServer(port);
int bound = server.start();
Runtime.getRuntime().addShutdownHook(
new Thread(server::stop, "linkd-shutdown"));
System.out.println("linkd listening on " + bound);
Thread.sleep(Long.MAX_VALUE);
}
}
Thread.sleep(Long.MAX_VALUE) 让主线程停住,进程不会因为 main 返回而进入退出流程。acceptor 和 handler 都是非守护线程,严格来说不睡也不会立刻退出,但显式停住让「服务器进程一直运行到收到信号」这件事写在代码里。3.3 会把这个 LinkdMain 的关闭钩子接到真正的排空逻辑上。
常见错误与边界
错误一:用无参 new ServerSocket(9090) 绑定所有网卡。 它等价于绑定 0.0.0.0,本机所有网络接口都能连进来。开发机的防火墙不一定拦住,云主机上就是直接暴露。linkd 明确写 127.0.0.1,需要对外时再通过配置改地址。
错误二:以为设置 running=false 就能让 accept 返回。 accept 阻塞在系统调用里,不会检查 Java 字段。不调用 server.close(),线程会一直挂着。关闭后 accept 抛 SocketException: Socket closed,这是预期路径。
错误三:把一次 read 当成一条消息。 阻塞套接字是字节流,没有消息边界。客户端写 100 字节,服务端可能读到 100、60、40,甚至先读到 1。Echo 能工作只是因为原样回写;一旦要解析协议,必须自己定帧。
错误四:用 read() == 0 判断连接关闭。 无参 read() 返回 0–255 的字节值或 -1;read(byte[]) 返回读取长度或 -1。只有 -1 是 EOF。把 0 当关闭会误判合法的 0x00 字节。
边界:backlog 不是并发上限。 它只影响握手完成后的排队长度。真正的并发上限由线程数、文件描述符上限和内存决定。ulimit -n 在 macOS 上默认不高,连接多了会先在 accept 或 Socket 创建处失败。
边界:SO_REUSEADDR 不解决端口冲突。 如果另一个进程正在 LISTEN 同一地址和端口,设置 reuse 也 bind 不上;它只处理 TIME_WAIT 这类残留状态。
小结
ServerSocket的bind(addr, backlog)同时完成绑定和监听,accept()返回一个新的Socket,监听套接字继续工作。- backlog 是内核队列长度的建议值,本机
kern.ipc.somaxconn为 128,调大 Java 参数不会突破内核上限。 SO_REUSEADDR必须在 bind 前设置;本机实测关闭后false得到Address already in use,true可以立即重绑。- 阻塞
read等到至少一个字节、EOF 或超时;write只复制到内核缓冲区,满了会阻塞。 read(byte[])的返回值是本次字节数,-1才是 EOF;消息边界要靠第 4 章的帧格式。BlockingEchoServer用server.close()唤醒accept,每个连接一个平台线程,这个模型就是第 6 章要替换的对象。
Echo 能跑起来之后,下一个问题是「等多久」。客户端连不上、服务端迟迟不响应、发送缓冲区满导致写卡住,这三种情况在代码里对应完全不同的 API。下一节把它们分开测量。
阅读导航:上一节:2.3 InetAddress、端口与网络接口 · 下一节:3.2 连接、读、写超时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。