这份清单只收本卷真正跑过的命令,格式固定:可复制的完整命令、一段输出、读哪几列、哪里会看错。输出分两种标注:本机输出是在 Apple M1 Pro、macOS 26.3 (25D125)、Temurin 25.0.5+8 上实际跑出来的;示意输出是没跑或不能跑时按协议写的样例,字段真实、数值不做承诺。实验对象是一个监听 127.0.0.1:9090 的最小 HTTP 服务,GET /healthz 返回 ok。
先记住这台机器没有什么
- macOS 没有
ss,也没有epoll。连接诊断用netstat -anv -p tcp、lsof、tcpdump,不要照抄 Linux 的ss -lntp。 tcpdump需要sudo,回环抓包用-i lo0;普通用户直接跑会得到权限错误。ab在/usr/sbin/ab;wrk、hey没有安装,本卷不写它们的实测。jcmd只能 attach 同用户、同 PID 命名空间的 Java 进程;容器里看到的 PID 和宿主机不是一回事。
命令地图
| 你想知道什么 | 命令 | 需要 sudo |
|---|---|---|
| 端口有没有人监听、监听在哪个地址 | lsof -nP -iTCP:9090 | 否 |
| 连接处于哪个 TCP 状态、收发多少字节 | netstat -anv -p tcp | 否 |
| 握手走没走完、FIN 是谁先发的 | tcpdump -i lo0 -nn -s 0 port 9090 | 是 |
| 对端端口通不通 | nc -z -v 127.0.0.1 9090 | 否 |
| 应用层返回了什么、各阶段耗时 | curl -v、curl -w | 否 |
| 并发下吞吐与分位 | ab -n 200 -c 10 | 否 |
| Java 线程卡在哪里 | jcmd <pid> Thread.print | 否 |
| 堆外内存涨在哪里 | jcmd <pid> VM.native_memory summary | 否 |
顺序上先做不需要权限的:tcpdump 噪声最大,先用 lsof 和 netstat 把范围缩到「哪个端口、哪个进程、哪个状态」,抓包才有过滤条件可写。
netstat -anv -p tcp:连接状态与字节计数
netstat -anv -p tcp | awk 'NR < 2 || $4 ~ /\.9090$/ || $5 ~ /\.9090$/'
没有用 grep 9090,因为 9090 会同时匹配 19090、90900;用 awk 匹配「地址以 .9090 结尾」才不会误伤。
本机输出(服务刚启动,还没有请求):
Proto Recv-Q Send-Q Local Address Foreign Address (state) rxbytes txbytes rhiwat shiwat process:pid state options gencnt flags flags1 usecnt rtncnt fltrs
tcp4 0 0 127.0.0.1.9090 *.* LISTEN 0 0 131072 131072 java:41146 00100 00000006 0000000002bb02f3 00000800 ... 1 0 000000
本机输出(一个客户端连上并发了 200000 字节、服务端还没读;右侧 flags 列省略):
Proto Recv-Q Send-Q Local Address Foreign Address (state) rxbytes txbytes rhiwat shiwat process:pid
tcp4 200000 0 127.0.0.1.9095 127.0.0.1.56176 ESTABLISHED 401404 0 408300 146988 java:42529
tcp4 0 0 127.0.0.1.56176 127.0.0.1.9095 ESTABLISHED 0 200000 408300 146988 nc:42542
两行是同一个连接的两端:java 侧 Recv-Q=200000,nc 侧 Send-Q=0、txbytes=200000。两组数字互相印证,说明 Recv-Q 就是「已到内核、还没被应用读走」的字节数。
| 列 | 怎么读 | 不要怎么读 |
|---|---|---|
Recv-Q | 本端未被应用读走的字节,长期非零说明读循环停了 | 不要当成应用层队列长度 |
Send-Q | 已交给内核、对端还没确认的字节,长期非零说明对端收得慢 | 不要和 txbytes 混为一谈 |
(state) | LISTEN / ESTABLISHED / TIME_WAIT / CLOSE_WAIT 等 | 不要以为 TIME_WAIT 是服务没关好 |
rxbytes / txbytes | 该 socket 累计收发的字节量级 | 不要逐字节对账,它含协议与缓冲开销 |
rhiwat / shiwat | socket 缓冲的历史高水位 | 不是当前占用:本机从 131072 抬到 408300 后不再回落 |
process:pid | 这一行属于哪个进程 | 不写 -v 没有这一列,很多人以为 netstat 看不到进程 |
TIME_WAIT 值得做个对照实验:
ab -n 50 -c 5 http://127.0.0.1:9090/healthz
netstat -anv -p tcp | awk '/\.9090$/ && /TIME_WAIT/' | wc -l
不加 -k 时,50 个请求留下 52 条 TIME_WAIT;加 -k 后只剩 5 条(等于并发数)。原因是 ab 默认用 HTTP/1.0,每个请求一条新连接,服务端响应后先关,TIME_WAIT 就落在 9090 这一侧。看到几十条 TIME_WAIT 就判断「连接泄漏」,是最常见的误判之一。
lsof -nP -iTCP:9090:谁占着端口
lsof -nP -iTCP:9090
本机输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 41146 bingrong.yan 5u IPv6 0xffa50e2828b1ddb8 0t0 TCP 127.0.0.1:9090 (LISTEN)
java 41146 bingrong.yan 10u IPv6 0x49afbd9511131ba6 0t0 TCP 127.0.0.1:9090->127.0.0.1:55282 (ESTABLISHED)
nc 41739 bingrong.yan 3u IPv4 0x637fc6cae75f408d 0t0 TCP 127.0.0.1:55282->127.0.0.1:9090 (ESTABLISHED)
-n不解析主机名,-P不解析端口名;两个都加才看得到9090而不是websm。FD的5u是文件描述符 5、u表示读写打开;同一进程的监听 fd 与连接 fd 编号不同。NAME里的->直接给出方向,左边是本端:java那行是服务端,nc那行是客户端;(LISTEN)、(ESTABLISHED)与netstat的 state 列对得上,可交叉验证。
最容易看错的是 TYPE 列:监听的是 127.0.0.1,lsof 却显示 IPv6。原因是 JDK 在这个平台上用的是双栈 socket,netstat 的对应行仍是 tcp4。不要因为 lsof 写了 IPv6 就认为服务监听在 ::,地址族以 NAME 列的实际地址为准。
lsof -nP -iTCP:9090 -sTCP:LISTEN # 只看监听
lsof -nP -iTCP:9090 -sTCP:ESTABLISHED # 只看已建立连接
lsof 返回 1 且无输出,表示没有匹配项(端口没人监听),不是命令出错。
tcpdump -i lo0 -nn -s 0 port 9090:看握手与挥手
sudo tcpdump -i lo0 -nn -s 0 port 9090
四个参数各有理由:-i lo0 抓回环(linkd 与 libraryd 都在这台机器上);-nn 不解析主机名和端口名,避免 DNS 拖慢抓包;-s 0 抓完整包不截断;port 9090 只留目标端口。不加 sudo 时,本机输出是:
tcpdump: lo0: You don't have permission to capture on that device
((cannot open BPF device) /dev/bpf0: Permission denied)
这条错误说明的是权限,不是网卡不存在;不要照着错误去改 -i 参数。
抓到一次完整请求,示意输出:
17:50:01.123456 IP 127.0.0.1.55282 > 127.0.0.1.9090: Flags [S], seq 1000, win 65535, options [mss 16344], length 0
17:50:01.123478 IP 127.0.0.1.9090 > 127.0.0.1.55282: Flags [S.], seq 2000, ack 1001, win 65535, options [mss 16344], length 0
17:50:01.123490 IP 127.0.0.1.55282 > 127.0.0.1.9090: Flags [.], ack 1, win 2048, length 0
17:50:01.123501 IP 127.0.0.1.55282 > 127.0.0.1.9090: Flags [P.], seq 1:19, ack 1, length 18
17:50:01.123560 IP 127.0.0.1.9090 > 127.0.0.1.55282: Flags [P.], seq 1:97, ack 19, length 96
17:50:01.123570 IP 127.0.0.1.9090 > 127.0.0.1.55282: Flags [F.], seq 97, ack 19, length 0
[S]、[S.]、[.]是三次握手;只有[S]没有[S.],说明 SYN 到了但没人应答,问题在监听端或防火墙。[P.]是带数据的包,[F.]是 FIN,[R]是 RST。seq区间长度等于length,可据此确认「半包」是不是真的被拆成了两个 TCP 段。- 谁先发
F.,谁就进TIME_WAIT。上例里 9090 先发 FIN,与前一节netstat看到的服务端TIME_WAIT一致。
sudo tcpdump -i lo0 -nn -s 0 -c 20 port 9090 # 抓 20 个包就停
sudo tcpdump -i lo0 -nn -A -s 0 port 9090 # ASCII 显示载荷,看 HTTP 头
sudo tcpdump -i lo0 -nn -w /tmp/linkd.pcap port 9090 # 存文件,用 Wireshark 打开
-c 是防止忘记停导致磁盘写满的最简单办法;-w 存下的 pcap 可以反复分析,比在终端里刷屏有用。
nc:最小连通性探针
nc -z -v 127.0.0.1 9090
本机输出:
Connection to 127.0.0.1 port 9090 [tcp/websm] succeeded!
-z 只扫描不发送数据。websm 是 macOS 把 9090 映射成的服务名,加 -n 会显示 [tcp/*],只影响显示。端口没人监听时,本机输出:
nc: connectx to 127.0.0.1 port 9091 (tcp) failed: Connection refused
Connection refused 说明 RST 回来了:包到了主机,但没有进程监听这个端口。它与「超时」是两类问题——超时是包发出去没有回应,通常出在防火墙或路由。
nc 还能手写请求,确认服务端是不是真的在解析 HTTP:
printf 'GET /healthz HTTP/1.0\r\n\r\n' | nc -w 3 127.0.0.1 9090
本机输出:
HTTP/1.1 200 OK
Connection: close
Date: Wed, 28 Oct 2026 09:44:07 GMT
Content-length: 2
ok
Connection: close 是服务端对 HTTP/1.0 请求的回答;Content-length: 2 与正文 ok 对得上。这条命令把「TCP 通」和「HTTP 解析对」分开验证,比直接上 curl 更能定位问题层。-w 3 是等待上限,不加的话 nc 会在对端不关连接时一直挂着。
curl -v:把一次往返读成文本
curl -v http://127.0.0.1:9090/healthz
本机输出(stderr 部分,节选):
* Trying 127.0.0.1:9090...
* Connected to 127.0.0.1 (127.0.0.1) port 9090
> GET /healthz HTTP/1.1
> Host: 127.0.0.1:9090
> User-Agent: curl/8.7.1
> Accept: */*
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Wed, 28 Oct 2026 09:44:03 GMT
< Content-length: 2
* Connection #0 to host 127.0.0.1 left intact
Trying到Connected之间是 TCP 建连;这一段慢,问题在网络或 backlog,不在业务代码。没有Connected直接报Failed to connect,与nc的Connection refused同义。>是请求、<是响应;请求里没有Content-Length说明是空 body,服务端不该等。left intact表示连接被复用(keep-alive),不是错误。看到它以为「连接没关」是常见误判。
要数字而不是肉眼,用 -w:
curl -s -o /dev/null -w 'code=%{http_code} connect=%{time_connect} total=%{time_total}\n' \
http://127.0.0.1:9090/healthz
本机输出:
code=200 connect=0.001038 total=0.005083
单位是秒。time_connect 只覆盖建连,time_total 覆盖到响应结束,两者之差包含服务端处理与传输。回环上绝对值很小,换机器会变,但两个字段的差值关系不变。
ab:压测的最小可用工具
/usr/sbin/ab -n 200 -c 10 http://127.0.0.1:9090/healthz
本机输出(节选,单次运行,未做统计):
Concurrency Level: 10
Time taken for tests: 0.128 seconds
Complete requests: 200
Failed requests: 0
Total transferred: 19200 bytes
Requests per second: 1561.95 [#/sec] (mean)
Time per request: 6.402 [ms] (mean)
Time per request: 0.640 [ms] (mean, across all concurrent requests)
Requests per second是吞吐;Time per request出现两次,第一行是客户端视角的单请求耗时(含排队,约为第二行乘以并发 10),第二行等于「总时间 ÷ 总请求数」,也就是吞吐的倒数。上表0.128s ÷ 200 = 0.640ms、6.402 = 0.640 × 10,可以对得上;用错这两个数会差一个并发倍数。Failed requests非 0 时,上面的Non-2xx responses或 length 对比会给原因,不要只看总数。Connect高先查 backlog 与accept循环;Processing高才是业务慢。- 这个数字只说明「这一次、这台机器、这个服务」。
linkd的真实压测在正文 15.2 节,那里给了三次运行与分位。
两个必知的默认值:ab 默认用 HTTP/1.0 且不带 -k 就不复用连接,上表的吞吐里含建连成本,与 keep-alive 客户端对比时必须加 -k 再测一次(本机用 nc -l 127.0.0.1 9091 接住 ab -n 1 的请求,请求行确实是 GET /x HTTP/1.0);-c 不能超过服务端 backlog 能承受的范围,否则失败会以连接错误的形式出现,看起来像「服务端崩了」。
jcmd Thread.print:线程卡在哪
jcmd -l # 先拿到 PID 与主类名
jcmd 41146 Thread.print
本机输出(节选):
41146:
2026-10-28 17:44:19
Full thread dump OpenJDK 64-Bit Server VM (25.0.5+8-LTS mixed mode, sharing)
"main" #3 [9475] prio=5 os_prio=31 cpu=61.53ms elapsed=35.90s tid=0x00000001012603b0 nid=9475 waiting on condition [0x000000016f7b6000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleepNanos0(java.base@25.0.5/Native Method)
at java.lang.Thread.sleep(java.base@25.0.5/Thread.java:540)
at Probe.main(Probe.java:16)
- 每个线程块第一行给名字、
#id、nid(内核线程号)、Java 状态与锁信息。 java.lang.Thread.State是判断依据:RUNNABLE、BLOCKED、WAITING、TIMED_WAITING含义不同。- 栈顶几帧决定「它在等什么」:
accept、poll、epollWait(Linux)都在等 I/O;sleepNanos0是定时等待。 - 先
jcmd -l拿 PID 与主类名,避免对着错误的进程看半天。
常见误判是看到大量 TIMED_WAITING 就判定「线程池卡死」。空闲的工作线程本来就是 TIMED_WAITING;真正的卡死通常是 BLOCKED 在同一把锁上排长队,或 RUNNABLE 却停在业务方法里不动。要判断「是不是卡住」,看间隔 10 秒的两次转储里栈顶是否完全一致,单次快照说明不了问题。
attach 失败时,本机见过的原文是:
com.sun.tools.attach.AttachNotSupportedException: pid: 41543, state is not ready to participate in attach handshake!
这表示目标进程正在退出或不允许 attach,不是 jcmd 坏了。重试前先确认进程还活着、jcmd -l 能列出来。
jcmd VM.native_memory summary:堆外内存
这个命令有前提:JVM 启动时必须带 -XX:NativeMemoryTracking=summary(或 detail)。没带的话,本机输出是:
41146:
Native memory tracking is not enabled
也就是说,线上进程如果没预留这个参数,只能重启后再看,不要指望事后开启。
java -XX:NativeMemoryTracking=summary -cp . Probe
jcmd <pid> VM.native_memory summary
本机输出(节选):
Native Memory Tracking:
Total: reserved=10030324KB, committed=619620KB
malloc: 23396KB #19916, peak=29670KB #16270
mmap: reserved=10006928KB, committed=596224KB
- Java Heap (reserved=8388608KB, committed=528384KB)
(mmap: reserved=8388608KB, committed=528384KB, at peak)
- Thread (reserved=41021KB, committed=397KB)
(threads #20)
(stack: reserved=40960KB, committed=336KB)
reserved是虚拟地址保留,不是实际占用。Java Heap reserved=8388608KB正好等于这台 32 GB 机器的默认MaxHeapSize(用java -XX:+PrintFlagsFinal -version可验证),它只是把地址空间圈出来。committed才是真正向系统要的内存,与 RSS 同量级但不相等,差在共享库、页表与未计入的分类。- 分类里
Java Heap、Class、Thread、Code、GC、Internal、Other分别对应堆、元空间、线程栈、JIT 代码缓存等;堆外泄漏通常表现为Internal或Other持续上涨。 Thread (threads #20)后面是线程数;栈按平台默认值预留,本机-XX:+PrintFlagsFinal显示ThreadStackSize=2048(2 MB),20 条线程正好对上stack: reserved=40960KB,线程数暴涨会直接推高reserved。
常见误判是拿 reserved 当 RSS,得出「JVM 占了 10 GB 但机器只有 32 GB」这类结论;或把 committed=619620KB 与 ps 的 RSS 逐 MB 对账。两者统计口径不同,看趋势比看单点有意义:间隔几分钟各取一次,只有 committed 持续单调上涨才值得继续查。
三条组合排查链
链一:端口起没起。
lsof -nP -iTCP:9090 # 谁在监听
netstat -anv -p tcp | awk '$4 ~ /\.9090$/' # 监听地址与状态
nc -z -v 127.0.0.1 9090 # 本机能不能连
curl -v http://127.0.0.1:9090/healthz # 应用层通不通
四步从「进程存在」到「HTTP 正确」,任何一步失败都能定位到层。跳过 nc 直接 curl,会把「TCP 不通」和「HTTP 写错」混成一个错误。
链二:连上了但没响应。
netstat -anv -p tcp | awk '$4 ~ /\.9090$/ || $5 ~ /\.9090$/'
jcmd <pid> Thread.print
sudo tcpdump -i lo0 -nn -s 0 -c 40 port 9090
先看 Recv-Q:非零说明数据到了但应用没读,问题在读循环;为零则看线程转储,确认读线程是不是卡在别处;最后抓包确认请求是否真的发出、响应是否真的回来。
链三:吞吐低或内存涨。
ab -k -n 500 -c 50 http://127.0.0.1:9090/healthz
jcmd <pid> VM.native_memory summary
jcmd <pid> Thread.print
压测给吞吐与分位,NMT 给堆外趋势,线程转储给「CPU 花在哪」。三个一起看,才能区分「模型选错」「缓冲泄漏」「业务阻塞」这三类完全不同的原因。
不要做的事
- 不要在 macOS 上敲
ss,它不存在;用netstat -anv -p tcp代替。也不要把TIME_WAIT数量当连接泄漏指标,先看是谁先发的 FIN。 - 不要因为
lsof显示IPv6就断定监听在::,看NAME列的实际地址。 - 不要用
grep 9090过滤端口,19090、90900都会被匹配;用awk匹配.9090结尾。 - 不要把
reserved当 RSS,也不要把单次ab的数字当结论;没跑过的输出不要写成「本机输出」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。