《Java 网络编程基础》2.1 TCP 连接、字节流与可靠性边界

linkd 的 HTTP 响应在 loopback 上被拆成多次 read。本节用最小程序观察三次握手在 Java 里的可见时刻、两次 write 被合并或拆开的真实输出、Nagle 与 setTcpNoDelay 的测量,以及 SO_LINGER(0) 触发 RST 和 TIME_WAIT 的 netstat 证据,最后落到 Connection 与 FrameDecoder 的边界处理。

本节目标:分清 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 与丢包 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发