《Java 网络编程基础》7.2 半包、粘包与缓冲区增长

一次 read 可能只拿到半个帧,一次 write 也可能只写出一部分。本节用 linkd 的 AccumulatingReader 累积字节、compact 保留残余、按帧上限扩容,并用 WriteQueue 暂存未写完的 ByteBuffer,给出半包、粘包与背压的可编译实现和实测输出。

本节目标:把「一次 read 不等于一条消息」落实到缓冲区代码里,写出能处理半包、粘包、部分写和缓冲区上限的 AccumulatingReader 与 WriteQueue。
适用版本:JDK 25(Temurin 25.0.5+8);ByteBuffer、SocketChannel、FrameDecoder。

承接的问题

7.1 的 Echo 是「读到多少回多少」,不需要知道消息边界。一旦换成 linkd 的帧协议,问题立刻出现:客户端发两个 100 字节的帧,服务端可能一次 read 拿到 200 字节;客户端发一个 3000 字节的帧,服务端可能分三次才收全。

这就是常说的半包与粘包。它们不是 TCP 的缺陷,而是 TCP 的本职工作:TCP 只保证字节流有序、可靠,不保证一次 read 对应一次 write,也不保证消息边界。边界必须由应用层协议定义,再由缓冲区代码还原。

写方向同样如此。7.1 已经实测:1 MB 的 write 只写出 1024 字节就返回 0。读方向的半包和写方向的半包,本质是同一个问题:用户态必须持有「还没处理完的那部分」。

本节给出两个可编译组件:AccumulatingReader 负责入站累积与拆帧,WriteQueue 负责出站排队与部分写。它们都只依赖 JDK 和 7.2 节约定的 BufferPool、FrameDecoder、Connection、Handler。

核心机制一:字节流里没有消息边界

假设协议是「2 字节大端长度 + 负载」。客户端依次发 hello 和 world,线上字节是:

00 05 68 65 6c 6c 6f 00 05 77 6f 72 6c 64

服务端可能看到下面任意一种切分:

情况 A:一次 read 拿到全部 14 字节  -> 两个完整帧
情况 B:第一次拿到 00 05 68 65      -> 半个帧

FrameDecoder.decode(ByteBuffer in) 的契约是:从 in 的当前位置开始读,返回所有已经完整的帧,并把 position 停在第一个不完整帧的起点;不完整的字节留在缓冲区里,等下一次 read 补齐。AccumulatingReader 的职责就是保证这些字节不被覆盖、不被丢弃。

拆帧本身在 4.2 已经讲过,这里只关心缓冲区的生命周期。一个完整的循环是:

  1. read 把字节追加到累积缓冲区;
  2. flip() 切换到读模式;
  3. decode 消费完整帧,position 停在残余数据处;
  4. compact() 把残余数据搬到缓冲区头部,恢复写模式;
  5. 下一次 read 从残余数据之后继续写。

第 4 步的 compact() 是关键。没有它,position 会一直增长到 capacity,残余数据永远留在原地,缓冲区很快耗尽。

核心机制二:累积、compact 与扩容

ByteBuffer 有三个值:position、limit、capacity。读写模式的区别只在这三个值上:

阶段positionlimit含义
写入后数据末尾capacity可继续 read
flip() 后0数据末尾可被 decode 消费
decode 后已消费位置数据末尾剩余的是半个帧
compact() 后残余字节数capacity残余搬到头部,继续写

缓冲区会满。策略有两种:每次 read 前检查剩余空间,不够就扩容;或者固定大小,满了就报错关闭连接。linkd 选择前者,但加一个硬上限 maxBuffered,防止对端声明一个超大长度把内存吃光。

扩容按倍数增长,而不是每次加固定字节。按倍数增长时,累计拷贝量是 O(n),均摊到每次追加是 O(1);每次只加 1 KB 则在长帧上退化成 O(n²)。上限决定最坏情况:maxBuffered = 1 MiB 意味着单连接最多占 1 MiB 累积缓冲。

AccumulatingReader 的完整实现:

package com.plume.linkd.core;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.List;

