常用 JDK 命令行工具(jps/jstat/jmap/jstack/jinfo)
问题
线上 Java 服务出现 CPU 飙高、内存泄漏、死锁、GC 频繁时,你手上最趁手的武器是什么?不用 IDE,不装图形工具,只靠一个 SSH 终端,如何快速定位问题?
核心答案
JDK 自带的 5 个命令行工具:jps、jstat、jmap、jstack、jinfo,是 Java 线上问题排查的第一梯队。它们无依赖、不侵入、不收钱,每个 JDK 发行版都自带。下面逐个拆解用法和坑。
这 5 个工具的底层数据来源各不相同:
- jps:读取
/tmp/hsperfdata_<user>/<pid>文件,该文件由 JVM 启动时创建 - jstat:通过 PerfData 共享内存区读取 JVM 内部计数器,JVM 每隔固定周期(通常 1ms)写入
- jmap / jstack / jinfo:通过 Attach API 发信号给目标 JVM,目标 JVM 调用
AttachListener线程执行回调(如HeapDumper、ThreadDumper) - jcmd:同样是 Attach API,但功能更统一,JDK 11+ 官方推荐
Attach API 的通信流程:发送方 → 创建 /tmp/.java_pid<pid> 文件 → 发送 SIGQUIT 信号 → 目标 JVM 启动 AttachListener 线程 → 建立 UNIX Domain Socket 连接 → 交换命令和数据。
1. jps — 找 Java 进程
jps(JVM Process Status Tool)等价于 ps aux | grep java 的加强版,列出当前机器上所有 Java 进程的 PID 和主类名。
# 基本用法
jps
# 输出完整包名
jps -l
# 输出 JVM 参数
jps -v
# 输出传递给 main 方法的参数
jps -m坑:jps 通过 /tmp/hsperfdata_<user> 目录下的文件来发现进程,如果该目录被删除(容器重启后常见),jps 就找不到任何进程。此时改用 ps aux | grep java 或直接用系统工具。
容器环境需要注意:Docker/K8s 下,Java 进程的 PID 在容器内可能是 1,且 /tmp 目录可能被挂载为 emptyDir 或 tmpfs,重启后 hsperfdata 丢失。建议在容器内用 ps 替代 jps。
JDK 9+ 的 jps 增强:JDK 9 开始,如果 /tmp 被挂载为 noexec,jps 会尝试通过 jcmd 枚举进程信息。但实测仍不稳定,首选 ps aux | grep java 或 pgrep java。
2. jstat — 看 GC 统计
jstat(JVM Statistics Monitoring Tool)是监控 GC 和类加载状态的利器,尤其在需要确认 GC 频率、耗时、各区容量时,比任何 GUI 工具都直接。
# 每 1 秒输出一次 GC 信息,共 5 次
jstat -gc <pid> 1000 5
# 输出 GC 摘要,含原因
jstat -gccause <pid> 1000
# 输出类加载统计
jstat -class <pid>-gc 的输出字段含义:
| 字段 | 含义 |
|---|---|
| S0C / S1C | Survivor 0/1 区容量(KB) |
| S0U / S1U | Survivor 0/1 区已使用(KB) |
| EC / EU | Eden 区容量/已使用 |
| OC / OU | 老年代容量/已使用 |
| MC / MU | 元空间容量/已使用 |
| YGC / YGCT | Young GC 次数/总耗时 |
| FGC / FGCT | Full GC 次数/总耗时 |
| GCT | 总 GC 耗时 |
生产环境技巧:jstat -gcutil 输出百分比,比绝对值更直观。连跑几轮看趋势,比单次快照更有价值。
jstat -gcutil <pid> 2000 10
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 0.00 45.2 67.3 89.1 85.0 124 8.342 2 0.521 8.863重点关注 E 区(Eden)占比是否在 GC 前一直很高、O 区(老年代)是否持续增长——O 区只涨不跌,大概率是内存泄漏。
G1 下的 jstat 差异:G1 不使用传统的 Eden/Survivor 分区概念,jstat -gc 的 S0/S1 始终为 0。改用 jstat -gcutil 看 E/O 的百分比,或通过 GC 日志获取更准确的信息。
实战案例:一次 jstat 定位的 GC 频繁问题
某 8C16G 服务,告警 RT 从 50ms 涨到 500ms,CPU 70%。
$ jstat -gcutil <pid> 2000 5
S0 S1 E O M YGC YGCT FGC FGCT GCT
0.0 0.0 98.0 65.0 90.0 500 12.0 5 3.0 15.0
0.0 0.0 2.0 65.0 90.0 501 12.1 5 3.0 15.1
0.0 0.0 98.0 65.0 90.0 502 12.1 5 3.0 15.1
...E 区从 98% 降到 2% 再回到 98%,每次间隔 2 秒,说明 Young GC 每 2 秒一次,每次恢复 96% 的 Eden 空间。YGCT 增长 ~100ms/次,GC 停顿占总时间的 5%。问题出在:每秒诞生的对象过多,Eden 区太小(默认约 4GB 堆的 1/3 = 1.3GB,每秒产生 650MB 以上对象才会 2 秒填满)。
解决:加了 -Xmn2g 扩大年轻代,Young GC 频率降到 4 秒一次。同时查出是 JSON 序列化在循环里创建了大量临时 byte[],改用 StringBuilder 复用后,频率降到 10 秒一次。
3. jmap — 看堆内存
jmap(Memory Map)有两个核心功能:打堆直方图和生成堆转储。
# 堆直方图(存活对象,会触发 Full GC,生产慎用!)
jmap -histo:live <pid> | head -30
# 堆直方图(不触发 Full GC,推荐)
jmap -histo <pid> | head -30
# 生成堆转储文件
jmap -dump:live,format=b,file=heap.hprof <pid>-histo 输出按对象占用大小排序,最前面的几行通常就是问题所在:
num #instances #bytes class name
----------------------------------------------
1: 124580 32456712 [B
2: 34589 8923456 [Ljava.lang.Object;
3: 234567 7890123 java.util.HashMap$Node看到 HashMap$Node 实例数异常多,就去查哪个 HashMap 在无限增长。
生产环境的大忌:
jmap -histo:live带:live会触发一次 Full GC,8GB 以上堆的 Full GC 停顿可达几十秒,线上服务直接超时。jmap -dump:live同理,大堆上慎用。- 替代方案:JDK 11+ 用
jcmd <pid> GC.heap_dump,默认不触发 Full GC。
为什么 :live 会触发 Full GC?
jmap -histo:live 内部调用 jmm_GetHeapHistogram() 时,JVM 会先执行一次 GC_locker::gc_locked() 触发的全量标记。具体流程:
- JVM 收到 jmap 请求 → 暂停所有应用线程(STW)
- 执行一次 Full GC(CMS 下是 Concurrent Mark,G1 下是 Mixed GC 或 Full GC)
- 只保留存活对象 → 统计存活对象直方图
- 恢复应用线程
堆直方图怎么读:重点关注 byte[]、char[]、HashMap$Node、ConcurrentHashMap$Node、ThreadLocal 相关类。这些是泄漏高频区。
实战案例:jmap 定位的 ThreadLocal 内存泄漏
$ jmap -histo <pid> | head -10
num #instances #bytes class name
----------------------------------------------
1: 3456789 123456789 [B
2: 234567 8923456 java.util.HashMap$Node
3: 123456 7890123 java.lang.ref.WeakReference
4: 98765 5678901 com.example.MyContextHolderMyContextHolder 有 98765 个实例,但业务上这个类应该每个请求一个、请求结束就释放。检查代码发现 ThreadLocal 在使用后没有调用 remove(),导致线程池复用时,线程持有的 ThreadLocalMap 里仍然挂载着旧请求的上下文对象。排查过程:
- jmap 直方图抓到可疑类
- MAT 打开堆转储,按 GC Root 路径追踪
- 发现所有
MyContextHolder都被ThreadLocalMap→Thread引用链持有 - 修复:在 finally 块加
threadLocal.remove()
4. jstack — 看线程栈
jstack(Stack Trace)是排查死锁、CPU 100%、线程阻塞的核心工具。它会输出所有线程的堆栈和状态。
# 输出所有线程栈
jstack <pid> > thread.dump
# 排查死锁,jstack 会自动检测死锁
jstack <pid> | grep -A 30 "deadlock"CPU 100% 排查标准流程:
# 1. 找到 CPU 最高的线程
top -Hp <pid>
# 2. 把 PID 转十六进制
printf "%x\n" <pid> # 输出如 0x3a7f
# 3. 在 jstack 输出中搜这个 nid
jstack <pid> | grep -A 30 "0x3a7f"更快的替代方案:Arthas 的 thread -n 3 直接显示最耗 CPU 的前 3 个线程,不必手动转十六进制。
jstack 输出快速解读:
"http-nio-8080-exec-1" #12 daemon prio=5 os_prio=0 tid=0x00007f...
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.putVal(HashMap.java:626)| 线程状态 | 常见含义 |
|---|---|
| RUNNABLE | 正在执行,可能死循环,也可能在等 CPU 调度 |
| BLOCKED | 等待锁,锁竞争严重 |
| WAITING / TIMED_WAITING | 等待条件满足(如 park()、Object.wait()),通常不是问题 |
大量 WAITING 且堆栈停在 ThreadPoolExecutor.getTask() | 线程序空闲,没有问题 |
大量线程 BLOCKED 在同一个锁上,说明锁竞争剧烈。日志里常见 java.util.logging 或 Logback 的 appender 锁——解决方案:上异步日志。
实战案例:jstack 定位的死锁
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8b8c006a00 (object 0x000000076b5f6d80, a java.lang.String),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00007f8b8c006b00 (object 0x000000076b5f6db0, a java.lang.String),
which is held by "Thread-1"jstack 自带的死锁检测会输出上述信息,包含两个线程互相等待的锁对象地址和持有者。标准解法:检查代码中锁的获取顺序是否一致。如果 A 方法先锁 resource1 再锁 resource2,B 方法先锁 resource2 再锁 resource1,必然死锁。确保所有线程以相同顺序获取锁即可。
实战案例:连接池耗尽排查
$ jstack <pid> | grep -c "BLOCKED"
342
$ jstack <pid> | grep "BLOCKED" | head -5
"http-nio-8080-exec-342" #342 prio=5 BLOCKED
- waiting to lock <0x000000076b5f6e00> (a org.apache.tomcat.jdbc.pool.ConnectionPool)
"http-nio-8080-exec-341" #341 prio=5 BLOCKED
- waiting to lock <0x000000076b5f6e00> (a org.apache.tomcat.jdbc.pool.ConnectionPool)342 个线程 BLOCKED 在同一个连接池锁上,说明数据库连接池耗尽,所有请求在等连接。检查连接池配置发现 maxActive=50,但应用有 200 个线程,高峰期大量线程在等连接。解决:maxActive=100 并增加数据库 max_connections。
jstack 的 JDK 版本差异:
- JDK 8:
jstack <pid>稳定可用,但输出的线程栈里没有 CPU 时间信息 - JDK 11: 输出格式微调,
nid从0x%x改成0x%X(大写),不影响 grep - JDK 17:
jstack已标记为 deprecated,官方推荐jcmd <pid> Thread.print - JDK 21+: 部分发行版不再打包
jstack,必须用jcmd
5. jinfo — 查 JVM 参数
jinfo(Configuration Info)可以查看和动态修改 JVM 参数(仅限可写参数)。
# 查看所有生效参数
jinfo -flags <pid>
# 查看单个参数值
jinfo -flag PrintGCDetails <pid>
# 动态开启 GC 日志(不需要重启!)
jinfo -flag +PrintGCDetails <pid>
jinfo -flag +PrintGCDateStamps <pid>
jinfo -flag +PrintGCApplicationStoppedTime <pid>
# 用完记得关掉
jinfo -flag -PrintGCDetails <pid>救命场景:线上服务 GC 异常但没开 GC 日志,加 -XX:+PrintGCDetails 意味着重启。用 jinfo 动态开启,不用重启,收集到足够日志后关闭。
注意:JDK 9+ 部分参数不可写,jinfo -flag 会报 Operation not supported。全部参数修改建议用 jcmd <pid> VM.set_flag。
哪些参数可写、哪些不可写?
JVM 启动参数分为三类:
| 类别 | 示例 | 动态修改 |
|---|---|---|
| 可写(manageable) | PrintGCDetails、MaxHeapFreeRatio | 支持 |
| 仅启动时设置 | Xmx、Xms、MetaspaceSize | 不支持 |
| 只读(诊断) | UseG1GC、UseCompressedOops | 不支持 |
jinfo -flag 只对第一类有效。想改 Xmx 必须重启。
生产环境组合拳(实战案例)
案例一:服务响应变慢,怀疑 GC 频繁
# 第 1 步:确认进程
jps -l
# 第 2 步:看 GC 频率和耗时
jstat -gcutil <pid> 2000 5
# 如果看到 YGCT 每秒增长超过 200ms,GC 太频繁
# 或 FGC 多且 FGCT 大 → 内存泄漏或堆太小
# 第 3 步:看线程是否在等锁
jstack <pid> | grep -c "BLOCKED"
# 如果 BLOCKED 线程数 > 总线程的 30%,锁竞争严重
# 第 4 步:看堆直方图(不带 live,不触发 Full GC)
jmap -histo <pid> | head -20
# 看到某个类实例数异常多 → 查哪里的业务代码在疯狂 new案例二:CPU 100%
# 1. top -Hp 找到最耗 CPU 的线程 PID
# 2. printf "%x\n" 转十六进制,jstack 里搜对应的 nid
# 3. 看栈顶:GC 线程 → 调堆;业务线程死循环 → 修代码
# 4. 如果看不出来,上 async-profiler 火焰图案例三:OOM 排查完整流程
某 4C8G 服务,2 小时内 OOM 3 次,K8s 自动重启。
第 1 步:重启后立刻 jstat -gcutil 看 O 区增长趋势
→ O 区每 10 分钟从 10% 涨到 80%,30 分钟后触发 Full GC
第 2 步:jmap -histo <pid> | head -15
→ 发现 byte[] 占 60% 堆,char[] 占 20%
→ 怀疑字符串或 IO 缓冲区泄漏
第 3 步:打开 GC 日志(jinfo -flag +PrintGCDetails)
→ 确认是 CMS 的 Concurrent Mode Failure
→ 老年代回收速度跟不上分配速度
第 4 步:jstack 看线程
→ 大量线程在读取 Kafka 消息,每条消息都解析成 JSON 字符串
→ 但 Kafka 消费逻辑里没有 commit 失败重试机制
第 5 步:最终定位
→ 上游发消息速度 2000 msg/s,消费者处理能力 500 msg/s
→ 消费端堆里积压了 30 万条未处理的消息对象
→ 修复:增加消费者分区数 + 限流 + 加熔断排查流程时序图
时间线 ──────────────────────────────────────────────────>
│
告警(RT 飙升) │
│ │
▼ │
步骤1: jps -l │ 确认 PID
│ │
▼ │
步骤2: jstat -gcutil│ GC 是否频繁?→ 是 → 调堆参数
│ │
▼ │
步骤3: top -Hp │ CPU 是否高?→ 是 → 定位线程
│ │
▼ │
步骤4: jstack │ 线程在干什么?→ 死锁/锁竞争/死循环
│ │
▼ │
步骤5: jmap -histo │ 内存泄漏?→ 生成堆转储 → MAT 分析
│ │
▼ │
结论 + 修复 │JDK 11+ 的 jcmd 替代方案
JDK 11 起,jcmd 可以取代大部分传统工具,且功能更强大、对生产更友好:
| 功能 | 传统命令 | jcmd 替代 | 优势 |
|---|---|---|---|
| 堆转储 | jmap -dump:live | jcmd <pid> GC.heap_dump | 不触发 Full GC |
| 线程栈 | jstack | jcmd <pid> Thread.print | 无差异 |
| 参数查看 | jinfo -flags | jcmd <pid> VM.flags | 输出更全 |
| 堆直方图 | jmap -histo:live | jcmd <pid> GC.class_histogram | 不触发 Full GC |
| GC 原因 | jstat -gccause | jcmd <pid> GC.run 或看 GC 日志 | 更底层 |
| 查看系统属性 | jinfo -sysprops | jcmd <pid> VM.system_properties | 无差异 |
| 查看JVM版本 | 无 | jcmd <pid> VM.version | 新增 |
jcmd 做堆转储不触发 Full GC,这是相比 jmap -dump:live 的显著优势,大堆上优先用。
jcmd 的更多用途
# 查看所有可用命令
jcmd <pid> help
# 查看 JVM 当前运行模式(解释/编译/混合)
jcmd <pid> VM.info
# 查看 JVM 启动参数
jcmd <pid> VM.flags -all
# 查看系统属性
jcmd <pid> VM.system_properties
# 设置 JVM 参数(同 jinfo -flag)
jcmd <pid> VM.set_flag PrintGCDetails true
# 触发 GC 日志轮转
jcmd <pid> GC.rotate_log
# 查看原生内存使用(需 -XX:NativeMemoryTracking=summary)
jcmd <pid> VM.native_memory summaryjcmd 实战:NMT 分析元空间内存泄漏
# 启动时开启 NMT(注意:开启后约 5% 性能开销,生产慎用)
-XX:NativeMemoryTracking=summary
# 线上查看(不要用 detail,detail 会 STW)
jcmd <pid> VM.native_memory summary
# 输出:
# Native Memory Tracking:
# Total: reserved=8192MB, committed=4096MB
# - Java Heap: reserved=4096MB, committed=4096MB
# - Class: reserved=1024MB, committed=512MB ← 元空间异常
# - Thread: reserved=512MB, committed=256MB
# - Code: reserved=256MB, committed=128MB
# - GC: reserved=256MB, committed=128MB
# - Compiler: reserved=16MB, committed=16MB
# - Internal: reserved=64MB, committed=32MB
# - Other: reserved=48MB, committed=24MBClass 占 512MB committed 远高于正常值(正常 100-200MB),说明动态类生成过多。检查发现 CGLIB 代理类在全量扫描时不断生成,修复:加上 -XX:MaxMetaspaceSize=256M 作为上限,防止撑爆元空间。
jcmd 的 Thread.print 与 jstack 的差异
# jstack 输出
"pool-1-thread-1" prio=5 tid=0x00007f...
java.lang.Thread.State: WAITING (parking)
# jcmd Thread.print 输出(JDK 11+)
"pool-1-thread-1" #17 prio=5 os_prio=0 cpu=0.32ms elapsed=1245.67s tid=0x00007f...
java.lang.Thread.State: WAITING (parking)jcmd 比 jstack 多输出 cpu= 和 elapsed= 两个字段,可以直接看到线程的 CPU 时间和存活时间,不再需要 top -Hp 配合。
容器化环境下的替代方案
K8s/Docker 环境下,传统 JDK 工具经常不可用:
| 问题 | 原因 | 替代方案 |
|---|---|---|
| jps 找不到进程 | /tmp 被清空或 noexec | ps aux | grep java 或 pgrep java |
| jmap 连接失败 | 容器没有 SIGQUIT 权限 | jhsdb jmap --pid <pid>(JDK 9+) |
| jstack 连不上 | Attach API 的 Socket 文件被删 | jhsdb jstack --pid <pid> |
| 堆转储过大 | 容器内磁盘空间有限 | 挂载 emptyDir 或 PVC 后转储,或 jcmd 直接输出到 stdout |
jhsdb(JDK 9+ 的救命工具):
jhsdb(Java HotSpot Debugger)不依赖 Attach API,直接通过 ptrace 系统调用读取目标进程内存。即使目标 JVM 卡死、OOM 后假死,jhsdb 依然可用。
# 堆直方图(不触发 Full GC,不依赖 Attach API)
jhsdb jmap --heap --pid <pid>
# 堆转储
jhsdb jmap --binaryheap --pid <pid>
# 线程栈
jhsdb jstack --pid <pid>关键区别:jhsdb 会暂停目标进程(ptrace stop),但不会触发 Full GC。对于已经卡死或 OOM 的服务,这是唯一能获取堆信息的途径。
面试常问:这 5 个工具的底层原理
jps 为什么有时候找不到进程?
jps 不依赖 PID 文件扫描,而是通过 /tmp/hsperfdata_<user>/ 目录。JVM 启动时会在该目录创建 hsperfdata_<pid> 文件,jps 枚举该目录下的文件名 → 提取 PID。容器化环境下,容器重启后 /tmp 被清空,jps 失效。
jstat 的数据从哪里来?
JVM 内部维护一组 PerfData 计数器(PerfDataManager),每秒更新多次。这些计数器通过 mmap 映射到共享内存,jstat 通过 libjvm.so 的 PerfData API 读取。因此 jstat 不会影响目标 JVM 的性能(只读共享内存,无 IPC)。
jmap/jstack/jinfo 如何与 JVM 通信?
它们通过 Attach API 实现跨进程通信。流程:
- 发送方尝试连接
/tmp/.java_pid<pid>(UNIX Domain Socket) - 如果文件不存在,发送 SIGQUIT 信号给目标 JVM
- 目标 JVM 的
Signal Dispatcher线程收到信号 → 创建AttachListener线程 AttachListener创建/tmp/.java_pid<pid>并监听- 发送方连接这个 Socket → 发送命令 → 接收结果 → 断开连接
所以当容器内没有 kill 权限或 SIGQUIT 被屏蔽时,Attach API 会失败。
jmap -histo:live 和 jmap -histo 的区别除了 Full GC 还有啥?
-histo:live 只统计存活对象,意味着 GC 后未回收的对象才是你的"问题"。-histo 统计所有对象(包括已不可达但未回收的)。如果 -histo 里有大量对象但 -histo:live 里没有,说明这些对象在下次 GC 时会被回收,不是内存泄漏。但正如前面所说,生产上别用 :live,看趋势就行。
jcmd 的 GC.heap_dump 真的不触发 Full GC 吗?
严格说:jcmd 的堆转储默认走 DumpHeap 操作,它不会触发 Full GC,但会持有堆锁(Heap_lock)导致应用线程短暂停顿(通常 < 100ms)。而 jmap -dump:live 在 -XX:+DisableExplicitGC 未设置时,会在 dump 前调用 System.gc() 触发 Full GC。所以大堆上,jcmd 确实是更安全的选择。
总结
| 工具 | 用途 | 生产风险 | 注意 |
|---|---|---|---|
| jps | 找进程 | 无 | 容器环境可能找不到 |
| jstat | 看 GC 趋势 | 无 | -gcutil 最实用,关注 O 区是否只涨不跌 |
| jmap | 直方图和堆转储 | 高(:live 触发 Full GC) | 不带 :live,大堆用 jcmd 替代 |
| jstack | 线程栈 | 低(STW < 10ms) | CPU 100% 和死锁的首选工具 |
| jinfo | 动态开关参数 | 低 | 不开 GC 日志时可以救命 |
| jcmd (JDK 11+) | 一换五 | 低 | 功能更强且对生产更友好 |
| jhsdb (JDK 9+) | 卡死服务排查 | 中(ptrace stop) | 传统工具连不上时的最后手段 |
这几个工具不要求你背参数,但要求你在 SSH 终端里能盲打出排查流程。线上出问题的时候,IDE 连不上、图形工具装不上,就靠这 5 个工具救命。
面试加分项:能用 Attach API 通信流程说出 jmap/jstack 的底层原理、能区分 :live 触发 Full GC 的源码级原因、知道容器环境下 jps 失效的根因、能说出 jcmd 和 jhsdb 的适用场景差异——面试官会另眼相看。