使用前提与本机口径
这一页只收 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:9090 | netstat -anv -p tcp、tcpdump -i lo0 | 没有 LISTEN 是没启动;有 LISTEN 但 SYN 无响应看 backlog |
| 连接数只涨不降 | lsof -nP -iTCP:9090 -sTCP:ESTABLISHED | jcmd Thread.print、WebSocketHub 日志 | 服务端 ESTABLISHED 持续增加,查会话表移除与半关闭 |
| QPS 下降、延迟抖动 | 连续三次 jcmd Thread.print | JFR 60 秒、netstat 的 Recv-Q | EventLoop 卡在业务方法或锁上;Recv-Q 持续大于 0 |
| 堆外内存上涨 | jcmd GC.heap_info | GC.class_histogram、PooledByteBufAllocator.metric() | 堆内稳定而 RSS 上涨,查 ByteBuf 释放与 arena |
| 代理返回 502 | lsof -nP -iTCP:8080 | GatewayHandler 日志、tcpdump port 8080 | libraryd 没听、连接被拒或被超时中断 |
固定顺序是 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;先便宜后昂贵,先现象后根因。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。