public final class AccumulatingReader implements Handler {

    @FunctionalInterface
    public interface FrameHandler {
        void onFrame(Connection c, byte[] frame);
    }

    public static final int DEFAULT_MAX_BUFFERED = 1 << 20;

    private final BufferPool pool;
    private final FrameDecoder decoder;
    private final FrameHandler frames;
    private final int maxBuffered;
    private ByteBuffer acc;

    public AccumulatingReader(BufferPool pool, FrameDecoder decoder, FrameHandler frames) {
        this(pool, decoder, frames, DEFAULT_MAX_BUFFERED);
    }

    public AccumulatingReader(BufferPool pool, FrameDecoder decoder,
                              FrameHandler frames, int maxBuffered) {
        if (maxBuffered < 64) {
            throw new IllegalArgumentException("maxBuffered too small: " + maxBuffered);
        }
        this.pool = pool;
        this.decoder = decoder;
        this.frames = frames;
        this.maxBuffered = maxBuffered;
        this.acc = pool.acquire(1024);
    }

    @Override
    public void onReadable(Connection c) {
        try {
            int n;
            do {
                if (!acc.hasRemaining()) {
                    grow();
                }
                n = c.channel().read(acc);
            } while (n > 0);
            drainFrames(c);
            if (n == -1) {
                c.close();
            }
        } catch (IOException e) {
            c.close();
        }
    }

    @Override
    public void onWritable(Connection c) {
        // 本类只处理入站数据,出站由 Connection 的写队列负责。
    }

    @Override
    public void onClosed(Connection c) {
        if (acc != null) {
            pool.release(acc);
            acc = null;
        }
    }

    public int capacity() {
        return acc.capacity();
    }

    public int bufferedBytes() {
        return acc.position();
    }

    private void drainFrames(Connection c) {
        acc.flip();
        List<byte[]> decoded = decoder.decode(acc);
        acc.compact();
        for (byte[] frame : decoded) {
            frames.onFrame(c, frame);
        }
    }

    private void grow() throws IOException {
        int current = acc.capacity();
        int next = current >= maxBuffered / 2 ? maxBuffered : current * 2;
        if (next <= current) {
            throw new IOException("frame exceeds maxBuffered=" + maxBuffered);
        }
        ByteBuffer bigger = pool.acquire(next);
        acc.flip();
        bigger.put(acc);
        pool.release(acc);
        acc = bigger;
    }
}

实现里有三个容易忽略的细节:

  • acc 始终处于写模式。 read 直接写进累积缓冲区,省掉「临时缓冲区再拷贝」的一次内存复制。只有 drainFrames 里短暂 flip,随后立刻 compact 回写模式。
  • grow() 在 hasRemaining() 为 false 时调用。 此时 position == limit == capacity,flip() 后 limit = capacity、position = 0,bigger.put(acc) 正好复制全部有效数据,bigger 的 position 落在旧数据末尾。
  • onClosed 释放累积缓冲区。 这个类每个连接一个实例,缓冲区也是每连接一份;不释放就会随连接数泄漏。BufferPool 的回收依赖这里,不能只依赖 GC。

用一个长度域解码器跑一遍真实的半包与扩容。客户端把 hello 拆成三段、把 world 拆成两段,再发一个 3002 字节的帧,服务端 maxBuffered 保持默认 1 MiB:

chunk 1 (00) -> buffered=1, cap=1024
chunk 2 (05 he) -> buffered=4, cap=1024
frame=hello
chunk 3 (llo 00) -> buffered=1, cap=1024
buffered before last byte = 1, cap=1024
frame=world
chunk 4 (05 world) -> buffered=0, cap=1024
big frame bytes = 3002, cap before = 1024
frame=xxxx…(3000 个 x,长行截断)
cap after big frame = 4096, buffered=0

本机输出,单次运行,未做统计。逐段看:

  • 前两段只有 4 字节,长度域 00 05 里还缺最后一个字节,buffered=4,没有帧输出。
  • 第三段补齐 hello,同时带来下一帧的第一个长度字节 00;decode 返回 hello,compact 留下 1 字节,所以 buffered=1。
  • 第四段补齐 world,缓冲区清空。
  • 3002 字节的帧让容量从 1024 翻到 2048,再翻到 4096;读完并拆帧后 buffered=0,容量保留在 4096 供后续复用,不会缩回去。连接上的帧大小通常稳定,缩容反而会让下一帧再次扩容。

