Netty 4.2.x 源码深度解析 (十八):ChannelOutboundBuffer 写缓冲区 —— 发送队列与流量控制

Channel 的出站数据并不会在 write() 调用时立刻交给操作系统。在 Netty 中,write() 只是把消息压入一条内存中的队列,直到 flush() 时才批量刷出,承担这一待发送数据暂存与调度职责的正是 ChannelOutboundBuffer。它用一条 Entry 单向链表组织所有待发送消息,并用 flushedEntry、unflushedEntry、tailEntry 三个指针精确区分"已刷新待发送"与"已入队未刷新"两类数据。与此同时,它还是 Netty 背压(back-pressure)机制的载体:通过 WriteBufferWaterMark 高低水位与 totalPendingSize 待发送字节数的联动,把"写缓冲是否已满"这一内部状态转化为 channelWritabilityChanged 事件,让上层能够及时停读、避免接收速率长期压垮发送速率。理解这条"入队、刷新、批量写出、清理"的链路,是掌握 Netty 出站数据流与流量控制的关键一环。

围绕 ChannelOutboundBuffer 的"入队 → 刷新 → 转换 → 写出 → 清理"五个环节,以下几个问题将贯穿本文的源码分析主线:

  • ChannelOutboundBuffer 如何用 flushedEntry、unflushedEntry、tailEntry 三个指针把一条 Entry 单向链表串起来,在同一链表里同时表达已刷新与未刷新两类数据?
  • 用户调用 write() 时,消息如何先经 filterOutboundMessage 与 size() 估算,再通过 addMessage 追加到链表尾部,pendingSize 又为何要把每个 Entry 的 96 字节对象开销一并计入待发送统计?
  • flush() 如何通过 addFlush 一次性把 unflushedEntry 之后的所有 Entry 标记为已刷新,setUncancellable 又如何在刷新阶段过滤掉已被取消的写入?
  • nioBuffers 如何把一串已刷新的 ByteBuf 批量转换为 JDK 的 ByteBuffer\[\],其中的 maxCount、maxBytes 上限与 count 缓存如何协同实现跨写调用、分批发送?
  • 高低水位 WriteBufferWaterMark(32KB/64KB)如何驱动 totalPendingSize 与 unwritable 的状态切换,fireChannelWritabilityChanged() 又如何把可写性变化转化为 Pipeline 事件进而实现背压?

本文将沿着 ChannelOutboundBuffer 的调用链路,从 Entry 链表的数据结构入手,依次分析 addMessage 入队、addFlush 刷新、nioBuffers 批量转换、removeBytes 发送清理,最后剖析高低水位流量控制的完整闭环。

一、ChannelOutboundBuffer 数据结构:Entry 单向链表与三指针追踪

1.1 三大指针与可写性字段

ChannelOutboundBuffer 是一个 final 类,仅由 AbstractChannel 内部持有,其类注释明确限定了调用约束:除 isWritable() 与用户自定义可写性相关方法外,其余方法都必须由 transport 实现方在 IO 线程内调用。要理解它的数据结构,关键是抓住一条单向链表与三个指针。

正如字段附近注释所写的那样,整条缓冲区是一条 Entry 单向链表:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#字段

    // 三大指针:一条 Entry 单向链表,同时表达已刷新与未刷新两类数据
    // Entry(flushedEntry) --> ... Entry(unflushedEntry) --> ... Entry(tailEntry)
    private Entry flushedEntry;      // 链表中第一个已刷新的 Entry,发送的起点
    private Entry unflushedEntry;    // 链表中第一个未刷新的 Entry,刷新/发送的分界线
    private Entry tailEntry;         // 链表尾节点,新 Entry 追加于此
    private int flushed;             // 已刷新但尚未写出的 Entry 数量

    private int nioBufferCount;      // 最近一次 nioBuffers() 转换出的数组数量
    private long nioBufferSize;      // 最近一次转换出的字节数

    private boolean inFail;          // failFlushed()/close() 的重入防护开关

    // 待发送字节总数,用 AtomicLongFieldUpdater 原子累加,保证 addMessage/removeBytes 多线程安全
    private static final AtomicLongFieldUpdater<ChannelOutboundBuffer> TOTAL_PENDING_SIZE_UPDATER =
    AtomicLongFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "totalPendingSize");
    private volatile long totalPendingSize;

    // 可写性位掩码:bit0 表示高水位不可写,bit1~31 为用户自定义可写性标志
    private static final AtomicIntegerFieldUpdater<ChannelOutboundBuffer> UNWRITABLE_UPDATER =
    AtomicIntegerFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "unwritable");
    private volatile int unwritable;

三个指针各司其职,共同把一条链切成了两个逻辑区间。flushedEntry 指向的段落是"已刷新、等待写出"的区间,unflushedEntry 到 tailEntry 之间是"已入队、尚未刷新"的区间。unflushedEntry 成了这两个区间的分水岭,addFlush 时正是通过移动它来实现区间切换。

此外还有两个容易被忽略的细节。其一,flushed 是一个计数而非指针,它记录的是已刷新区间的 Entry 个数,isEmpty() 与 size() 都直接基于它判断,避免了遍历链表的 O(n) 开销。其二,unwritable 用了一个 int 位掩码而不是布尔值,bit0 专属于高水位状态,bit1 到 bit31 则留给上层通过 setUserDefinedWritability() 自定义,isWritable() 只要发现任一位为 1 就返回 false。

1.2 Entry 单向链表与池化复用

链表的节点是 ChannelOutboundBuffer.Entry 这个静态内部类,它承载了一条待发送消息的全部元信息:

