SPARROW ZOO · Netty 内存

Netty 内存共享与释放机制

结合 WebSocketFrameHandler 案例,梳理引用计数模型、框架的自动释放点、 retainedDuplicate 零拷贝共享与 newFrame 深拷贝的区别,以及一处 「分配后未写入也未释放」导致的直接内存泄漏。

目录 CONTENTS
  1. 结论先行:案例与核心结论
  2. 问题背景:引用计数与内存共享
  3. 释放机制:自动释放点与手动释放
  4. 内存共享:零拷贝 vs 深拷贝
  5. 案例逐行分析:泄漏点定位
  6. 泄露监控:ResourceLeakDetector
  7. 总结归纳

01结论先行:案例与核心结论

这个案例(WebSocketFrameHandler)完整展示了 Netty 的两大内存机制——内存共享(零拷贝) 与 释放机制(引用计数),以及一个典型的释放 bug。

在向多个接收方转发一条消息时,代码先调用 newFrame(msg) 深拷贝出一帧新消息 (分配了一块新的直接内存,refCnt = 1),紧接着却用 retainedDuplicate(msg) 把变量覆盖掉。于是那块刚分配的新 buffer 既没写入、也没释放——每转发一条消息、 每个接收方就泄漏一块直接内存。

⛔一句话结论

「谁创建、谁释放;谁最后使用、谁释放」。这里 newFrame 创建了 buffer 却没有把它写出去,也没有 release,等于创建后立刻丢弃 → 直接内存泄漏。 泄漏量 = 消息数 × 接收方数。

顺带梳理出 Netty 完整的释放责任链,方便对照自查:

释放点触发时机谁负责
SimpleChannelInboundHandler channelRead0 返回后 框架自动 release 入站消息
TailContext 入站消息走到 pipeline 尾部仍未被处理 框架自动 release(丢弃未处理消息)
HeadContext 写方向 flush 完成后 框架自动 release 出站 buffer
业务代码 手动调用 ReferenceCountUtil.release / safeRelease

02问题背景:引用计数与内存共享

Netty 高性能的两大支柱是零拷贝与池化内存,两者都建立在同一个机制上: 引用计数(Reference Counting)。

ByteBuf 以及包装它的 ByteBufHolder(如 WebSocketFrame)都实现了 ReferenceCounted 接口:retain() 让计数 +1,release() 让计数 -1, 计数归零时才触发真正的释放。

「内存共享」意味着多个使用者共享同一块底层内存(零拷贝,不复制),代价是每个使用者都必须负责一次 release——这就是「谁最后使用谁释放」的由来。反之「深拷贝」(如 newFrame) 各自持有独立内存、互不干扰,但要额外分配和复制。

🧭一句话理解

共享 = 省内存但要管好计数;拷贝 = 多占内存但各管各的。 混用(既拷贝又共享,还不释放)就是本案例踩的坑。

03释放机制:自动释放点与手动释放

3.1 引用计数模型与释放流程

一次 release() 把计数减到 0 后,底层内存的归宿取决于分配方式:

而 Chunk 本身也有生命周期:释放时根据使用率把 Chunk 移动到合适的 PoolChunkList,使用率低于阈值时销毁整个 Chunk,把内存还给操作系统。

3.2 读方向:TailContext 兜底释放

入站消息沿 Head → Tail 依次经过 ChannelInboundHandler。如果消息一直没人处理、走到 pipeline 尾部,TailContext 会兜底释放它:

final class TailContext extends AbstractChannelHandlerContext implements ChannelInboundHandler {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        onUnhandledInboundMessage(msg);
    }
}

protected void onUnhandledInboundMessage(Object msg) {
    try {
        logger.debug("Discarded inbound message {} that reached at the tail of the pipeline.", msg);
    } finally {
        ReferenceCountUtil.release(msg);   // 未处理消息在此释放
    }
}

3.3 写方向:HeadContext flush 后释放

出站消息沿 Tail → Head 依次经过 ChannelOutboundHandler。写请求最终到达 HeadContext,真正把数据刷到 socket 后释放 buffer:

