《Netty 高性能网络编程》附录 B Netty 诊断与调优命令

整理 macOS 上排查 linkd-v2 的可用命令:lsof、netstat、tcpdump、jcmd、jstack、JFR 与 Netty 系统属性,每条给出用途、可复制命令、输出读法与风险提示,并给出从连接积压到堆外内存上涨的组合排查路径。

使用前提与本机口径

这一页只收 macOS 上能直接执行的命令。macOS 没有 epoll,也没有 Linux 那套 socket 统计工具;连接诊断靠 lsof、netstat、tcpdump,JVM 诊断靠 jcmd、jstack、jfr。Linux 部署侧的差异不在本附录展开。

Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8,Netty 4.2.19.Final

先把 JDK 固定住,避免 jcmd 用 JDK 8 去连 JDK 25 的进程:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"

linkd-v2 的入口是 com.plume.linkd.v2.app.LinkdV2Main,HTTP 在 9090、h2c 在 9091、gRPC 在 9092、自定义协议在 9093。下文默认排查 9090。

标注「本机输出」的片段来自一个监听 9090 的最小 Java 进程(pid 85516),用于验证命令与输出格式;它不是完整 linkd-v2,所以线程栈里没有 nioEventLoopGroup。涉及 Netty 内部行为的片段标注「示意输出」,不冒充实测。

先定位进程与端口

lsof -nP -iTCP:9090 一次回答「进程在不在、端口有没有在听」。-n 不做 DNS 反解,-P 不把端口换成服务名,避免命令卡住。

lsof -nP -iTCP:9090

本机输出(服务已启动,尚无客户端连接):

COMMAND   PID         USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    85516 bingrong.yan    5u  IPv6 0x53172db9b40af12a      0t0  TCP *:9090 (LISTEN)

FD 为 5u 表示文件描述符 5、可读写;NAME 的 *:9090 是双栈监听,macOS 上即使只连 IPv4 也可能显示 IPv6 通配,不要据此判断「只监听了 IPv6」。没有 (LISTEN) 就是没起来或绑错地址。

有客户端连上后会多出两条 ESTABLISHED:

lsof -nP -iTCP:9090 -sTCP:ESTABLISHED

本机输出(用 nc 建立了一条连接):

java    85516 bingrong.yan    6u  IPv6 0x688d8c42df4bdb07      0t0  TCP 127.0.0.1:9090->127.0.0.1:54740 (ESTABLISHED)
nc      85593 bingrong.yan    3u  IPv4 0x6f9eeb4a57d5fcdc      0t0  TCP 127.0.0.1:54740->127.0.0.1:9090 (ESTABLISHED)

同一个连接出现两次是正常的:服务端与客户端各一条,本地/远端端口对调。数连接数时只数服务端 PID 的行。

风险:lsof 会遍历文件描述符表,连接数很大时耗时上升,不要放进每秒执行的心跳脚本。看自己的进程不需要 sudo。

连接队列与 socket 状态

netstat -anv -p tcp 给出内核视角的 TCP 状态、收发队列与 socket 缓冲。-a 包含监听态,-n 不解析名字,-v 增加字节计数,-p tcp 只看 TCP。

netstat -anv -p tcp | awk 'NR==1 || $4 ~ /\.9090$/ || $5 ~ /\.9090$/'

本机输出(为便于排版,删去 options 之后的列):

Proto Recv-Q Send-Q  Local Address        Foreign Address      (state)      rxbytes  txbytes  rhiwat  shiwat  process:pid
tcp4       6      0  127.0.0.1.9090       127.0.0.1.54740      ESTABLISHED      64        0  408300  146988  java:85516
tcp4       0      0  127.0.0.1.54740      127.0.0.1.9090       ESTABLISHED       0        6  408300  146988  nc:85593
tcp46      0      0  *.9090               *.*                  LISTEN            0        0  131072  131072  java:85516
  • Recv-Q 是内核接收队列里还没被应用读走的字节数。服务端持续大于 0,说明 EventLoop 没来得及读,可能是 handler 阻塞,也可能是 AUTO_READ=false 后忘了 read()。
  • Send-Q 是已交给内核、还没被对端确认的字节数。持续增大说明对端读得慢或网络拥塞,和 Netty 写水位不是同一个指标。
  • rhiwat/shiwat 是 socket 缓冲高水位,rxbytes/txbytes 是累计字节;它们不是 WriteBufferWaterMark。
  • tcp46 是双栈,tcp4 是纯 IPv4 连接。