java 复制代码
io.netty.channel.ChannelOutboundBuffer.Entry

    // 写缓冲区的最小存储单元,通过 Recycler 池化复用
    static final class Entry {
        private static final Recycler<Entry> RECYCLER = new Recycler<Entry>() {
            @Override
            protected Entry newObject(Handle<Entry> handle) {
                return new Entry(handle);
            }
        };

        private final EnhancedHandle<Entry> handle;
        Entry next;              // 链表后继指针
        Object msg;              // 待发送消息
        ByteBuffer[] bufs;       // 复合缓冲:多个 JDK ByteBuffer 的缓存
        ByteBuffer buf;          // 单缓冲:单个 JDK ByteBuffer 的缓存
        ChannelPromise promise;  // 关联的写入结果通知
        long progress;           // 已发送字节数,支持分批续传
        long total;              // 消息总字节数
        int pendingSize;         // 计入 totalPendingSize 的字节(含 Entry 开销)
        int count = -1;          // 可拆出的 nioBuffer 数量,-1 表示惰性计算
        boolean cancelled;       // 是否已被取消

        private Entry(Handle<Entry> handle) {
            this.handle = (EnhancedHandle<Entry>) handle;
        }

        static Entry newInstance(Object msg, int size, long total, ChannelPromise promise) {
            Entry entry = RECYCLER.get();
            entry.msg = msg;
            // 把 Entry 自身的 96 字节对象开销也计入待发送统计
            entry.pendingSize = size + CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD;
            entry.total = total;
            entry.promise = promise;
            return entry;
        }
    }

Entry 的字段可归纳为三类:next/msg/promise 维系链表结构与回调关系;progress/total/pendingSize 负责发送进度与字节统计;buf/bufs/count 则是 nioBuffers 转换阶段的缓存,避免同一消息在多次写出时重复创建 JDK ByteBuffer 对象。

这里面最值得留意的是 pendingSize 的计算方式。newInstance() 里它等于传入的 size(消息本身可发送的字节数)再加上 CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD,这个常量默认是 96 字节,对应一个 Entry 对象自身在 64 位 JVM 上的内存占用量。之所以要把对象开销也算进去,是因为待发送字节统计的最终目的是度量真实内存压力,如果只算消息内容而忽略海量小消息情况下 Entry 对象本身的开销,水位判定就会失真。

Entry 的生命周期管理依赖 Recycler<Entry> 对象池。newInstance() 通过 RECYCLER.get() 取实例,unguardedRecycle() 则负责清空字段并归还,二者构成"取用---归还"的闭环。在高速写场景下,频繁创建消息对象是 GC 的重要压力来源,池化让 Entry 得以反复复用,这是 Netty 在写路径上的一处关键优化。

下面这幅图把链表结构与三个指针的指向关系画了出来:

上面的链表结构清晰地展示了三个指针的边界:flushedEntry 是已刷新区间的起点,unflushedEntry 是未刷新区间的起点,而 tailEntry 总是链表尾。随着 addMessage、addFlush、remove 的交替调用,这三个指针会不断在链上移动,从而在不新建容器的情况下动态维护出两个逻辑区间。

二、消息入队:write() 到 addMessage 的链路

2.1 write() 入口:过滤与大小估算

用户调用 Channel.write() 后,经过 Pipeline 一系列出站 handler,最终会落到 AbstractChannel.AbstractUnsafe.write()。它是消息进入 ChannelOutboundBuffer 前必经的关口:

java 复制代码
io.netty.channel.AbstractChannel.AbstractUnsafe#write

    // 用户 write() 的第一站:先过滤、估算,再入队
    @Override
    public final void write(Object msg, ChannelPromise promise) {
        assertEventLoop();

        ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
        if (outboundBuffer == null) {
            try {
                // Channel 已关闭,立即释放消息防止泄漏
                ReferenceCountUtil.release(msg);
            } finally {
                safeSetFailure(promise,
                newClosedChannelException(initialCloseCause, "write(Object, ChannelPromise)"));
            }
            return;
        }

        int size;
        try {
            // 过滤 transport 层不支持的消息,再估算字节大小
            msg = filterOutboundMessage(msg);
            size = pipeline.estimatorHandle().size(msg);
            if (size < 0) {
                size = 0;
            }
        } catch (Throwable t) {
            try {
                ReferenceCountUtil.release(msg);
            } finally {
                safeSetFailure(promise, t);
            }
            return;
        }

        outboundBuffer.addMessage(msg, size, promise);
    }

这段代码有两个关键点。一是 outboundBuffer == null 的判断:AbstractUnsafe.close() 在关闭时会把 outboundBuffer 置空,因此一旦为 null 就意味着 Channel 已经关闭,此时需要立即 release(msg) 释放消息、safeSetFailure 失败 promise,避免引用计数泄漏。二是 write 并不做任何真正的 IO,它只做三件事:用 filterOutboundMessage 过滤 transport 层不支持的消息类型(例如将堆内存 ByteBuf 转换为直接内存,避免 JDK 的 SocketChannel.write 对堆缓冲再创建临时直接缓冲),用 pipeline.estimatorHandle().size(msg) 估算消息字节大小,然后将消息连同估算值一并交给 addMessage。

这里出现的 MessageSizeEstimator 是 Netty 用于估算消息大小的抽象,它在无法精确判断消息大小时返回一个默认估计值,size < 0 的兜底则是把未知大小当 0 处理。

2.2 addMessage():链表尾部追加