private void invokeWriteAndFlush(Object msg, ChannelPromise promise) {
    if (invokeHandler()) {
        invokeWrite0(msg, promise);
        // invokeFlush0() 这里释放过程 refCnt → 0
        invokeFlush0();
    }
}

private void invokeFlush0() {
    ((ChannelOutboundHandler) handler()).flush(this);
}

@Override
public void flush(ChannelHandlerContext ctx) throws Exception {
    unsafe.flush();   // unsafe flush 后被释放
}

3.4 SimpleChannelInboundHandler 自动释放

前面的文档已详述:继承 SimpleChannelInboundHandler 后,channelRead0 返回时框架会 在 finally 里自动 release 入站消息(autoRelease = true)。 本案例的 WebSocketFrameHandler 正是这么做的,所以入站帧本身不会泄漏——泄漏点在它内部手动创建的那块新 buffer。

3.5 手动释放:ReferenceCountUtil

public final class ReferenceCountUtil {
    public static boolean release(Object msg, int decrement) {
        if (msg instanceof ReferenceCounted) {
            return ((ReferenceCounted) msg).release(decrement);
        }
        return false;
    }

    public static void safeRelease(Object msg) {
        try {
            release(msg);
        } catch (Throwable t) {
            logger.warn("Failed to release a message: {}", msg, t);
        }
    }
}

ReferenceCounted 对象初始化时引用计数默认是 1——表示「当前持有者」占用一份引用, 用完必须 release 一次把这份引用还掉。

04内存共享:零拷贝 vs 深拷贝

发送一份消息给多个接收方时,有两条路可选,取舍完全不同:

方式是否复制内存引用计数变化适用场景
retain() / retainedDuplicate() 否(共享同一底层内存,零拷贝) refCnt + 1,每个消费者用完各自 release 多个消费者只读同一份数据
newFrame(手动复制) 是(深拷贝,独立内存) 新 buffer refCnt = 1,各管各的 每个消费者需要各自修改内容(如追加时间戳)
// 零拷贝共享:复制的是「视图」,不是内存
private static Object retainedDuplicate(Object message) {
    if (message instanceof ByteBuf) {
        return ((ByteBuf) message).retainedDuplicate();
    } else {
        return message instanceof ByteBufHolder
            ? ((ByteBufHolder) message).retainedDuplicate()   // refCnt + 1
            : ReferenceCountUtil.retain(message);
    }
}
⚠️共享的两面性

共享省内存,但所有消费者看到的是同一份字节。本案例里 newFrame 原本要往消息尾部追加 _时间戳,改用 retainedDuplicate 后共享的是原始内容、时间戳就丢了—— 除了泄漏,还顺带改变了业务语义。

05案例逐行分析:泄漏点定位

关键代码(channelRead0 的二进制分支与 writeAndFlush):

private BinaryWebSocketFrame newFrame(BinaryWebSocketFrame msg) {
    ByteBuf byteBuf = null;
    try {
        byte[] serviceTimeBytes = ("_" + System.currentTimeMillis()).getBytes();
        int capacity = msg.content().readableBytes() + serviceTimeBytes.length;
        byteBuf = ByteBufAllocator.DEFAULT.directBuffer(capacity);   // 深拷贝:新分配直接内存
        byteBuf.writeBytes(msg.content());
        byteBuf.writeBytes(serviceTimeBytes);
        msg.content().resetReaderIndex();
        return new BinaryWebSocketFrame(byteBuf);                    // refCnt = 1
    } catch (Exception e) {
        if (byteBuf != null) ReferenceCountUtil.release(byteBuf);       // 异常路径已修复
        throw e;
    }
}

private void writeAndFlush(..., BinaryWebSocketFrame msg, List<Channel> channels) {
    for (Channel channel : channels) {
        ...
        BinaryWebSocketFrame webSocketFrame = this.newFrame(msg);   // 分配新 buffer,refCnt=1
        webSocketFrame = (BinaryWebSocketFrame) retainedDuplicate(msg);   // 覆盖!新 buffer 被丢弃 → 泄漏
        channel.writeAndFlush(webSocketFrame, promise);                    // 共享帧写出,flush 后释放
    }
}

