线上服务变慢、内存不释放、线程卡住,第一反应往往是"加个 APM"。但在没有监控平台的机器上,JDK 自带的两件工具就够完成一次现场取证:jconsole 负责连接看数,visualvm 负责采样和转储。它们走的是 JMX 协议,不需要改一行代码,也不需要重启。
这篇把两件工具的取证路径拆成四步:找进程 → 建立连接 → 取什么数据 → 数据怎么读。
1. 第一步:找进程与确认 JDK 身份
$ jps -lv
5123 clarity-web.jar -Xms512m -Xmx512m -Dspring.profiles.active=prod
8471 Jps
| 列 | 含义 | 取证价值 |
|---|---|---|
| PID | 进程号 | 后续所有命令的入口 |
| 主类/jar | 启动目标 | 确认连的是不是那个服务 |
-l 参数 |
输出完整包名或 jar 路径 | 有 jar 名才敢确认是哪个应用 |
-v 参数 |
输出 JVM 启动参数 | 直接读到 -Xmx、GC、JMX 配置 |
jps只列出当前用户有权限的 Java 进程。用 root 跑jps看不到别的用户起的进程,这是权限问题不是进程不存在。按 JDK 17 核对过该行为。
如果 jps 输出为空但服务确实在跑,先确认 java 命令和进程用的是同一个 JDK:
$ which java && java -version
$ ls -l /proc/5123/exe # 进程真实使用的 java 可执行文件
/proc/<pid>/exe 指向的路径才是进程实际运行的 JVM,which java 只反映当前 shell 的 PATH。
2. 第二步:JConsole 的连接方式
2.1 本地连接(同机、同用户)
$ jconsole 5123
不带参数时 jconsole 会列出本机可附加的进程。本地连接靠的是 Attach API(com.sun.tools.attach),要求同用户、同 JDK 大版本。跨用户会看到空列表,跨 JDK 版本可能报 AttachNotSupportedException。
2.2 远程连接(JMX over RMI)
远程必须让 JVM 先开 JMX 端口,这需要重启或在启动脚本里补参数:
java -Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.port=9010 \
-Dcom.sun.management.jmxremote.rmi.port=9010 \
-Dcom.sun.management.jmxremote.local.only=false \
-Dcom.sun.management.jmxremote.authenticate=true \
-Dcom.sun.management.jmxremote.password.file=/app/jmx/password \
-Dcom.sun.management.jmxremote.ssl=false \
-jar clarity-web.jar
| 参数 | 作用 | 不写会怎样 |
|---|---|---|
jmxremote.port |
注册表端口(RMI registry) | 连不上 |
jmxremote.rmi.port |
实际数据端口 | 注册表返回随机端口,防火墙挡住 → 连上后立刻断开 |
local.only=false |
允许非回环地址连接 | 远程连接被拒 |
authenticate |
是否校验用户名密码 | true 时必须有 password 文件 |
ssl |
是否加密 | 留 false 只应出现在内网 |
最容易踩的坑:只写了 jmxremote.port 没写 rmi.port。表现是"JConsole 显示连接成功,然后立即无数据"。原因是 RMI registry 会把一个随机端口回给客户端,而那个端口没在防火墙上放行。
2.3 生产环境的安全底线
authenticate=false + ssl=false + 公网 IP,等于把 JVM 的 MBean 全开给任何人——通过 DiagnosticCommand MBean 可以直接执行 jcmd 命令(包括触发 GC、导出堆、改日志级别)。要么走 SSH 隧道只绑回环:
# 服务器只绑 127.0.0.1,客户端建隧道
ssh -L 9010:127.0.0.1:9010 blog
jconsole 127.0.0.1:9010
要么老老实实配 password 文件(权限必须是 600,否则 JVM 拒绝启动并报 FileNotFoundException/权限错误)。
3. 第三步:取哪四类数据
进 JConsole 后界面有六个标签页,真正用于取证的只有四类:
| 数据 | 位置 | 看什么 | 异常信号 |
|---|---|---|---|
| 堆与 GC | 「内存」标签 | 各代使用曲线、执行 GC 按钮 |
老年代只涨不落 → 泄漏候选 |
| 线程 | 「线程」标签 | 线程数、检测死锁 |
线程数持续单调上升 → 池泄漏 |
| 类加载 | 「类」标签 | 已加载类数 | 类数随请求线性增长 → 动态代理/脚本引擎泄漏 |
| MBean | 「MBean」标签 | java.lang:type=Memory、Threading |
取精确计数而非曲线目测 |
「执行 GC」按钮不是调优手段。它触发的是 System.gc() 语义的一次 Full GC,只在取证时用来判断"这块内存是不是可回收的":点完立刻回落 → 是垃圾只是没到回收时机;点完不回落 → 真泄漏。
3.1 线程转储:一次点击拿到全栈
「线程」标签右下角的「线程 Dump」会打印全部线程栈。它等价于:
$ jstack 5123 > /tmp/thread-$(date +%H%M).txt
$ jcmd 5123 Thread.print > /tmp/thread-2.txt
jstack 与 jcmd Thread.print 都能出栈,后者是 JDK 7 之后推荐的方式(jstack 在部分 JDK 版本对 -F 强制模式有兼容问题)。连续抓三次、间隔 5 秒,三次都停在同一行代码的线程才是真热点;只抓一次很容易误判。
3.2 堆转储:只抓一次,文件很大
$ jcmd 5123 GC.heap_dump /tmp/heap.hprof
或在 JConsole 的「内存」标签点「堆 Dump」。注意三点:
- 会 STW,堆越大停得越久,线上要避开高峰;
- 文件大小约等于堆使用量,2G 堆就是 2G 文件;
- 导出前先确认磁盘余量,写满磁盘的后果比 OOM 更麻烦。
拿到 hprof 后用 jhat(已废弃)、MAT 或 VisualVM 打开分析。
4. 第四步:VisualVM 的采样与分析
4.1 安装与插件
VisualVM 从 JDK 9 起不再随 JDK 分发,需要单独下载。启动后「工具 → 插件」里可以装:
| 插件 | 用途 |
|---|---|
| Visual GC | 各代区域实时图(比 JConsole 曲线细) |
| Sampler | CPU/内存采样 |
| Threads Inspector | 线程按状态聚合 |
| OQL 控制台 | 对堆快照做类 SQL 查询 |
4.2 采样器 vs 分析器:先采样
| 模式 | 原理 | 开销 | 用在哪 |
|---|---|---|---|
| 采样(Sampler) | 周期性抓栈统计 | 低(1–3%) | 先定位"哪个方法占 CPU" |
| 分析(Profiler) | 字节码插桩,统计每次调用 | 高,可能 10 倍以上 | 采样看不出细节时再用 |
生产环境的顺序必须是:采样 → 看出热点 → 才考虑是否上分析器。直接插桩在生产上跑,等于人为制造一次性能事故。
4.3 CPU 采样读数
采样结果按"自身耗时 / 总耗时"两列排。判断规则:
- 自身耗时占比高的方法才是热点(它自己耗 CPU);
- 总耗时高但自身耗时低的方法只是"调用链上的过客",往下钻才是真热点;
- 占比前几的方法如果都在
Object.wait/LockSupport.park,说明瓶颈不在 CPU,而是等锁或等 IO——该去线程页看阻塞。
4.4 内存采样与「对象为什么没被回收」
内存采样能看到各类型实例数与占用,但要回答"谁在引用它",采样不够,需要堆快照 + 支配树(Dominator Tree):
| 视图 | 回答的问题 |
|---|---|
| 类直方图 | 哪个类型实例最多、占多少字节 |
| 支配树 | 哪个对象的持有量最大(删掉它能释放多少) |
| 引用链(Path to GC Roots) | 从 GC Root 到该对象的完整引用路径 |
| OQL | select * from java.lang.String s where s.count > 1000 |
排查泄漏的标准动作:类直方图找出异常大的类 → 支配树确认持有量 → 引用链看是谁抓着不放 → 回到代码找对应的静态字段/缓存/监听器注册。
5. 排错:五种连不上/看不懂的情况
| 现象 | 原因 | 处理 |
|---|---|---|
jps 看不到目标进程 |
不同用户 / 容器内 namespace 隔离 | 用 docker exec 进容器跑,或以对应用户执行 |
| 本地 JConsole 列表为空 | JDK 版本不一致、Attach 被禁 | 确认 java.home 一致;检查 -XX:+DisableAttachMechanism 是否被加上 |
| 远程连上后立即断开 | 只配了 jmxremote.port,随机数据端口被防火墙拦 |
补 jmxremote.rmi.port 并放行,或改用 SSH 隧道 |
报 Authentication failed |
password 文件权限不是 600、用户名不在文件里 | chmod 600,用户名必须是启动 JVM 的系统用户 |
| 采样结果里全是 native 方法 | 采样只覆盖 Java 栈 | 结合 perf/async-profiler 看 native 与内核态 |
最后一条值得单独强调:JConsole 与 VisualVM 看不进 native 与内核。JVM 自己的工具在"纯 Java 计算"场景很好用;如果是 JNI、GC 线程、IO 密集,需要 async-profiler、perf、bcc 这类工具补齐。
6. 边界:什么情况本就该用别的工具
| 场景 | 首选工具 | 理由 |
|---|---|---|
| 线上临时取一次数 | jcmd + jstack |
无需 GUI、无需开端口、可脚本化 |
| 需要历史曲线与告警 | Prometheus + Micrometer | JConsole 只能看当下 |
| GC 停顿归因 | -Xlog:gc* + GCViewer |
GC 日志才有每次回收的精确停顿 |
| native/内核态热点 | async-profiler / perf | JVM 工具看不到 |
| 容器内且无 JDK 工具 | jattach 或 cgroup 指标 |
精简镜像常不装 jstack |
jcmd 在这里是"最被低估"的一个——它把 JConsole 里大部分操作做成了命令行:
$ jcmd 5123 help # 列出所有可用子命令
$ jcmd 5123 VM.flags # 实际生效的 JVM 参数
$ jcmd 5123 GC.class_histogram # 类直方图(会 STW 一小下)
$ jcmd 5123 Thread.print -l # 带锁信息的线程栈
$ jcmd 5123 VM.system_properties # 系统属性
一句话选择:要交互看数用 JConsole,要脚本留证用 jcmd,要深挖内存用 VisualVM + MAT。
7. 自测
- 线上机器
jps看不到你的服务进程,最先查什么? - 远程 JConsole 连接成功却没有任何数据,最可能是哪个参数漏了?
- 「执行 GC」按钮点完老年代没降,说明什么?
- 线程转储为什么要连续抓三次?
- CPU 采样里占比最高的是
LockSupport.park,下一步该看哪一页? - 引用链(Path to GC Roots)用来回答什么问题?