addMessage 是入队的真正执行者:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#addMessage

    // 用户调用 write() 时,将消息包装成 Entry 追加到链表尾部
    public void addMessage(Object msg, int size, ChannelPromise promise) {
        // 从回收池创建 Entry,total(msg) 计算消息可发送的字节数
        Entry entry = Entry.newInstance(msg, size, total(msg), promise);
        if (tailEntry == null) {
            // 空队列:清空已刷新起点(说明此前数据都已发出)
            flushedEntry = null;
        } else {
            // 非空队列:把新 Entry 挂到当前尾节点之后
            Entry tail = tailEntry;
            tail.next = entry;
        }
        tailEntry = entry;
        if (unflushedEntry == null) {
            // 记录第一个未刷新的 Entry
            unflushedEntry = entry;
        }

        // touch 消息便于排查引用计数泄漏
        if (msg instanceof AbstractReferenceCountedByteBuf) {
            ((AbstractReferenceCountedByteBuf) msg).touch();
        } else {
            ReferenceCountUtil.touch(msg);
        }

        // 累加待发送字节,超过高水位时置不可写
        incrementPendingOutboundBytes(entry.pendingSize, false);
    }

入队逻辑遵循最朴素的尾插法:用 tailEntry.next = entry 完成链接,再把 tailEntry 自身指向新节点。有两个分支值得注意:当 tailEntry == null(即队列为空)时,代码会把 flushedEntry 置为 null,这是因为空队列意味着此前的数据已全部写出,旧的 flushedEntry 已经失效;当 unflushedEntry == null 时,说明这是当前未刷新区间的第一个 Entry,需要把 unflushedEntry 指向它。

消息入队后还会调用一次 touch。这是专门的泄漏排查机制:touch 会把调用栈信息记录到消息对象中,一旦消息因引用计数未归零而泄漏,泄漏报告里就能定位到是哪一个 write 调用留下的。

最后是 total(msg),它负责算出消息"可发送的字节数",为 Entry.total 与 progress 记录基准:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#total

    // 按消息类型计算"可发送字节数",无法估算的类型返回 -1
    private static long total(Object msg) {
        if (msg instanceof ByteBuf) {
            return ((ByteBuf) msg).readableBytes();
        }
        if (msg instanceof FileRegion) {
            return ((FileRegion) msg).count();
        }
        if (msg instanceof ByteBufHolder) {
            return ((ByteBufHolder) msg).content().readableBytes();
        }
        return -1;
    }

可以看到它针对三种类型分别取字节数:ByteBuf 取可读字节,FileRegion(用于零拷贝文件传输)取文件长度,ByteBufHolder(如 HTTP 消息体)取其内容可读字节。其余类型一律返回 -1 表示无法精确估算。

上面的入队链路可以用下面这幅时序图完整呈现:

上面的时序图清晰地展示了从 write() 到消息真正挂上链表的完整链路:AbstractUnsafe.write() 先做前置处理,随后 addMessage 创建 Entry、执行尾插、touch 并累加待发送字节,若累加后越过 highWaterMark 则触发 setUnwritable。值得注意的是,此时并没有发生任何真正的网络写,消息只是安静地躺在链表里。

三、标记刷新:flush() 到 addFlush 的语义切换

3.1 flush() 与 flush0()

write() 只入队、不落盘,这是 Netty 出站路径上最重要的语义设计之一。它把"写数据"与"真正发出数据"两个动作解耦,让上层可以在一次 flush() 之前连续调用多次 write(),从而将多条消息合并成一次系统调用批量刷出,显著减少了 writev/send 这类系统调用的次数。

flush() 的入口同样在 AbstractUnsafe 中:

java 复制代码
io.netty.channel.AbstractChannel.AbstractUnsafe#flush

    // 用户 flush() 的第一站:先标记刷新,再真正执行写出
    @Override
    public final void flush() {
        assertEventLoop();

        ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
        if (outboundBuffer == null) {
            return;
        }

        outboundBuffer.addFlush();
        flush0();
    }

flush() 只做两件事:先调用 addFlush 把未刷新区间整体切换为已刷新,再调用 flush0 触发真正的写出。outboundBuffer == null 时直接返回,因为 Channel 已关闭的场景下没有需要刷新的数据。

flush0 是写出前最后的把关:

java 复制代码
io.netty.channel.AbstractChannel.AbstractUnsafe#flush0

    // 处理完所有前置检查后,把已刷新数据交给 transport 层循环写出
    protected void flush0() {
        if (inFlush0) {
            // 避免重入:同一线程内有 write/flush 相互回调时不重复写出
            return;
        }

        final ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
        if (outboundBuffer == null || outboundBuffer.isEmpty()) {
            return;
        }

        inFlush0 = true;

        // Channel 未激活时,所有待发送消息直接标记失败
        if (!isActive()) {
            try {
                if (!outboundBuffer.isEmpty()) {
                    if (isOpen()) {
                        outboundBuffer.failFlushed(new NotYetConnectedException(), true);
                    } else {
                        // Channel 已关闭,不再触发 channelWritabilityChanged
                        outboundBuffer.failFlushed(newClosedChannelException(initialCloseCause, "flush0()"), false);
                    }
                }
            } finally {
                inFlush0 = false;
            }
            return;
        }

        try {
            doWrite(outboundBuffer);
        } catch (Throwable t) {
            handleWriteError(t);
        } finally {
            inFlush0 = false;
        }
    }

flush0 有三个职责:一是用 inFlush0 布尔标记做重入保护,防止某个 write() 的回调间接再次触发 flush() 造成无限递归;二是对未激活的 Channel 直接把已刷新消息 failFlushed 快速失败;三是真正调用 doWrite(outboundBuffer) 把数据交给 transport 层。doWrite 属于底层 IO 下沉的范畴,本文点到为止,我们接下来把重点放回 addFlush。

3.2 addFlush():整体切换已刷新区

