本节目标:分清 TCP 保证什么、不保证什么,以及这些边界在 Java API 里的具体表现。
适用版本:JDK 25(Temurin 25.0.5+8)。
承接的问题
linkd 的 /books/{id} 返回一段约 300 字节的 JSON。第一版客户端只调用一次 InputStream.read(byte[]),然后把缓冲区交给 HttpParser。在 loopback 上它大多数时候能工作,但并发一上来就间歇性返回 NEED_MORE。日志里同一段响应有时一次到齐,有时分三次才到。
这不是 HttpParser 的 bug。TCP 只保证字节按序到达,不保证一次 read 对应一个请求或一个响应。把「消息」当成「字节流」的边界,是网络编程里最常见的一类错误。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
三次握手在 Java 里只露出两个时刻
TCP 建连的 SYN、SYN-ACK、ACK 三个包,Java 代码看不到。能观察到的只有两个返回点:
- 客户端
Socket.connect()正常返回,表示本端收到 SYN-ACK 并发出 ACK,内核状态进入ESTABLISHED。 - 服务端
ServerSocket.accept()返回一个Socket,表示三次握手已经完成,连接已在内核的 accept 队列里。
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class ConnectDemo {
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(19092, 50, InetAddress.getLoopbackAddress())) {
Thread acceptor = new Thread(() -> {
try (Socket s = server.accept()) {
System.out.println("server accepted " + s.getRemoteSocketAddress());
Thread.sleep(20_000);
} catch (Exception e) {
e.printStackTrace();
}
});
acceptor.start();
long start = System.nanoTime();
try (Socket client = new Socket()) {
client.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), 19092), 1000);
System.out.println("connect returned in "
+ (System.nanoTime() - start) / 1_000 + " us");
System.out.println("connected=" + client.isConnected()
+ ", local=" + client.getLocalSocketAddress()
+ ", remote=" + client.getRemoteSocketAddress());
Thread.sleep(20_000);
}
acceptor.join();
}
}
}
本机输出:
connect returned in 4846 us
server accepted /127.0.0.1:60490
connected=true, local=/127.0.0.1:60490, remote=localhost/127.0.0.1:19092
connect 的 4846 微秒包含线程调度,不是网络 RTT,单次运行不能当性能数字。getLocalSocketAddress() 给出内核分配的临时端口 60490,getRemoteSocketAddress() 把 loopback 反解成了 localhost。此时从另一个终端看:
netstat -an -p tcp | grep 19092
本机输出:
tcp4 0 0 127.0.0.1.19092 127.0.0.1.60490 ESTABLISHED
tcp4 0 0 127.0.0.1.60490 127.0.0.1.19092 ESTABLISHED
tcp4 0 0 127.0.0.1.19092 *.* LISTEN
两行 ESTABLISHED 是连接的两端,第三行是监听 socket。这里能读出三个事实。
connect()返回时,服务端的 accept 队列里已经有这条连接,但应用可能还没调用accept()。isConnected()只表示本端 socket 的状态,不表示对端进程还活着。对端崩溃后,本端仍可能是true,直到写入失败或探测超时。- 内核的
LISTEN与ESTABLISHED是分开的;backlog限制的是已完成握手、等待accept()的队列长度,不是 Java 线程数。
字节流没有消息边界
把两次 write 当成两条消息,是第一个会踩的坑。下面程序连续三轮发送 hello 和 world,每一次都分两次 write,接收端用 64 字节缓冲读第一次:
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class StreamBoundaryDemo {
public static void main(String[] args) throws Exception {
for (int round = 1; round <= 3; round++) {
try (ServerSocket server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) {
int port = server.getLocalPort();
Thread writer = new Thread(() -> {
try (Socket s = server.accept()) {
OutputStream out = s.getOutputStream();
out.write("hello".getBytes());
out.write("world".getBytes());
out.flush();
} catch (Exception e) {
e.printStackTrace();
}
});
writer.start();
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), port)) {
InputStream in = client.getInputStream();
byte[] buf = new byte[64];
int n = in.read(buf);
System.out.println("round " + round + ": (" + n + ") ["
+ new String(buf, 0, n) + "]");
int m = in.read(buf);
System.out.println(" next read = " + m);
}
writer.join();
}
}
}
}
本机输出:
round 1: (5) [hello]
next read = 5
round 2: (10) [helloworld]
next read = -1
round 3: (10) [helloworld]
next read = -1
同一个程序、同样的两次 write,第一轮被拆成 5+5,后两轮被合并成 10。第二轮之后 read 返回 -1,是收到了 FIN,不是「消息结束」。另一次同类运行把一条 22 字节的请求一次写入,用 8 字节缓冲接收,本机输出是:
8-byte buffer read 8 bytes
8-byte buffer read 8 bytes
8-byte buffer read 7 bytes
read 的返回值是「这次拿到了多少字节」,不是「这条消息有多长」。因此 TCP 之上必须有帧格式。linkd 用 FrameDecoder.decode(ByteBuffer) 判断完整性,返回 List<byte[]>;没有完整帧时返回空列表,把剩余字节留在缓冲区。长度域、分隔符和半包处理在第 4.2 节展开,这里先守住一条:除了解码器,任何地方都不能假设一次 read 等于一条消息。
Nagle、延迟确认与 setTcpNoDelay
Nagle 算法规定:如果连接上还有未被 ACK 的小段数据,就把后续小段攒起来,等 ACK 或攒够一个 MSS 再发。接收方的延迟确认又会把 ACK 推迟几十毫秒(Linux 常见 40 毫秒,具体随系统实现不同)。两者叠加,write、write、read 这种模式可能每轮多出几十毫秒。Java 的 Socket.setTcpNoDelay(true) 关闭 Nagle,默认值是 false。
下面程序在 loopback 上跑三轮 write-write-read,对比开关:
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class NagleDemo {
public static void main(String[] args) throws Exception {
for (boolean noDelay : new boolean[]{false, true}) {
try (ServerSocket server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) {
int port = server.getLocalPort();
Thread echo = new Thread(() -> {
try (Socket s = server.accept()) {
InputStream in = s.getInputStream();
OutputStream out = s.getOutputStream();
for (int i = 0; i < 3; i++) {
out.write(in.read() + in.read());
out.flush();
}
} catch (Exception e) {
e.printStackTrace();
}
});
echo.start();
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), port)) {
client.setTcpNoDelay(noDelay);
OutputStream out = client.getOutputStream();
InputStream in = client.getInputStream();
for (int i = 0; i < 3; i++) {
long start = System.nanoTime();
out.write('a');
out.write('b');
out.flush();
in.read();
System.out.println("nodelay=" + noDelay + " round " + i + ": "
+ (System.nanoTime() - start) / 1_000 + " us");
}
}
echo.join();
}
}
}
}
本机输出(单次运行,未做统计):
nodelay=false round 0: 1325 us
nodelay=false round 1: 1083 us
nodelay=false round 2: 779 us
nodelay=true round 0: 805 us
nodelay=true round 1: 660 us
nodelay=true round 2: 865 us
这里没有出现几十毫秒级别的差距。原因是 loopback 的 ACK 几乎即时返回,Nagle 没有机会等到延迟确认。这个实验只能证明测量方法可用,不能证明 Nagle 在真实链路上无影响;要复现这类延迟,需要经过会延迟 ACK 的对端和更高 RTT 的链路,本节没有这样的环境,不作实测声明。
工程结论按场景分:小控制帧、请求-响应型协议可以开 setTcpNoDelay(true) 降低尾延迟;批量传输应尽量一次写够,攒批比逐条写更重要。另外,Socket 的 OutputStream.flush() 不会把数据推过网卡,它最多把数据交给内核缓冲区,flush 成功不等于对端收到。
TIME_WAIT、RST 与 close 的三种结局
正常关闭时,主动关闭方发 FIN,进入 TIME_WAIT,等待 2 倍 MSL,防止旧连接的迟到报文污染新连接。这个状态留在内核里,Java 进程退出后仍然存在,所以 lsof 往往看不到它,要用 netstat。默认 linger 的主动关闭只有三行:
Socket client = new Socket(loop, 19093);
client.getOutputStream().write("bye".getBytes());
client.close(); // 发 FIN,主动关闭方进入 TIME_WAIT
下面程序改用 SO_LINGER(true, 0) 强制 RST:
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
public class CloseDemo {
public static void main(String[] args) throws Exception {
InetAddress loop = InetAddress.getLoopbackAddress();
try (ServerSocket server = new ServerSocket(19094, 50, loop)) {
Thread t = new Thread(() -> {
try (Socket s = server.accept()) {
System.out.println("rst: server read=" + s.getInputStream().read());
Thread.sleep(300);
OutputStream out = s.getOutputStream();
out.write("late".getBytes());
out.flush();
System.out.println("rst: write succeeded");
} catch (SocketException e) {
System.out.println("rst: " + e.getClass().getSimpleName() + ": " + e.getMessage());
} catch (Exception e) {
e.printStackTrace();
}
});
t.start();
Socket client = new Socket(loop, 19094);
client.getOutputStream().write('x');
client.getOutputStream().flush();
client.setSoLinger(true, 0);
client.close();
System.out.println("rst: client closed with SO_LINGER(true, 0)");
t.join();
}
System.out.println("sleeping 20s for netstat");
Thread.sleep(20_000);
}
}
本机输出:
rst: server read=120
rst: client closed with SO_LINGER(true, 0)
rst: SocketException: Broken pipe
sleeping 20s for netstat
另一次运行改用默认 close(),客户端本地端口是 60912;进程还在 sleep 时执行:
netstat -an -p tcp | grep 19093
本机输出:
tcp4 0 0 127.0.0.1.60912 127.0.0.1.19093 TIME_WAIT
主动关闭方是客户端,所以 TIME_WAIT 落在临时端口 60912 这一侧,服务端的 19093 已经回到 CLOSED。此时 lsof -nP -iTCP:19093 没有输出:TIME_WAIT 是内核状态,不再关联进程。
RST 那一半更值得注意。客户端 setSoLinger(true, 0) 后 close(),内核直接发 RST 并丢弃发送缓冲;服务端 300 毫秒后再写,拿到的是 Broken pipe,而不是「对端已关闭」的温和通知。SO_LINGER(true, 0) 适合「宁可丢数据也要立刻断」的场景,例如超时清理;普通业务关闭应该用默认 linger,让内核把已排队的数据发完。
在 linkd 里落地
linkd-core 的读路径只做三件事:读字节、交给解码器、把完整帧入队。边界判断集中在 FrameDecoder,Connection 不解释字节含义。
package com.plume.linkd.core;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.List;
public record FramedEchoHandler(FrameDecoder decoder, BufferPool pool) implements Handler {
@Override
public void onReadable(Connection c) {
ByteBuffer in = pool.acquire(1024);
try {
int n = c.channel().read(in);
if (n == -1) {
c.close();
return;
}
in.flip();
List<byte[]> frames = decoder.decode(in);
for (byte[] frame : frames) {
c.enqueue(ByteBuffer.wrap(frame));
}
} catch (IOException e) {
c.close();
} finally {
pool.release(in);
}
}
@Override public void onWritable(Connection c) { }
@Override public void onClosed(Connection c) { }
}
这段代码里每一处都对应前面的边界:
c.channel().read(in)返回的是字节数,可能是半个帧,也可能是三个帧;n == -1只表示 FIN。decoder.decode(in)返回完整帧列表,未消费的字节留在ByteBuffer里。FrameDecoder必须允许「这次不够、下次再来」,否则就会把半包当错误。pool.release(in)必须在decode之后;如果解码器保存的是输入缓冲区的切片而不是拷贝,释放后就会读到被复用的内存。本节的decode约定返回拷贝。c.enqueue(ByteBuffer.wrap(frame))只是把帧追加到连接自己的写队列,不保证一次系统调用发出去,也不保证对端一次 read 收到。写队列的背压和水位在第 14.1 节处理。
EventLoop.register(SocketChannel, Handler) 把 SocketChannel 设为非阻塞后注册读事件,所以 onReadable 可能在一次可读事件里只拿到部分数据,也可能连续触发多次。处理方式不变:每次把新字节喂给解码器,直到它交不出完整帧。
常见错误与边界
错误一:把一次 read 当一条消息。 本机同一个程序三轮出现 5+5 和 10 两种结果;用 8 字节缓冲读 22 字节请求得到 8/8/7。解析器必须循环读、按帧判断,不能把 read 的返回值当消息长度。
错误二:用 isConnected() 判断对端活着。 它只反映本端 socket 状态。对端进程崩溃、机器掉电时,本端可能长时间仍是 true,直到写入失败、读超时或 keepalive 探测。业务存活要靠读超时和心跳,见第 14.2 节。另外,TIME_WAIT 是主动关闭方的正常状态,数量高说明短连接多;解决方向是连接复用,而不是调小它。按状态过滤用 netstat -an -p tcp。
错误三:把 flush() 当送达确认,或者逐字节 write。 Socket 的输出流 flush() 不会等待 ACK;close() 默认也只保证内核尝试发送,进程崩溃时缓冲区里的数据会丢。反过来,在循环里逐字节写且不开 TCP_NODELAY,每个小段都可能等前一段的 ACK,在真实链路上放大成延迟;正确做法是先在应用缓冲区拼好一帧,再一次写出。loopback 上这个差异可能完全看不见,不能拿本机结果推断公网。
小结
connect()和accept()是三次握手仅有的两个可见返回点;isConnected()与netstat的ESTABLISHED都只说明本端或内核状态,不证明对端存活。- TCP 是字节流:两次
write可能合并成一次read,一次write也可能被拆成多次read;本机三轮输出已经出现两种结果。消息边界必须由FrameDecoder.decode这类帧格式收口,Connection.enqueue只负责排队。 - Nagle 与延迟确认可能叠加出几十毫秒延迟,但 loopback 复现不了;正常关闭进入
TIME_WAIT,SO_LINGER(true, 0)发 RST 并丢数据,本机服务端随后写入得到Broken pipe。
TCP 用连接、重传和有序交付换取可靠性,代价是握手、状态机和队头阻塞。当业务需要的只是「一条尽量送达的消息」,并且能容忍丢失时,这套机制反而成为负担。下一节看 UDP 的数据报语义、MTU 与丢包。
阅读导航:上一节:1.3 linkd 项目与实验环境 · 下一节:2.2 UDP 数据报、MTU 与丢包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。