引用计数的完整流转如下:

入站:WebSocketFrameHandler(SimpleChannelInboundHandler<WebSocketFrame>)
  channelRead0 收到 BinaryWebSocketFrame msg,msg.refCnt = 1

  循环每个接收方 channel(共 N 个):
    ├─ newFrame(msg)            → 新分配独立 direct buffer,refCnt=1  ← 被覆盖,永不释放 ✗
    ├─ retainedDuplicate(msg)   → 共享 msg 底层内存,msg.refCnt + 1
    └─ channel.writeAndFlush()  → flush 后 Netty 释放该共享帧,msg.refCnt - 1

  channelRead0 返回 → SimpleChannelInboundHandler 自动 release(msg),msg.refCnt - 1

最终:
  msg 的共享内存:1 + N - N - 1 = 0  ✓ 正确释放
  newFrame 的独立内存:每个 channel 泄漏 1 块  ✗ 永不释放(泄漏量 = 消息数 × N)
⛔泄漏点

newFrame(msg) 每次循环都分配一块新的直接内存,紧接着被 retainedDuplicate(msg) 覆盖,这块内存既没写入 channel、也没手动 release, 引用计数永远停在 1,直接内存只增不减。配合 -Xmx200m(直接内存上限默认也约 200m),很快 OOM。

正确写法

要么用「拷贝」、要么用「共享」,二选一,别混用。这里因为要追加时间戳,用拷贝即可:

for (Channel channel : channels) {
    if (channel == null || !channel.isOpen() || !channel.isActive()) {
        if (chatType == Chat.CHAT_TYPE_1_2_1) {
            ctx.channel().writeAndFlush(new TextWebSocketFrame(Instruction.OFFLINE));
        }
        continue;
    }
    // 每个接收方一份独立拷贝,写出去后所有权移交出站管线,flush 后由 HeadContext 释放
    BinaryWebSocketFrame webSocketFrame = this.newFrame(msg);
    channel.writeAndFlush(webSocketFrame, promise);
}

这样 newFrame 返回的 refCnt=1 新帧被 writeAndFlush 写出去, 释放责任移交给出站管线(HeadContext flush 后释放),既不用手动 release, 也不需要 retainedDuplicate。

06泄露监控:ResourceLeakDetector

Netty 用 ResourceLeakDetector 检测内存泄漏,但要注意它的定位:只告警、不释放。

private static final class DefaultResourceLeak<T>
        extends WeakReference<Object> implements ResourceLeakTracker<T>, ResourceLeak {

private void clearRefQueue() {
    for (;;) {
        DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
        if (ref == null) break;
        ref.dispose();   // 仅记录泄漏堆栈,不释放底层内存
    }
}
📌一个小纠正

源码里 DefaultResourceLeak 继承的是 WeakReference(弱引用), 并非「虚引用」(PhantomReference)。两者行为近似——对象一旦只被该引用指向、即将被 GC 回收,就会被放入 ReferenceQueue 供检测器发现,但弱引用是「GC 时即回收」,是 Netty 实际的实现选择。

07总结归纳

维度要点
引用计数 retain() +1、release() -1,归零才真正释放(归还 PoolThreadCache / Chunk,huge 销毁)
框架自动释放 入站:SimpleChannelInboundHandler(channelRead0 后)、TailContext(未处理兜底);出站:HeadContext(flush 后)
手动释放 ReferenceCountUtil.release / safeRelease,用于自己创建/占用的 buffer
共享 vs 拷贝 retainedDuplicate 零拷贝共享(每个消费者各 release 一次);newFrame 深拷贝(各管各的)
本案例 bug newFrame 分配的新 buffer 被 retainedDuplicate 覆盖,未写也未释放 → 每个接收方泄漏一块直接内存
泄露监控 ResourceLeakDetector 基于弱引用跟踪,只告警不释放
✅一句话记住

谁创建谁释放、谁最后使用谁释放;共享要 retain、用完要 release。 拷贝和共享二选一,别「先拷贝、再改成共享」却忘了把拷贝的那块内存释放掉。