addFlush 是本节的核心,它完成了"未刷新"到"已刷新"的区间切换:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#addFlush

    // 用户调用 flush() 时,把未刷新区整体切到已刷新区
    public void addFlush() {
        Entry entry = unflushedEntry;
        if (entry != null) {
            if (flushedEntry == null) {
                // 此前没有已刷新节点,本次作为新的发送起点
                flushedEntry = entry;
            }
            do {
                flushed++;
                if (!entry.promise.setUncancellable()) {
                    // promise 已被取消:释放消息并回收统计
                    int pending = entry.cancel();
                    decrementPendingOutboundBytes(pending, false, true);
                }
                entry = entry.next;
            } while (entry != null);

            // 全部已刷新,清空未刷新起点
            unflushedEntry = null;
        }
    }

addFlush 从 unflushedEntry 出发向后遍历。如果此前 flushedEntry == null,说明这是本轮的第一批已刷新数据,需要把 flushedEntry 指向当前节点作为新的发送起点;否则说明链表头部还有上一轮未写完的数据,flushedEntry 保持不变,两批数据在链上自然衔接成一段连续的已刷新区间。

遍历过程中每走一步 flushed++ 维护计数,同时调用 promise.setUncancellable()。这个方法返回 false 意味着 promise 已被用户取消(例如写操作超时被主动取消),此时数据不能再写出,需要通过 entry.cancel() 释放消息并 decrementPendingOutboundBytes 回收字节统计:

java 复制代码
io.netty.channel.ChannelOutboundBuffer.Entry#cancel

    // 取消该 Entry:释放消息,用空缓冲占位,返回回收的字节数
    int cancel() {
        if (!cancelled) {
            cancelled = true;
            int pSize = pendingSize;
            // 释放消息并替换为空缓冲,避免后续写出时用到已释放的资源
            ReferenceCountUtil.safeRelease(msg);
            msg = Unpooled.EMPTY_BUFFER;
            pendingSize = 0;
            total = 0;
            progress = 0;
            bufs = null;
            buf = null;
            return pSize;
        }
        return 0;
    }

cancel() 的巧妙之处在于它并不把 Entry 从链表中摘除,而是把 msg 替换成 Unpooled.EMPTY_BUFFER 这个空缓冲并置 cancelled = true。这样做的原因在于,链表的结构调整在 addFlush 这种高频路径上代价不菲,而一个"已取消"的占位节点在后续 nioBuffers 遍历时会因 cancelled 标记被直接跳过,语义上等同移除,却避开了指针的现场维护。这也是 Netty 用"惰性"换取性能的一个典型例子。

从 write() 到 flush() 的语义切换,可以概括为下面这幅时序图:

上面的时序图清晰地展示了刷新语义切换的完整过程:flush() 先经 addFlush 把 unflushedEntry 之后的整段链表并入已刷新区,同时用 setUncancellable 过滤已取消的消息,最后交给 flush0 决定是快速失败还是真正写出。

四、批量写出:nioBuffers() 的 ByteBuffer 转换

4.1 遍历转换与溢出保护

flush0 最终会调用 doWrite(outboundBuffer),而 transport 层的 doWrite 要做的第一件事,就是从 ChannelOutboundBuffer 里取出一批可以直接交给操作系统写的数据。这就是 nioBuffers 的职责:把链表中连续的、类型为 ByteBuf 的消息,批量转换为 JDK 的 ByteBuffer[],供 NIO 的 SocketChannel.write(ByteBuffer[]) 一次性 gather-write。

java 复制代码
io.netty.channel.ChannelOutboundBuffer#nioBuffers

    // 把已刷新的 ByteBuf 批量转换为 JDK ByteBuffer[],供 transport 用 writev 写出
    public ByteBuffer[] nioBuffers(int maxCount, long maxBytes) {
        assert maxCount > 0;
        assert maxBytes > 0;
        long nioBufferSize = 0;
        int nioBufferCount = 0;
        final InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
        ByteBuffer[] nioBuffers = NIO_BUFFERS.get(threadLocalMap);
        Entry entry = flushedEntry;
        while (isFlushedEntry(entry) && entry.msg instanceof ByteBuf) {
            if (!entry.cancelled) {
                ByteBuf buf = (ByteBuf) entry.msg;
                final int readerIndex = buf.readerIndex();
                final int readableBytes = buf.writerIndex() - readerIndex;

                if (readableBytes > 0) {
                    // maxBytes 溢出保护:避免单次 writev 超过 Integer.MAX_VALUE 触发 EINVAL
                    if (maxBytes - readableBytes < nioBufferSize && nioBufferCount != 0) {
                        break;
                    }
                    nioBufferSize += readableBytes;
                    int count = entry.count;
                    if (count == -1) {
                        // 惰性计算该消息可拆出的 buffer 数并缓存
                        entry.count = count = buf.nioBufferCount();
                    }
                    int neededSpace = min(maxCount, nioBufferCount + count);
                    if (neededSpace > nioBuffers.length) {
                        // 数组不够大,两倍扩容
                        nioBuffers = expandNioBufferArray(nioBuffers, neededSpace, nioBufferCount);
                        NIO_BUFFERS.set(threadLocalMap, nioBuffers);
                    }
                    if (count == 1) {
                        ByteBuffer nioBuf = entry.buf;
                        if (nioBuf == null) {
                            // 缓存单缓冲,避免重复创建 ByteBuffer 实例
                            entry.buf = nioBuf = buf.internalNioBuffer(readerIndex, readableBytes);
                        }
                        nioBuffers[nioBufferCount++] = nioBuf;
                    } else {
                        // 复合缓冲分支单独成方法,避免主方法过大导致无法内联
                        nioBufferCount = nioBuffers(entry, buf, nioBuffers, nioBufferCount, maxCount);
                    }
                    if (nioBufferCount >= maxCount) {
                        break;
                    }
                }
            }
            entry = entry.next;
        }
        this.nioBufferCount = nioBufferCount;
        this.nioBufferSize = nioBufferSize;

        return nioBuffers;
    }