风险:netstat 是快照,不是历史。判断「持续积压」要间隔采样,例如每 2 秒一次、连续 5 次,而不是看单行。

抓包看握手、重传与零窗口

tcpdump 回答「内核统计看不出对端到底发了什么」。macOS 的 loopback 是 lo0,不是 Linux 的 lo。

sudo tcpdump -i lo0 -nn -s 0 -c 20 port 9090

-nn 不解析主机名与端口名,-s 0 抓完整包,-c 20 抓满 20 个包退出。看明文 HTTP 再加 -A。

没有 sudo 时,本机输出是:

tcpdump: lo0: You don't have permission to capture on that device
((cannot open BPF device) /dev/bpf0: Permission denied)

这不是命令写错,是 macOS 的 BPF 设备权限。sudo 之后,一次请求的抓包示意输出:

19:20:01.123456 IP 127.0.0.1.54740 > 127.0.0.1.9090: Flags [S], seq 123456789, win 65535, options [mss 16344,nop,wscale 6], length 0
19:20:01.123478 IP 127.0.0.1.9090 > 127.0.0.1.54740: Flags [S.], seq 987654321, ack 123456790, win 65535, options [mss 16344,nop,wscale 6], length 0
19:20:01.123501 IP 127.0.0.1.54740 > 127.0.0.1.9090: Flags [.], ack 1, win 2048, length 0
19:20:01.123600 IP 127.0.0.1.54740 > 127.0.0.1.9090: Flags [P.], seq 1:7, ack 1, win 2048, length 6
19:20:01.123622 IP 127.0.0.1.9090 > 127.0.0.1.54740: Flags [.], ack 7, win 2048, length 0
  • 前三个包是三次握手:[S]、[S.]、[.]。只有 [S] 没有 [S.],说明 SYN 到了但服务端没回,常见于 backlog 满或 accept 线程卡住。
  • [P.] 是带数据的包,length 6 是负载字节数。
  • win 0 是零窗口,表示接收缓冲区满;对照 netstat 的 Recv-Q,能区分「应用没读」与「内核没腾空间」。
  • 大量重复 seq 是重传;大量 [R] 是连接被重置,先查对端是否提前关闭。

风险:tcpdump 需要 sudo,抓到的包含明文 payload,可能带令牌、Cookie 与业务数据,pcap 要按敏感文件管理。生产环境不要长期开,用 -c 限包数或 -w /tmp/linkd.pcap 落盘后立即停止。抓包本身有开销,流量大时会丢包,看到「少包」不等于链路真丢了。

线程栈:EventLoop 卡在哪

jcmd <pid> Thread.print 与 jstack <pid> 都能出线程栈,前者是 jcmd 子命令,后者是独立工具。先取 pid:

jcmd -l | grep -i LinkdV2Main

示意输出:

12345 com.plume.linkd.v2.app.LinkdV2Main

连续抓两次、间隔 5 秒,用来区分「正在跑」与「卡住」:

jcmd 12345 Thread.print > /tmp/linkd-threads-1.txt
sleep 5
jcmd 12345 Thread.print > /tmp/linkd-threads-2.txt

输出开头是本机信息(pid 85516 是最小监听进程),随后按线程分组。EventLoop 线程的栈形如以下示意输出:

"nioEventLoopGroup-2-1" #18 prio=5 os_prio=31 cpu=12.34ms elapsed=302.11s tid=0x... nid=0x...
   java.lang.Thread.State: RUNNABLE
        at sun.nio.ch.KQueue.poll(java.base@25.0.5/Native Method)
        at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@25.0.5/KQueueSelectorImpl.java:71)
        at io.netty.channel.nio.SelectedSelectionKeySetSelector.select(SelectedSelectionKeySetSelector.java:68)

读法:

  • 空闲 EventLoop 停在 sun.nio.ch.KQueue.poll 或 KQueueSelectorImpl.doSelect,状态 RUNNABLE,这是正常等待,不是死循环。
  • 出现 BLOCKED,看它在等哪个锁;出现 WAITING 在 CountDownLatch.await、CompletableFuture.join,说明 handler 里做了同步等待。
  • 栈里出现 SocketInputStream.socketRead0、JDBC 驱动或文件 IO,说明阻塞调用进了 IO 线程,应移到 DefaultEventExecutorGroup 或虚拟线程。
  • 对比两次栈:同一个方法两次都在栈顶,才是「卡住」;只出现一次可能只是采样撞上。