核心机制三:写半包与 WriteQueue

读方向要「攒够再拆」,写方向要「写不完就留」。WriteQueue 用一个 ArrayDeque<ByteBuffer> 保存待写数据,writeTo 反复调用非阻塞 write,直到队列为空或 write 返回 0:

package com.plume.linkd.core;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.ArrayDeque;
import java.util.Deque;

public final class WriteQueue {

    private final Deque<ByteBuffer> queue = new ArrayDeque<>();
    private final BufferPool pool;
    private final long highWaterMark;
    private long pending;

    public WriteQueue(BufferPool pool, long highWaterMark) {
        this.pool = pool;
        this.highWaterMark = highWaterMark;
    }

    public boolean enqueue(ByteBuffer out) {
        int size = out.remaining();
        if (pending + size > highWaterMark) {
            return false;
        }
        queue.addLast(out);
        pending += size;
        return true;
    }

    public boolean writeTo(SocketChannel ch) throws IOException {
        while (!queue.isEmpty()) {
            ByteBuffer head = queue.peekFirst();
            int n = ch.write(head);
            if (n < 0) {
                throw new IOException("channel closed while writing");
            }
            pending -= n;
            if (head.hasRemaining()) {
                return false;
            }
            queue.pollFirst();
            pool.release(head);
        }
        return true;
    }

    public boolean isEmpty() {
        return queue.isEmpty();
    }

    public long pendingBytes() {
        return pending;
    }

    public int queuedBuffers() {
        return queue.size();
    }

    public void clear() {
        ByteBuffer buf;
        while ((buf = queue.pollFirst()) != null) {
            pool.release(buf);
        }
        pending = 0;
    }
}

行为边界:

  • writeTo 返回 false 表示「还没写完」,不是失败。 此时队头缓冲区保留,pending 是剩余字节数,调用方应该确保 OP_WRITE 仍然注册。
  • writeTo 返回 true 表示队列已空。 调用方应立刻摘掉 OP_WRITE,否则 7.1 说的空转就会出现。
  • enqueue 返回 false 表示超过水位。 缓冲区所有权仍归调用方,调用方要负责释放;Connection.enqueue 的返回类型是 void,所以这条拒绝路径由 Connection 内部决定是关闭连接还是丢弃,Handler 拿不到布尔值。
  • clear() 在连接关闭时调用。 队列里每个缓冲区都要 pool.release,否则连接关了、内存没还。
  • n < 0 只可能出现在通道已关闭的实现里。 非阻塞 write 在正常情况下返回 0 或正数;把它当异常向上抛,是为了让调用方走统一的关闭流程。

同一个 64 KiB 缓冲区,在 1 KB 发送缓冲区、对端不读的条件下,本机输出:

enqueue 64KiB = true
drained=false, pending=64512, buffers=1

只写出了 1024 字节,队列里还剩 64512 字节和 1 个缓冲区。drained=false 告诉事件循环「下次可写还要来」,pendingBytes=64512 则是背压信号的原始值。14.1 会把它换算成水位百分比,决定是继续接收还是暂停读取。

在 linkd 里落地:把拆帧接到事件循环

AccumulatingReader 是每连接一个的 Handler,不能跨连接复用。accept 之后,按连接创建实例并登记:

AccumulatingReader reader = new AccumulatingReader(pool, decoder, (conn, frame) -> {
    ByteBuffer out = pool.acquire(frame.length);
    out.put(frame).flip();
    conn.enqueue(out);
});
loop.register(ch, reader);