遍历的终止条件很讲究:isFlushedEntry(entry) 判定 entry 非空且尚未越过 unflushedEntry,entry.msg instanceof ByteBuf 则保证返回的数组里只有 ByteBuf,一旦遇到 FileRegion 这类不支持 gather-write 的消息就立即停止,把非 ByteBuf 的消息留给 doWrite 走单条写路径处理。

maxBytes 的溢出保护是这段代码的隐藏要点。BSD 与 macOS 不允许单次 writev 写入超过 Integer.MAX_VALUE 字节,超限会返回 EINVAL 并抛 IOException;即便在 Linux 上也可能因架构不同而行为不一。因此代码在累加 nioBufferSize 前先判断 maxBytes - readableBytes < nioBufferSize,一旦超过就跳出循环,宁可少带一批也要保证一次系统调用合法。

4.2 数组复用与两倍扩容

返回的 ByteBuffer[] 并不新鲜,它来自 NIO_BUFFERS 这个 FastThreadLocal:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#NIO_BUFFERS

    // 线程本地复用的 ByteBuffer 数组,避免每次写出都新建数组
    private static final FastThreadLocal<ByteBuffer[]> NIO_BUFFERS = new FastThreadLocal<ByteBuffer[]>() {
        @Override
        protected ByteBuffer[] initialValue() throws Exception {
            return new ByteBuffer[1024];
        }
    };

借助 FastThreadLocal,每个 IO 线程都持有自己的一份 ByteBuffer[1024] 数组,nioBuffers 反复复用同一块缓冲区而不必每次分配。这正是类注释反复强调"返回的数组被复用、绝不能逃逸出 doWrite 调用"的原因,数组的生命周期由线程本地管理,一旦被上层缓存引用,下次写出时内容就会被覆盖。与之配套的是两个记录本次结果的方法 nioBufferCount() 与 nioBufferSize(),它们必须在 nioBuffers 之后立即调用才有意义。

当单批转换出的 buffer 数量超过当前数组长度时,expandNioBufferArray 会按两倍扩容并迁移旧数据:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#expandNioBufferArray

    // 数组容量不足时翻倍扩容,采用 System.arraycopy 迁移旧元素
    private static ByteBuffer[] expandNioBufferArray(ByteBuffer[] array, int neededSpace, int size) {
        int newCapacity = array.length;
        do {
            // 翻倍直到容量满足需求,参见 Netty#1890
            newCapacity <<= 1;
            if (newCapacity < 0) {
                throw new IllegalStateException();
            }
        } while (neededSpace > newCapacity);

        ByteBuffer[] newArray = new ByteBuffer[newCapacity];
        System.arraycopy(array, 0, newArray, 0, size);
        return newArray;
    }

这里还有一处缓存设计值得一提:count == 1 时用 entry.buf 缓存单个 JDK ByteBuffer,count > 1 时用 entry.bufs 缓存 ByteBuffer[],配合 count == -1 的惰性计算,保证同一消息在多轮部分写出的场景下不会重复调用 internalNioBuffer() 与 nioBuffers 去创建对象。

整个转换流程可以概括为下面的时序图:

上面的时序图清晰地展示了批量转换的完整过程:doWrite 拿到线程本地的复用数组后,nioBuffers 从 flushedEntry 遍历已刷新 Entry,逐个把 ByteBuf 切成 JDK ByteBuffer,途中受 maxCount、maxBytes 双重约束,并在容量不足时两倍扩容,最终返回可直接写出的数组。

五、发送确认与清理:remove/removeBytes 的进度管理

5.1 remove() 与三指针连锁调整

doWrite 完成一次系统调用后,需要根据实际写出的字节数把已完成的 Entry 从链表中移除,并通知对应的 promise。这个职责由 remove、remove(Throwable) 与 removeBytes 三个方法分担。

remove 处理"整条消息已完全写出"的场景,既通知成功也负责释放:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#remove

    // 移除当前已刷新的消息,通知成功并归还回收池
    public boolean remove() {
        Entry e = flushedEntry;
        if (e == null) {
            clearNioBuffers();
            return false;
        }
        Object msg = e.msg;
        ChannelPromise promise = e.promise;
        int size = e.pendingSize;

        removeEntry(e);

        // 消息未被取消才释放、通知、扣减统计
        if (!e.cancelled) {
            if (msg instanceof AbstractReferenceCountedByteBuf) {
                try {
                    ((AbstractReferenceCountedByteBuf) msg).release();
                } catch (Throwable t) {
                    logger.warn("Failed to release a ByteBuf: {}", msg, t);
                }
            } else {
                ReferenceCountUtil.safeRelease(msg);
            }
            safeSuccess(promise);
            decrementPendingOutboundBytes(size, false, true);
        }

        // 归还回收池
        e.unguardedRecycle();
        return true;
    }

正确路径下,remove 依次做三件事:removeEntry(e) 调整链表指针,release + safeSuccess 释放消息并通知 promise 成功,decrementPendingOutboundBytes 扣减待发送字节统计。最后 unguardedRecycle() 把 Entry 归还对象池。失败路径则由 remove(Throwable cause) 承担,逻辑几乎一致,只是把 safeSuccess 换成 safeFail(promise, cause),用于 Channel 关闭或写出异常时批量清场。

链表指针的连锁调整封装在 removeEntry 里:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#removeEntry

    // 移除链首 Entry 并连锁调整三指针
    private void removeEntry(Entry e) {
        if (--flushed == 0) {
            // 已处理完所有刷新消息,清空整条链表
            flushedEntry = null;
            if (e == tailEntry) {
                tailEntry = null;
                unflushedEntry = null;
            }
        } else {
            // 还有待写消息,起点后移一格
            flushedEntry = e.next;
        }
    }