jstack 的用法相同:jstack 12345 > /tmp/linkd-jstack.txt,输出格式与 Thread.print 基本一致,同样可以抓两次对比。

风险:线程栈需要走到安全点,进程有明显停顿时输出会延迟;对延迟敏感的服务不要连续高频抓。栈是瞬时快照,至少抓三次。jcmd 与目标进程最好用同一个 JDK,版本差太远可能附加失败。

堆与对象:先看总量,再看类型

GC.heap_info 给出堆的保留、提交、已用和分代区域,不触发 full GC,适合先看趋势。

jcmd 12345 GC.heap_info

本机输出:

85516:
garbage-first heap   total reserved 8388608K, committed 528384K, used 30805K [0x0000000600000000, 0x0000000800000000)
 region size 4096K, 8 young (32768K), 0 survivors (0K)
  • reserved 是虚拟地址保留量,committed 是向系统提交的量,used 是存活对象占用。判断压力看 committed 与 used 的差,不要只看 reserved。
  • region size 4096K 是 G1 的 region 大小,8 young (32768K) 表示当前有 8 个年轻代 region。
  • 连续采样时 used 锯齿上升、Full GC 后仍不下降,才考虑泄漏;单次高可能只是还没到回收点。

看「哪类对象多」用 GC.class_histogram:

jcmd 12345 GC.class_histogram | head -20

本机输出:

