Java 与 JVM 列表全部文章
Java 与 JVM
技术笔记›Java 与 JVM第 1 / 6 篇

Netty 实战笔记:线程模型、编解码、心跳与内存泄漏排查

Netty 的核心组件与 Reactor 线程模型讲透,包含拆包粘包的四种解法、编解码器选型、心跳保活、ByteBuf 引用计数,以及生产上 DIRECT_MEMORY 泄漏和 EventLoop 阻塞的真实排查过程。

更多

Netty 是 Java 里写网络程序的事实标准:Dubbo、RocketMQ、Elasticsearch、gRPC-Java 底层都是它。这篇讲它在生产里真正会用到的部分。

一、线程模型:为什么 Netty 快

Netty 用的是 Reactor 主从多线程模型。

BossGroup 1–2 线程,只管 accept WorkerGroup CPU×2 线程,管读写 ChannelPipeline Handler 责任链 业务线程池 耗时操作必须隔离 关键约束:一个 Channel 绑定一个 EventLoop,整个生命周期内所有事件都由这一个线程串行执行。 好处:Handler 里不用考虑并发,无锁。代价:Handler 里绝不能做耗时操作,否则整条连接被拖死。 反例:在 channelRead 里查数据库 / 调 HTTP —— 该 EventLoop 上所有连接全部卡住

二、启动模板(服务端)

EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();          // 默认 CPU 核数 × 2
try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(boss, worker)
     .channel(NioServerSocketChannel.class)
     .option(ChannelOption.SO_BACKLOG, 1024)
     .childOption(ChannelOption.SO_KEEPALIVE, true)
     .childOption(ChannelOption.TCP_NODELAY, true)
     .childHandler(new ChannelInitializer<SocketChannel>() {
         protected void initChannel(SocketChannel ch) {
             ch.pipeline()
               .addLast(new IdleStateHandler(60, 30, 0))   // 读空闲 60s / 写空闲 30s
               .addLast(new LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4, 0, 4))
               .addLast(new MyDecoder())
               .addLast(new MyEncoder())
               .addLast(bizGroup, new BizHandler());        // ★ 耗时业务丢到独立线程池
         }
     });
    ChannelFuture f = b.bind(8080).sync();
    f.channel().closeFuture().sync();
} finally {
    boss.shutdownGracefully();
    worker.shutdownGracefully();
}

addLast(EventExecutorGroup, handler) 这一行是性能与稳定的分水岭:加了它,handler 会被提交到业务线程池执行,EventLoop 不会被阻塞。

三、拆包粘包:四种解法

TCP 是字节流,没有消息边界。发送方写两次,接收方可能一次读到,也可能读半个。

方案 适用 Netty 实现
固定长度 定长协议 FixedLengthFrameDecoder
分隔符 文本协议(如 \n) LineBasedFrameDecoder / DelimiterBasedFrameDecoder
长度字段(推荐) 二进制协议 LengthFieldBasedFrameDecoder(最大帧长, 长度字段偏移, 长度字段字节数, 长度调整, 剥离字节数)
自定义 私有协议 继承 ByteToMessageDecoder

LengthFieldBasedFrameDecoder 的参数最容易搞错,记住一个例子:报文 = [4字节长度][报文体],长度字段的值只表示报文体长度,则:

// maxFrameLength, lengthFieldOffset=0, lengthFieldLength=4, lengthAdjustment=0, initialBytesToStrip=4
new LengthFieldBasedFrameDecoder(1024 * 1024, 0, 4, 0, 4);

四、ByteBuf:引用计数是核心

ByteBuf 用引用计数管理堆外内存,refCnt 归零才释放。规则:

  • 谁最后使用,谁负责 release()。
  • SimpleChannelInboundHandler 会自动帮你 release 一次(它泛型匹配的那种消息)。
  • 在 handler 里把 ByteBuf 传给别的线程,或者转成别的对象保留下来,就必须手动释放或 retain()。
  • 忘了 release → 堆外内存泄漏;release 多了 → IllegalReferenceCountException。

检测手段:

-Dio.netty.leakDetection.level=PARANOID   # 全量采样,抓泄漏专用(性能损耗大)
# 生产可设 SIMPLE(默认)或 ADVANCED

五、心跳与断线重连

IdleStateHandler(readerIdleTime, writerIdleTime, allIdleTime, unit) 本身不发心跳,只触发 IdleStateEvent,需要自己处理:

public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
    if (evt instanceof IdleStateEvent) {
        IdleStateEvent e = (IdleStateEvent) evt;
        if (e.state() == IdleState.WRITER_IDLE) {
            ctx.writeAndFlush(new PingMessage());     // 空闲时主动发心跳
        } else if (e.state() == IdleState.READER_IDLE) {
            ctx.close();                              // 长时间没收到对端数据,断开
        }
    }
}

客户端还要配重连:在 channelInactive 里用带退避的定时任务重连(ctx.channel().eventLoop().schedule),不要死循环重连,会打爆服务端。

六、生产排查两个真实案例

案例一:OutOfDirectMemoryError

现象:长连接网关运行几天后报 io.netty.util.internal.OutOfDirectMemoryError: failed to allocate ... byte(s) of direct memory。

排查:

  1. 加 -Dio.netty.leakDetection.level=PARANOID 复现,日志里出现 LEAK: ByteBuf.release() was not called before it's garbage-collected,并带有分配点的栈。
  2. 定位到自定义解码器里,异常分支提前 return 而没有 release 已经读到的 ByteBuf。
  3. 补 try/finally + ReferenceCountUtil.release(msg)。

顺带的配置:-XX:MaxDirectMemorySize 要显式设置(默认和 -Xmx 一样大,容易被忽略),并监控堆外内存使用。

案例二:EventLoop 阻塞导致大面积超时

现象:网关 CPU 不高,但大量连接读写超时,jstack 里 Worker 线程栈顶停在一个数据库查询上。

根因:有人在 channelRead0 里直接查了数据库(还是慢查询)。因为一个 EventLoop 管着几千个连接,这一个查询就把整批连接都卡住了。

修法:

  • 把业务处理挪到独立线程池(前面那行 addLast(bizGroup, handler))。
  • 慢查询本身也优化掉(加索引)。
  • 加一个EventLoop 阻塞检测:Netty 提供 DefaultEventExecutorGroup 配合 -Dio.netty.eventLoop.maxPendingTasks,也可以用 BlockHound 在测试期直接拦住阻塞调用。

七、调优参数清单

参数 建议
SO_BACKLOG 1024 以上,高并发下不够会出现连接被拒
TCP_NODELAY 开启,禁用 Nagle,降低小包延迟
SO_KEEPALIVE 开,但系统默认 2 小时太长,应用级心跳必须有
ALLOCATOR 使用 PooledByteBufAllocator(默认已是),不要改成非池化
RCVBUF / SNDBUF 高吞吐场景适当调大
bossGroup 线程数 1 就够(只 accept),多端口才需要更多
workerGroup 线程数 CPU 核数 × 2,IO 密集可再放大,靠压测定

八、一句话总结

Netty 的性能来自「一个连接一个线程串行处理 + 零拷贝 + 内存池」,而它的坑也来自同一处:任何阻塞都会放大成一批连接的故障。写 handler 时始终记住:你这段代码是跑在 IO 线程上的。

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人