removeEntry 先把 flushed 计数减一。若减到 0,说明已刷新区间已被全部写出,此时把 flushedEntry 置 null,并且当被移除的 Entry 恰好也是 tailEntry 时,把 tailEntry 与 unflushedEntry 一并置 null,即整条链表彻底清空;否则,说明后面还有待写节点,只需把 flushedEntry 后移到 e.next 即可。

5.2 removeBytes() 与部分写出续传

真实网络写很少一次写完整批数据。SocketChannel.write(ByteBuffer[]) 可能只写出部分字节便返回,这时就需要 removeBytes 来精确推进:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#removeBytes

    // 发送后按实际写出字节推进 readerIndex,支持部分写出续传
    public void removeBytes(long writtenBytes) {
        for (;;) {
            Object msg = current();
            if (!(msg instanceof ByteBuf)) {
                assert writtenBytes == 0;
                break;
            }

            final ByteBuf buf = (ByteBuf) msg;
            final int readerIndex = buf.readerIndex();
            final int readableBytes = buf.writerIndex() - readerIndex;

            if (readableBytes <= writtenBytes) {
                // 该消息已全部写出,推进进度后移除
                if (writtenBytes != 0) {
                    progress(readableBytes);
                    writtenBytes -= readableBytes;
                }
                remove();
            } else { // readableBytes > writtenBytes
                // 只写出了一部分,前进 readerIndex 记为续传位置
                if (writtenBytes != 0) {
                    buf.readerIndex(readerIndex + (int) writtenBytes);
                    progress(writtenBytes);
                }
                break;
            }
        }
        clearNioBuffers();
    }

removeBytes 的核心是一个贪心循环:对每个链首消息,比较它的 readableBytes 与剩余 writtenBytes。若 readableBytes <= writtenBytes,说明这条消息已完全写出,先 progress(readableBytes) 记录进度,再扣减剩余字节并 remove 移除;若 readableBytes > writtenBytes,说明只写出了一部分,把 readerIndex 前进 writtenBytes 字节作为下次续传的起点,然后 break 结束本轮。最后 clearNioBuffers() 会把线程本地数组里已填充的元素清空,从而释放对 JDK ByteBuffer 的强引用,让它能被 GC 回收。

对于支持进度的 promise,progress 会在写出过程中持续通知:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#progress

    // 推进当前消息的发送进度,并向进度型 promise 传播
    public void progress(long amount) {
        Entry e = flushedEntry;
        assert e != null;
        ChannelPromise p = e.promise;
        long progress = e.progress + amount;
        e.progress = progress;
        assert p != null;
        final Class<?> promiseClass = p.getClass();
        // fast-path:跳过 O(n) 的类型判断,避免热点路径上的类型污染
        if (promiseClass == VoidChannelPromise.class || promiseClass == DefaultChannelPromise.class) {
            return;
        }
        if (p instanceof DefaultChannelProgressivePromise) {
            ((DefaultChannelProgressivePromise) p).tryProgress(progress, e.total);
        } else if (p instanceof ChannelProgressivePromise) {
            ((ChannelProgressivePromise) p).tryProgress(progress, e.total);
        }
    }

这段代码先做一次 promiseClass 的 fast-path 判断,跳过绝大多数普通 promise,再对 ChannelProgressivePromise 调用 tryProgress(progress, total) 传播进度。之所以把 VoidChannelPromise 和 DefaultChannelPromise 单列出来做类相等判断,是为了减少 instanceof 在热点路径上的类型污染开销。

下面的时序图概括了发送确认与清理的完整流程:

上面的时序图清晰地展示了发送完成后的清理过程:removeBytes 按实际写出字节数推进 readerIndex,完全写出的消息走 remove 释放并通知 promise,部分写出的消息只前进读取位置等待下轮续传,最后 clearNioBuffers() 释放数组引用。

六、高低水位流量控制:totalPendingSize 与可写性判定

6.1 WriteBufferWaterMark 的滞回设计

前文多次出现的 incrementPendingOutboundBytes 与 decrementPendingOutboundBytes,正是流量控制的抓手。它们的核心是维护 totalPendingSize 这一全局待发送字节计数,并与高低水位比较:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#incrementPendingOutboundBytes / #decrementPendingOutboundBytes

    // 累加待发送字节,超过高水位时置不可写
    private void incrementPendingOutboundBytes(long size, boolean invokeLater) {
        if (size == 0) {
            return;
        }
        long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, size);
        if (newWriteBufferSize > channel.config().getWriteBufferHighWaterMark()) {
            setUnwritable(invokeLater);
        }
    }

    // 扣减待发送字节,跌破低水位时恢复可写
    private void decrementPendingOutboundBytes(long size, boolean invokeLater, boolean notifyWritability) {
        if (size == 0) {
            return;
        }
        long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, -size);
        if (notifyWritability && newWriteBufferSize < channel.config().getWriteBufferLowWaterMark()) {
            setWritable(invokeLater);
        }
    }

这里的高低水位由 WriteBufferWaterMark 定义:

java 复制代码
io.netty.channel.WriteBufferWaterMark

    // 高低水位标记:默认低水位 32KB、高水位 64KB
    public final class WriteBufferWaterMark {
        private static final int DEFAULT_LOW_WATER_MARK = 32 * 1024;
        private static final int DEFAULT_HIGH_WATER_MARK = 64 * 1024;

        public static final WriteBufferWaterMark DEFAULT =
        new WriteBufferWaterMark(DEFAULT_LOW_WATER_MARK, DEFAULT_HIGH_WATER_MARK, false);

        private final int low;
        private final int high;

        public WriteBufferWaterMark(int low, int high) {
            this(low, high, true);
        }

        // 校验高低水位合法性,high 不能小于 low
        WriteBufferWaterMark(int low, int high, boolean validate) {
            if (validate) {
                checkPositiveOrZero(low, "low");
                if (high < low) {
                    throw new IllegalArgumentException(
                    "write buffer's high water mark cannot be less than " +
                    " low water mark (" + low + "): " + high);
                }
            }
            this.low = low;
            this.high = high;
        }
    }