85516:
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         15432         768960  [B (java.base@25.0.5)
   2:          3409         439840  java.lang.Class (java.base@25.0.5)
   3:         15269         366456  java.lang.String (java.base@25.0.5)
   4:          2512         239896  [Ljava.lang.Object; (java.base@25.0.5)
   5:          4051         129632  java.util.concurrent.ConcurrentHashMap$Node (java.base@25.0.5)
  • [B 是 byte[],[Ljava.lang.Object; 是 Object[]。byte[] 排名靠前不一定是泄漏,先看数量是否随请求数单调上涨。
  • ConcurrentHashMap$Node 多,可能是会话表、连接表或缓存没有淘汰;回到 WebSocketHub、ConnectionRegistry 的容器看上限。
  • 这里只统计堆内对象,ByteBuf 的堆外内存不在其中;堆外看 PooledByteBufAllocator.metric() 与进程 RSS。

风险:GC.class_histogram 默认只统计存活对象,会先做一次 full GC 再遍历,停顿明显。不要在高负载生产实例上随手执行,避开流量高峰。堆外内存问题不要指望这个命令给答案。

JFR:把一段时间的现象录下来

JFR 的 start/stop 通过 jcmd 的 JFR.start 与 JFR.stop 完成;jfr 命令本身负责读文件、汇总与导出事件。

jcmd 12345 JFR.start name=linkd settings=profile filename=/tmp/linkd.jfr duration=60s

本机输出(录制编号与路径以实际为准):

Started recording 2. The result will be written to:
/tmp/linkd.jfr

settings=profile 是配置名,不要写成位置参数;写成 name=linkd profile 会得到 Warning! The .jfc option/setting 'profile' doesn't exist.。

录制中途停止:

jcmd 12345 JFR.stop name=linkd

本机输出是 Stopped recording "linkd2", 342.8 kB written to: /private/tmp/linkd2.jfr。如果 duration 已到、录制自动结束,再按名字 stop 会得到 Could not stop recording "linkd".,这不是权限问题,是录制已经不在运行;先 jcmd 12345 JFR.check 看状态,再决定是否 stop。

录制结束后用 jfr 读文件:

jfr summary /tmp/linkd.jfr
jfr print --events jdk.GarbageCollection,jdk.SocketRead,jdk.SocketWrite /tmp/linkd.jfr

jfr summary 先看 Duration,它是实际覆盖时长,不是你以为的 duration;进程提前退出或手动 stop 都会缩短。再看 jdk.GarbageCollection 的暂停时间与次数,然后看 jdk.SocketRead/jdk.SocketWrite 的耗时分布,最后才看 CPU 采样。jdk.NativeLibrary、jdk.SystemProcess 数量大通常不是问题,别被 Count 吓到。

也可以在启动时直接录:-XX:StartFlightRecording=filename=/tmp/linkd.jfr,settings=profile,dumponexit=true。

风险:profile 配置的开销低于 diagnostic,但不是零;长期开启会持续写文件,必须配 maxsize 或 duration。录制文件可能包含请求路径、SQL 与异常信息,按敏感数据处理。JFR 记录已发生的事件,不产生流量,不能替代压测。

Netty 泄漏检测参数

-Dio.netty.leakDetection.level 控制 ResourceLeakDetector 的采样级别。默认 SIMPLE,生产临时排查用 ADVANCED,PARANOID 只适合短时复现。

java -Dio.netty.leakDetection.level=paranoid \
  -jar linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar

启动后在 LinkdV2Main 的日志里确认 leakLevel=paranoid,再复现请求。命中泄漏时的日志示意:

严重: LEAK: ByteBuf.release() was not called before it's garbage-collected.
Recent access records:
#1:
	io.netty.buffer.AdvancedLeakAwareByteBuf.writeBytes(AdvancedLeakAwareByteBuf.java:591)
	com.plume.linkd.v2.server.LinkdHttpHandler.channelRead0(LinkdHttpHandler.java:42)
Created at:
	io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:408)
	com.plume.linkd.v2.protocol.LinkdFrameDecoder.decode(LinkdFrameDecoder.java:31)

Created at 是分配栈,指向谁创建了缓冲区;Recent access records 是最后一次访问栈,指向谁用完没还。两条栈之间的异常分支最可疑:try 里 retain、catch 里提前 return、finally 漏 release。同一处泄漏通常只报一次,修完要重新跑。

风险:PARANOID 对每次分配都做记录,开销明显高于 SIMPLE,具体倍数取决于分配频率,不给固定数字。只在一台可复现的实例上短时开启,复现完立即改回 SIMPLE 或 ADVANCED。它只能发现「对象被 GC 回收时 refCnt 仍大于 0」的泄漏;仍被静态容器强引用的 ByteBuf 不会报,要配合 PooledByteBufAllocator.metric() 与堆外内存趋势看。

分配器与 EventLoop 参数

-Dio.netty.allocator.type=pooled 把 ByteBufAllocator.DEFAULT 固定为池化实现。Netty 4.2 默认已经是 pooled,这个参数的价值是排除启动脚本把它改成 unpooled,不是「打开池化就变快」。

java -Dio.netty.allocator.type=pooled \
  -jar linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar

启动时打印 ByteBufAllocator.DEFAULT.getClass().getName() 确认,输出应为 io.netty.buffer.PooledByteBufAllocator;如果是 UnpooledByteBufAllocator,说明参数没生效或被代码显式覆盖。池化的收益与代价都在「复用」:复用减少分配与 GC 压力,但池化内存不会立刻还给操作系统,usedDirectMemory 高不等于泄漏,要结合 arena 指标与请求量看。

-Dio.netty.eventLoopThreads=N 决定无参 NioEventLoopGroup() 的线程数,默认 max(1, CPU 核数 × 2);本机 10 核对应 20,这是默认公式,不是性能建议。

java -Dio.netty.eventLoopThreads=8 \
  -jar linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar

确认属性是否传进去:

jcmd 12345 VM.system_properties | grep io.netty

示意输出:

io.netty.eventLoopThreads=8

没设置时 grep 不到,不代表默认值没生效。线程数不是越多越好:每个 Channel 终身绑定一个 EventLoop,线程过多增加上下文切换,线程过少会让一个慢 handler 拖住更多连接。改完用 jcmd 12345 Thread.print | grep nioEventLoopGroup 数线程,boss 组与 worker 组分开算。

风险:这两个参数都在类初始化或 EventLoopGroup 构造时读取,运行期改系统属性不生效,必须重启。不要在没有压测基线时同时改分配器与线程数,否则无法判断是哪个参数导致的。

原生访问参数

JDK 24 起,加载 JNI/原生库默认会给出受限方法警告。Netty 的 kqueue/epoll 传输会触发它。linkd-v2 用 classpath 启动时模块是 unnamed,参数写 ALL-UNNAMED:

java --enable-native-access=ALL-UNNAMED \
  -jar linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar

不加参数时的警告示意输出:

WARNING: A restricted method in java.lang.System has been called
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in the unnamed module
WARNING: Recompile with --enable-native-access for an explicit module

警告只是提示,不代表 kqueue 不可用;真正的可用性看 KQueue.isAvailable() 与启动日志。如果以后改成模块化启动,参数要换成具体模块名,ALL-UNNAMED 只覆盖 classpath 上的代码。

确认参数已传入用 jcmd 12345 VM.command_line,本机临时进程的输出里能看到 jvm_args: --enable-native-access=ALL-UNNAMED;源码启动器还会追加 --add-modules=ALL-DEFAULT,jar 启动没有这一项。

风险:ALL-UNNAMED 对所有 classpath 代码放开原生访问,范围比单个模块大,只在自己可控的部署包里使用。它不会让不可用的 native 构件变得可用,缺 osx-aarch_64 classifier 时仍要回退 NioEventLoopGroup。

组合排查路径

单条命令只回答一个问题。按现象定顺序,前一步没有结论再走下一步:

现象第一条命令接着看判据
9090 连不上lsof -nP -iTCP:9090netstat -anv -p tcp、tcpdump -i lo0没有 LISTEN 是没启动;有 LISTEN 但 SYN 无响应看 backlog
连接数只涨不降lsof -nP -iTCP:9090 -sTCP:ESTABLISHEDjcmd Thread.print、WebSocketHub 日志服务端 ESTABLISHED 持续增加,查会话表移除与半关闭
QPS 下降、延迟抖动连续三次 jcmd Thread.printJFR 60 秒、netstat 的 Recv-QEventLoop 卡在业务方法或锁上;Recv-Q 持续大于 0
堆外内存上涨jcmd GC.heap_infoGC.class_histogram、PooledByteBufAllocator.metric()堆内稳定而 RSS 上涨,查 ByteBuf 释放与 arena
代理返回 502lsof -nP -iTCP:8080GatewayHandler 日志、tcpdump port 8080libraryd 没听、连接被拒或被超时中断

固定顺序是 lsof → netstat → 两次 Thread.print(间隔 5 秒)→ GC.heap_info → GC.class_histogram → JFR。原则是「先便宜、后昂贵;先现象、后根因」。lsof 与 netstat 几乎不影响服务,线程栈有短暂 safepoint,class_histogram 触发 full GC,JFR 持续写文件。不要在第一步就上最重的工具。

常见错误与边界

把 lsof 的 IPv6 通配当成只监听 IPv6。 *:9090 通常是双栈,IPv4 客户端也能连;判断协议族看 tcp4/tcp46。

把 netstat 的 rhiwat 当成 Netty 水位。 前者是内核 socket 缓冲高水位,后者是 WriteBufferWaterMark 的应用层阈值,调整方式与观察位置都不同。

不带 sudo 跑 tcpdump。 本机报错是 You don't have permission to capture on that device,不是接口名写错。

在生产长期开 PARANOID。 它记录每次分配,开销与日志量都大;只用于短时复现。

用 jcmd 的 JDK 版本和目标进程不一致。 先对齐 jcmd -version 与 java -version;附加失败先怀疑版本,不要怀疑端口。

把 GC.class_histogram 当无侵入命令。 它默认统计存活对象,会先做 full GC;延迟敏感实例上这一下停顿足以触发超时。

JFR 录制结束后再 stop。 本机输出 Could not stop recording "linkd".,因为 duration 已到、录制自动结束;先 JFR.check。

看到一条 LEAK 就以为只有一处。 同一处通常只报一次,修完要重新跑;Created at 与 Recent access records 是两个不同时点。

在 handler 里执行这些命令。 在 EventLoop 线程里 Runtime.exec 等待输出,会把这个 EventLoop 上的所有连接一起拖住。

小结

  • 先 lsof -nP -iTCP:9090 确认进程、端口与连接;-n 与 -P 避免解析卡顿。
  • netstat -anv -p tcp 看 Recv-Q/Send-Q 与 socket 缓冲;rhiwat 不是 Netty 写水位。
  • tcpdump -i lo0 -nn port 9090 需要 sudo,用 -c 限包数、-w 落盘;pcap 按敏感数据管理。
  • 线程栈至少抓两次、间隔 5 秒;EventLoop 停在 KQueue.poll 是正常,停在业务方法或锁上才是问题。
  • GC.heap_info 看总量,GC.class_histogram 看类型;后者触发 full GC,避开高峰。
  • JFR 用 jcmd JFR.start/stop 控制,jfr summary/jfr print 读文件;duration 到了再 stop 会失败。
  • PARANOID 只短时开,pooled 是固定默认值而非加速开关,线程数按压测结果调。
  • 排查顺序:端口与连接 → 线程栈 → 堆与对象 → JFR;先便宜后昂贵,先现象后根因。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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