《Java 网络编程基础》附录 B 网络诊断命令清单

《Java 网络编程基础》附录 B:macOS 26.3 上可直接复制的网络诊断命令,覆盖 netstat -anv -p tcp、lsof -nP -iTCP:9090、sudo tcpdump -i lo0、nc、curl -v、ab、jcmd Thread.print 与 VM.native_memory,每条给用途、关键列读法与常见误判。

这份清单只收本卷真正跑过的命令,格式固定:可复制的完整命令、一段输出、读哪几列、哪里会看错。输出分两种标注:本机输出是在 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 / shiwatsocket 缓冲的历史高水位不是当前占用:本机从 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 的数字当结论;没跑过的输出不要写成「本机输出」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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