所谓滞回(hysteresis) ,是指变不可写与恢复可写用的是两个不同的阈值:待发送字节超过 high(64KB)才 setUnwritable,而恢复可写要等到字节跌破 low(32KB)才 setWritable。这两个阈值之间隔着一个 32KB 的缓冲带,字节数在这个区间内来回波动时不会触发任何状态切换,从而避免了在单一阈值附近反复横跳导致的"可写---不可写"抖动。

这两个阈值都来自 WriteBufferWaterMark.DEFAULT,它通过 DefaultChannelConfig 的 writeBufferWaterMark 字段设到每个 Channel 上,getWriteBufferHighWaterMark() 与 getWriteBufferLowWaterMark() 毫厘不差地返回 high() 与 low()。用户也可以通过 ChannelConfig.setWriteBufferWaterMark() 自定义阈值,构造器的 high < low 校验则在事前拦截了非法配置。

6.2 可写性切换与事件传播

状态切换最终落到 unwritable 这个 int 位掩码上,由 setUnwritable 与 setWritable 两个 CAS 方法完成:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#setWritable / #setUnwritable

    // 重新变可写:清除 bit0
    private void setWritable(boolean invokeLater) {
        for (;;) {
            final int oldValue = unwritable;
            final int newValue = oldValue & ~1;
            if (UNWRITABLE_UPDATER.compareAndSet(this, oldValue, newValue)) {
                // 仅当从"完全不可写"跨越到"完全可写"时才触发事件
                if (oldValue != 0 && newValue == 0) {
                    fireChannelWritabilityChanged(invokeLater);
                }
                break;
            }
        }
    }

    // 变不可写:置位 bit0
    private void setUnwritable(boolean invokeLater) {
        for (;;) {
            final int oldValue = unwritable;
            final int newValue = oldValue | 1;
            if (UNWRITABLE_UPDATER.compareAndSet(this, oldValue, newValue)) {
                // 仅当从"可写"跨越到"不可写"的边沿才触发事件
                if (oldValue == 0) {
                    fireChannelWritabilityChanged(invokeLater);
                }
                break;
            }
        }
    }

两个方法都用 CAS 自旋保证并发安全,但真正精妙的是事件触发条件。setUnwritable 只在 oldValue == 0(从完全可写跨越到不可写的边沿)时触发;setWritable 只在 oldValue != 0 && newValue == 0(从不可写完全回到可写)时触发。这既与前述滞回设计呼应,也避免了每次字节增减都重复派发事件。

事件的分发由 fireChannelWritabilityChanged 完成:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#fireChannelWritabilityChanged

    // 把可写性变化转化为 Pipeline 事件,供上层实现背压
    private void fireChannelWritabilityChanged(boolean invokeLater) {
        final ChannelPipeline pipeline = channel.pipeline();
        if (invokeLater) {
            // 异步投递:包装成任务交给 EventLoop,避免在调用栈内重入
            Runnable task = fireChannelWritabilityChangedTask;
            if (task == null) {
                fireChannelWritabilityChangedTask = task = new Runnable() {
                    @Override
                    public void run() {
                        pipeline.fireChannelWritabilityChanged();
                    }
                };
            }
            channel.eventLoop().execute(task);
        } else {
            pipeline.fireChannelWritabilityChanged();
        }
    }

invokeLater 为 true 时,事件不会在调用栈内同步派发,而是包装成 Runnable 交给 EventLoop 异步执行,并且这个任务被缓存到 fireChannelWritabilityChangedTask 字段复用。之所以需要异步投递,是因为 incrementPendingOutboundBytes(size)、decrementPendingOutboundBytes(size) 这两个公开的包级方法标注了线程安全,可能被 EventLoop 之外的线程调用(例如上层 handler 借助 DefaultChannelPipeline 的钩子追踪待发送数据),此时 Pipeline 事件必须投递回 EventLoop 线程执行。

可写性状态最终通过 isWritable()、bytesBeforeUnwritable()、bytesBeforeWritable() 三个方法暴露给上层:

java 复制代码
io.netty.channel.ChannelOutboundBuffer#isWritable / #bytesBeforeUnwritable / #bytesBeforeWritable

    // 只有位掩码全 0 才是可写
    public boolean isWritable() {
        return unwritable == 0;
    }

    // 还能写多少字节才会变成不可写(+1 是因为只有越过阈值才切换)
    public long bytesBeforeUnwritable() {
        long bytes = channel.config().getWriteBufferHighWaterMark() - totalPendingSize + 1;
        return bytes > 0 && isWritable() ? bytes : 0;
    }

    // 还需排空多少字节才能恢复可写
    public long bytesBeforeWritable() {
        long bytes = totalPendingSize - channel.config().getWriteBufferLowWaterMark() + 1;
        return bytes <= 0 || isWritable() ? 0 : bytes;
    }

两个 bytesBefore* 方法都做了 +1 修正,这与"越过阈值才切换"的语义严格对应,当 totalPendingSize 恰好等于 high 时其实仍可写,还需要再多 1 字节才会越过阈值。上层据此可以预先感知水位逼近情况,在真正不可写之前平滑地降低写入速率。

把待发送字节数与水位、可写性的关系画出来,是这样一幅图:

上面的水位图清晰地展示了滞回判定的两条阈值线:字节数向上越过 64KB 的 high 才变不可写,向下跌破 32KB 的 low 才恢复可写,中间 32KB 的缓冲带杜绝了状态抖动。

