Skip to content

常用 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 线程执行回调(如 HeapDumperThreadDumper
  • 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 和主类名。

bash
# 基本用法
jps

# 输出完整包名
jps -l

# 输出 JVM 参数
jps -v

# 输出传递给 main 方法的参数
jps -m

:jps 通过 /tmp/hsperfdata_<user> 目录下的文件来发现进程,如果该目录被删除(容器重启后常见),jps 就找不到任何进程。此时改用 ps aux | grep java 或直接用系统工具。

容器环境需要注意:Docker/K8s 下,Java 进程的 PID 在容器内可能是 1,且 /tmp 目录可能被挂载为 emptyDirtmpfs,重启后 hsperfdata 丢失。建议在容器内用 ps 替代 jps。

JDK 9+ 的 jps 增强:JDK 9 开始,如果 /tmp 被挂载为 noexec,jps 会尝试通过 jcmd 枚举进程信息。但实测仍不稳定,首选 ps aux | grep javapgrep java


2. jstat — 看 GC 统计

jstat(JVM Statistics Monitoring Tool)是监控 GC 和类加载状态的利器,尤其在需要确认 GC 频率、耗时、各区容量时,比任何 GUI 工具都直接。

bash
# 每 1 秒输出一次 GC 信息,共 5 次
jstat -gc <pid> 1000 5

# 输出 GC 摘要,含原因
jstat -gccause <pid> 1000

# 输出类加载统计
jstat -class <pid>

-gc 的输出字段含义:

字段含义
S0C / S1CSurvivor 0/1 区容量(KB)
S0U / S1USurvivor 0/1 区已使用(KB)
EC / EUEden 区容量/已使用
OC / OU老年代容量/已使用
MC / MU元空间容量/已使用
YGC / YGCTYoung GC 次数/总耗时
FGC / FGCTFull GC 次数/总耗时
GCT总 GC 耗时

生产环境技巧jstat -gcutil 输出百分比,比绝对值更直观。连跑几轮看趋势,比单次快照更有价值。

bash
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)有两个核心功能:打堆直方图和生成堆转储。

bash
# 堆直方图(存活对象,会触发 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() 触发的全量标记。具体流程:

  1. JVM 收到 jmap 请求 → 暂停所有应用线程(STW)
  2. 执行一次 Full GC(CMS 下是 Concurrent Mark,G1 下是 Mixed GC 或 Full GC)
  3. 只保留存活对象 → 统计存活对象直方图
  4. 恢复应用线程

堆直方图怎么读:重点关注 byte[]char[]HashMap$NodeConcurrentHashMap$NodeThreadLocal 相关类。这些是泄漏高频区。

实战案例: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.MyContextHolder

MyContextHolder 有 98765 个实例,但业务上这个类应该每个请求一个、请求结束就释放。检查代码发现 ThreadLocal 在使用后没有调用 remove(),导致线程池复用时,线程持有的 ThreadLocalMap 里仍然挂载着旧请求的上下文对象。排查过程:

  1. jmap 直方图抓到可疑类
  2. MAT 打开堆转储,按 GC Root 路径追踪
  3. 发现所有 MyContextHolder 都被 ThreadLocalMapThread 引用链持有
  4. 修复:在 finally 块加 threadLocal.remove()

4. jstack — 看线程栈

jstack(Stack Trace)是排查死锁、CPU 100%、线程阻塞的核心工具。它会输出所有线程的堆栈和状态。

bash
# 输出所有线程栈
jstack <pid> > thread.dump

# 排查死锁,jstack 会自动检测死锁
jstack <pid> | grep -A 30 "deadlock"

CPU 100% 排查标准流程

bash
# 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.loggingLogback 的 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: 输出格式微调,nid0x%x 改成 0x%X(大写),不影响 grep
  • JDK 17: jstack 已标记为 deprecated,官方推荐 jcmd <pid> Thread.print
  • JDK 21+: 部分发行版不再打包 jstack,必须用 jcmd

5. jinfo — 查 JVM 参数

jinfo(Configuration Info)可以查看和动态修改 JVM 参数(仅限可写参数)。

bash
# 查看所有生效参数
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)PrintGCDetailsMaxHeapFreeRatio支持
仅启动时设置XmxXmsMetaspaceSize不支持
只读(诊断)UseG1GCUseCompressedOops不支持

jinfo -flag 只对第一类有效。想改 Xmx 必须重启。


生产环境组合拳(实战案例)

案例一:服务响应变慢,怀疑 GC 频繁

bash
# 第 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%

bash
# 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:livejcmd <pid> GC.heap_dump不触发 Full GC
线程栈jstackjcmd <pid> Thread.print无差异
参数查看jinfo -flagsjcmd <pid> VM.flags输出更全
堆直方图jmap -histo:livejcmd <pid> GC.class_histogram不触发 Full GC
GC 原因jstat -gccausejcmd <pid> GC.run 或看 GC 日志更底层
查看系统属性jinfo -syspropsjcmd <pid> VM.system_properties无差异
查看JVM版本jcmd <pid> VM.version新增

jcmd 做堆转储不触发 Full GC,这是相比 jmap -dump:live 的显著优势,大堆上优先用。

jcmd 的更多用途

bash
# 查看所有可用命令
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 summary

jcmd 实战: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=24MB

Class 占 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 被清空或 noexecps aux | grep javapgrep 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 依然可用。

bash
# 堆直方图(不触发 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.soPerfData API 读取。因此 jstat 不会影响目标 JVM 的性能(只读共享内存,无 IPC)。

jmap/jstack/jinfo 如何与 JVM 通信?

它们通过 Attach API 实现跨进程通信。流程:

  1. 发送方尝试连接 /tmp/.java_pid<pid>(UNIX Domain Socket)
  2. 如果文件不存在,发送 SIGQUIT 信号给目标 JVM
  3. 目标 JVM 的 Signal Dispatcher 线程收到信号 → 创建 AttachListener 线程
  4. AttachListener 创建 /tmp/.java_pid<pid> 并监听
  5. 发送方连接这个 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 的适用场景差异——面试官会另眼相看。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。