2607 字
约 8 分钟
22
S05-X02 · VisualVM 与 JConsole:不装 Agent 的 JVM 现场取证
2024-12-31
2026-11-08

线上服务变慢、内存不释放、线程卡住,第一反应往往是"加个 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」。注意三点:

  1. 会 STW,堆越大停得越久,线上要避开高峰;
  2. 文件大小约等于堆使用量,2G 堆就是 2G 文件;
  3. 导出前先确认磁盘余量,写满磁盘的后果比 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. 自测

  1. 线上机器 jps 看不到你的服务进程,最先查什么?
  2. 远程 JConsole 连接成功却没有任何数据,最可能是哪个参数漏了?
  3. 「执行 GC」按钮点完老年代没降,说明什么?
  4. 线程转储为什么要连续抓三次?
  5. CPU 采样里占比最高的是 LockSupport.park,下一步该看哪一页?
  6. 引用链(Path to GC Roots)用来回答什么问题?
S05-X02 · VisualVM 与 JConsole:不装 Agent 的 JVM 现场取证
https://www.clxhxhhr.top/posts/5645/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。