需要特别说明的是背压的落地方式。fireChannelWritabilityChanged 只是把一个事件沿 Pipeline 传播出去,Netty 内核并不内置 "自动停读"。上层需要在自己的 handler 中覆写 channelWritabilityChanged,当发现 Channel 不可写时调用 channel.config().setAutoRead(false) 取消 OP_READ 关注、暂停读入,待写出数据排空、Channel 恢复可写后再 setAutoRead(true) 恢复。这是一种把控制权交给上层的设计,是否停读、停读的粒度、何时恢复,都由业务自主决定。

七、整体链路串联

把前六节的内容串联起来,ChannelOutboundBuffer 的完整闭环是这样一条链路。用户 write() 时,消息经 filterOutboundMessage 与 size() 估算后由 addMessage 挂到 tailEntry;用户 flush() 时,addFlush 把 unflushedEntry 切到已刷新区,flush0 触发 doWrite;doWrite 调用 nioBuffers 获取 ByteBuffer[] 批量写出,再按实际写出字节数调用 removeBytes 推进 readerIndex、传播 progress 并 remove 清理已发完的 Entry。

这条主链路之上还并行着一条"水位流":totalPendingSize 随 addMessage 累加、随 removeBytes 扣减,与 WriteBufferWaterMark 的 high/low 比较后驱动 unwritable 位掩码切换,最终经 fireChannelWritabilityChanged 转化为 Pipeline 事件,向上层传递背压信号。把这七个环节与它们操作的关键字段整理成下表:

环节 入口方法 操作的三指针/字段 关键副作用
入队 addMessage tailEntry / unflushedEntry pendingSize 累加 totalPendingSize
刷新 addFlush flushedEntry / unflushedEntry flushed++,setUncancellable 过滤取消
转换 nioBuffers flushedEntry 遍历 写 nioBufferCount / nioBufferSize
写出 doWrite(transport) ByteBuffer\[\] 系统调用 writev
清理 removeBytes / remove flushedEntry 后移 release + promise 通知 + 回收统计
背压 increment / decrement totalPendingSize / unwritable 触发 channelWritabilityChanged

纵观全貌,ChannelOutboundBuffer 的设计思想可以凝练为 "三指针单向链表 + 延迟刷新 + 滞回水位" 。flushedEntry、unflushedEntry、tailEntry 用一条链同时串起已刷新与未刷新数据,write 只入队、flush 才刷新,把多次写合并成一次批量提交;WriteBufferWaterMark 的 high/low 滞回判定则在杜绝状态抖动的同时,把内部写压力转化为上层可感知的 channelWritabilityChanged 事件,让背压机制得以优雅落地。

全文小结

本文聚焦 Netty 的写缓冲区 ChannelOutboundBuffer,从数据结构设计与高低水位流量控制两个维度,深入分析了 Entry 单向链表的组织方式、write/flush 的入队与刷新语义、nioBuffers 的批量转换以及 removeBytes 的发送清理。

在数据结构层面,ChannelOutboundBuffer 用 flushedEntry、unflushedEntry、tailEntry 三个指针把 Entry 单向链表切分为已刷新区与未刷新区,Entry 通过 Recycler 池化复用并把 96 字节对象开销计入 pendingSize。在入队与刷新层面,write() 只经 filterOutboundMessage 与 size() 估算后 addMessage 追加到链表尾,flush() 才经 addFlush 把 unflushedEntry 整体切到已刷新区并触发 flush0,setUncancellable 在刷新阶段过滤被取消的写入。在批量写出层面,nioBuffers 从 flushedEntry 遍历已刷新的 ByteBuf,用 maxCount/maxBytes 控制分批规模、用 count 惰性缓存与 entry.buf/entry.bufs 缓存避免重复创建,返回的数组由 FastThreadLocal 复用、严禁逃逸出 doWrite。在发送清理层面,removeBytes 按实际写出字节推进 readerIndex,完全写出的 Entry 通过 remove/removeEntry 释放消息、通知 promise 并归还回收池,未写出的部分由 progress 保留续传位置。在流量控制层面,totalPendingSize 随 incrementPendingOutboundBytes/decrementPendingOutboundBytes 增减,与 WriteBufferWaterMark 的 high(64KB)/low(32KB)比较后经 setUnwritable/setWritable CAS 切换 unwritable 位,并触发 fireChannelWritabilityChanged 把可写性变化传播为 Pipeline 事件,供上层据此 setAutoRead(false) 实现背压。


原创不易,如果本文对您有帮助,带来了些许灵感或启发,烦请动动小手点赞、关注、转发、收藏。这是作者持续更新的动力源泉,衷心感谢您的支持。我会尽量在工作之余,为大家带来更高品质的内容,努力保持周更。

相关推荐
谢亮_vipxieliang1 小时前
Go gRPC基础与实战
开发语言·后端·golang
god_cvz1 小时前
第二章:5、RAG进阶优化
后端·ai编程
我是人✓1 小时前
Java核心术语与内存机制深度解析
java
xuxigifxfh1 小时前
面试记录2026
java·mysql·面试·职场和发展
liangbo71 小时前
04-List 到底该用哪个?ArrayList、LinkedList、Vector、CopyOnWriteArrayList 选型实战
java
Wang's Blog1 小时前
Java 中间件之 RabbitMQ 快速入门: SpringAMQP WorkQueue 工作队列模型
java·中间件·java-rabbitmq
今年下半年1 小时前
【低成本埋点统计】Spring Boot+vue2前后端分离项目统计系统访问、使用、登录次数
spring boot·后端·状态模式
代码山河1 小时前
第一个Java程序:Hello World从零编写与运行
java·学习·架构·教程·面向对象·项目
codigger1 小时前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
java·后端·编程·编程语言