这段装配有三个约束:

  • decoder 可以共享,reader 不能共享。 FrameDecoder 如果是无状态的(长度域、分隔符)可以一个实例服务所有连接;AccumulatingReader 持有 acc 和 maxBuffered,共享会让两个连接的字节混进同一个缓冲区。
  • 帧回调里只做「转成 ByteBuffer 并入队」。 业务处理(查库、写文件)不能放在这里,否则会阻塞事件循环;12.2 会把帧投递到业务线程池。
  • enqueue 不返回结果。 Connection.enqueue 的契约是 void,水位拒绝和关闭策略封装在 Connection 内部。Handler 侧想主动感知背压,需要通过 14.1 暴露的水位回调,而不是检查返回值。

FrameDecoder 的实现不在这里展开。4.2 的长度域解码器已经满足「消费完整帧、保留残余」的契约;如果自定义解码器在数据不足时 reset 回帧起点,compact 之后残余字节的位置才是正确的。解码器提前消费了半个帧的字节,compact 也救不回来。

常见错误与边界

错误一:decode 前不 flip。

// 反例:position 在数据末尾,decode 看不到任何字节
List<byte[]> frames = decoder.decode(acc);

写模式下 position 指向数据末尾,decode 从 position 读到 limit,区间为空,永远返回空列表。缓冲区随后被写满,grow() 扩容,内存持续增长但一帧也解析不出来。正确顺序是先 flip(),解码后再 compact()。

错误二:每次 read 都整块复制。

// 反例:每来一块就复制整个累积缓冲区
byte[] copy = new byte[acc.position() + n];
System.arraycopy(acc.array(), 0, copy, 0, acc.position());

帧越长、分片越多,拷贝次数越多,复杂度接近 O(n²)。AccumulatingReader 直接读进累积缓冲区,只在扩容时复制一次,均摊成本是 O(1)。

错误三:不设缓冲区上限。

长度域协议里,对端只要发 7f ff ff ff,实现就会尝试分配 2 GB。即使分配成功,多个连接同时这么做也会把堆打满。maxBuffered 必须在协议层有明确上限,超限时关闭连接并记录对端地址,而不是继续扩容。

错误四:write 返回 0 后 clear() 缓冲区。

// 反例:没写出去的字节被清掉
int n = ch.write(buf);
if (n == 0) {
    buf.clear();
}

clear() 把 position 归零、limit 设为 capacity,看起来「可以重新写了」,实际上剩余数据已经丢失。返回 0 时应该保留 position 不动,把缓冲区留在队头。

错误五:用 available() 判断帧是否完整。 InputStream.available() 返回的是「不用阻塞就能读到的估计字节数」,不是消息长度,也不保证下一次 read 能拿到同样多。NIO 侧没有 available(),更不应该用 ByteBuffer.remaining() 猜帧边界;边界只由协议的长度域或分隔符决定。

边界:一次 read 的字节数没有协议意义。 它受 MSS、Nagle、接收窗口、内核调度共同影响。测试时不要写「一次 read 应该收到 100 字节」这类断言,应该断言「累积后能拆出哪些帧」。

边界:EOF 时残余的半个帧只能丢弃。 对端在帧中间关闭,read 返回 -1,AccumulatingReader 会先 drainFrames 再关闭,残余字节随缓冲区释放。协议层要把这种截断记录成异常关闭,不能当成正常请求结束。

小结

  • TCP 只提供字节流,一次 read 可能包含半个帧或多个帧,边界由协议定义;累积缓冲始终处于写模式,flip → decode → compact 是拆帧的标准三步。
  • compact() 把半个帧搬到头部,恢复写模式;没有它,缓冲区很快耗尽。
  • 扩容按倍数增长、设 maxBuffered 上限;本机 3002 字节帧把容量从 1024 扩到 4096。
  • WriteQueue 保留未写完的队头,writeTo 返回 false 时继续关注 OP_WRITE,返回 true 时立即摘掉;AccumulatingReader 每连接一个,FrameDecoder 无状态时可以共享。

读和写都补齐了,但连接本身还没有明确的生命周期:什么时候算「正在读」、什么时候算「正在关」、空闲多久该断开。下一节用枚举状态机和定时扫描把这些状态固定下来。

阅读导航:上一节:7.1 非阻塞 accept、read 与 write · 下一节:7.3 连接状态机与空闲检测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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