排查Java线上问题,很多人习惯先开监控平台,再拉ARMS、Prometheus,可一旦到了容器环境或者只有一台裸机,手里只剩JDK本身的时候,你第一反应是什么?我见过不少同事连jps都不熟,更别提jstat、jmap、jstack这三兄弟。其实JDK自带的这几个命令行工具,从来都是Java性能排查里最朴素也最扎实的一层——不需要额外安装包,不受网络限制,只要机器上有JDK就能直接开干。这篇内容就把jstat、jmap、jstack从原理到命令再到实战完整过一遍,适合刚接触Java问题排查的新人,也适合平时依赖各种监控平台、突然面对"裸奔"环境卡壳的老手。
这三个工具各管一摊:jstat看的是JVM运行时的统计信息,GC情况、类加载、编译数据都在里面;jmap管内存,能把堆里的对象分布翻个底朝天,也能直接导出堆快照;jstack抓线程运行现场,所有线程此刻卡在哪一行、锁被谁持有,一清二楚。把这三样组合起来,等于给一个运行中的JVM做一次"心电图+CT+现场采访"式的全面体检。
1. 排查问题的"三把手术刀":jstat、jmap、jstack各自管哪一摊
1.1 这三兄弟为什么到现在还不过时
JDK里可视化监控工具不少,老的有jconsole、jvisualvm,后来还有Java Flight Recorder,但命令行三件套的地位一直没被撼动。原因很简单:它们轻、快、不挑环境。拿jconsole来说,走JMX协议做远程连接,在生产环境要开端口、配认证,还得担心防火墙拦截;jvisualvm在JDK 9之后被从默认发行包拆出去了,还得单独下载。而jstat、jmap、jstack是实实在在跟着JDK走的本机诊断工具,一条命令下去,结果直接打到终端,没有任何中间链路。
这个特性意味着,只要你能登录到应用所在的机器,就能用它们。Docker容器里能敲命令吗?能,哪怕容器里只有一个精简的JRE镜像,只要java进程在,绝大多数情况下这三条命令都能跑。K8s环境里最常用的排查手段之一,就是先kubectl exec进Pod,再用jstack抓线程栈,配合top -Hp看CPU,快速定位问题。占资源?这三个工具瞬时占用极低,jstack抓一次栈也就几十毫秒的事,对运行中的服务几乎无感,这也是它们能在高负载环境里安全使用的前提。
还有一个被忽视的价值:它们是"无平台依赖"的。企业内部监控系统往往只覆盖核心业务应用,一些边缘服务、定时任务、数据处理脚本根本没有监控。突然告警了,你总不能先花两小时给它配一套监控吧?这时候直接登录机器用JDK自带工具排查,是最快的路径。
1.2 使用前的几个基础认知
在动手敲命令之前,有几个概念必须先捋清楚,否则命令打出一大堆输出,你也只能干瞪眼。
第一个是PID。三个工具的核心参数都是目标Java进程的进程号,直接用jps拿。jps -l能列出当前机器上所有Java进程的完整主类名或JAR包路径,比ps -ef | grep java更干净,因为它只筛Java进程,不会把其他语言的进程捞进来。注意一点,jps是依赖进程启动时留下的临时文件来识别进程的,如果Java进程被kill -9过或者用某些特殊方式启动,jps偶尔会认不齐,这时候用ps -ef | grep java兜底就行。
第二个是JDK的bin目录路径。正常情况下直接在终端敲jstat就行,但如果你的Java是从tar.gz包解压安装的,环境变量又没配好,终端会用系统的/usr/bin/jstat,而这个符号链接可能指向一个旧版本或错误的JDK目录。最稳妥的办法是定位到你实际使用的Java安装目录,用全路径执行,避免工具版本和运行中的JVM不匹配导致连不上。
第三个是权限问题。这点在排查时特别容易踩。用jstat或jmap去连接一个属于其他用户的Java进程时,会报sun.jvm.hotspot.debugger.DebuggerException: Cant attach to the process。原因很简单,Attach机制需要读取/tmp/hsperfdata_<用户名>目录下的性能数据文件,或者需要通过ptrace系统调用来attach。也就是说,排查用的账号通常得和目标进程使用同一个用户,或者干脆用sudo -u切到进程所属用户再执行命令。在有容器隔离的环境里,这一条尤其重要,很多"命令连不上"的报错都是权限引发的,不是工具坏了。
2. jstat:JVM运行状态的心电图
2.1 jstat的核心参数与常规用法
jstat全名是JVM Statistics Monitoring Tool,核心用途是实时读取JVM内的统计信息。它不会像jmap那样把整个堆打印一遍,而是像心电图一样持续输出采样数据,特别适合观察趋势。命令格式如下:
jstat -<选项> -t <PID> [<间隔毫秒> [<采样次数>]]其中-t表示输出从JVM启动到现在的运行秒数,做趋势分析时很有用。间隔毫秒和采样次数配合使用,比如想让数据每秒输出一次、持续输出10条,就可以写:
jstat -gcutil -t 12345 1000 10最常用的选项有这么几类:
| 选项 | 功能 | 适合场景 |
|---|---|---|
-gcutil | 各内存区域使用率及GC次数、耗时 | 日常体检、GC问题初查 |
-gc | 各内存区域的容量与使用量 | 需要看具体容量大小时 |
-class | 类加载/卸载数量 | 排查类加载泄漏、频繁卸载异常 |
-compiler | JIT编译统计 | 观察编译压力 |
-printcompilation | 最近编译的方法 | 配合compiler做深度排查 |
我平时用得最多的是-gcutil,因为它把老年代、新生代、MetaSpace的使用率直接折算成百分比,一眼就能看出哪个区域在异常上涨。如果还需要确认涨的是"容量"还是"使用量",再用-gc看具体字节数。
2.2 一条命令看懂GC情况与类加载
跑一条实际命令感受一下输出长什么样:
jstat -gcutil 28451 1000 5 S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT 11.25 0.00 64.70 45.12 92.33 88.70 8526 98.421 3 1.024 2 0.021 99.466 10.80 0.00 68.42 45.20 92.33 88.70 8526 98.421 3 1.024 2 0.021 99.466 12.31 0.00 72.55 45.28 92.33 88.70 8526 98.421 3 1.024 2 0.021 99.466 12.90 0.00 76.31 45.35 92.33 88.70 8526 98.421 3 1.024 2 0.021 99.466 13.36 0.00 79.57 45.40 92.33 88.70 8526 98.421 3 1.024 2 0.021 99.466逐列拆给你看。S0和S1是幸存区Survivor的使用率,注意它显示的是百分比,不是容量。E是Eden区使用率,O是老年代使用率,M是MetaSpace使用率。CCS是压缩类空间使用率,JDK 8以后才有,通常和MetaSpace一起变化。YGC是Young GC次数,YGCT是Young GC累计耗时,单位秒。FGC是Full GC次数,FGCT是Full GC累计耗时。CGC是并发GC次数,一般在CMS或G1下才有数据,CGCT是并发GC累计耗时。GCT是GC总耗时。
怎么快速判读?如果FGC蹭蹭往上涨,说明老年代持续被打满,几乎可以断定发生了内存泄漏或堆配置不合理。如果YGC频繁到每秒好几次,说明Eden区太小或者对象分配速率过高,小对象不断被创建,加剧了GC压力。还有一个容易看漏的细节:GCT里如果YGCT占比非常高,说明Minor GC都花在复制存活对象上,往往意味着Survivor区放不下,存活对象过早晋升到老年代。
另外jstat -class也值得记住,命令是:
jstat -class 28451 Loaded Bytes Unloaded Bytes Time 21445 40151.6 441 421.8 4.27Loaded是从启动到现在累计加载的类数量。如果服务运行时间不长,这个数却在以肉眼可见的速度增长,基本可以把类加载泄漏列为高度怀疑对象——比如频繁发布脚本、反复创建URLClassLoader又没有释放。
2.3 jstat输出里的关键信号与判读方法
光会看单次输出还不够,关键在对比。我的习惯是拉几组不同时间点的数据放一起看,比如12个小时前、当前、以及刚启动时。如果老年代使用率从20%慢慢爬到了80%,哪怕FGC次数没怎么涨,这也是个危险信号——说明有对象正在缓慢堆积,最终会触发一次代价高昂的Full GC。
值得警惕的三种组合:
- Eden使用率高 + YGC极频繁:典型的新生代对象分配过快。优先看代码里是否有大数组、批量对象、JSON序列化循环等在热路径上频繁创建临时对象。我自己遇到过最典型的案例,是在一个定时任务里循环创建
ObjectMapper实例,结果YGC从每分钟几次飙到每秒一次。 - 老年代持续攀升 + FGC间隔越来越短:基本可以判断是内存泄漏。老年代回收后使用率反弹得越快,泄漏越严重。
- MetaSpace使用率持续增长:除了动态生成类的框架(CGLIB、ASM)、Groovy表达式缓存这类常见原因外,还要检查是不是有自定义类加载器没有被卸载干净。
jstat -class里的Unloaded列如果长时间不动,说明泄漏的类连卸载的机会都没有。
这里必须提醒一句:jstat给出的只是统计指标,它告诉你"哪里不正常",但不会告诉你"为什么不正常"。比如老年代涨了,到底是谁占的?这个问题就要交给下一节的主角jmap来回答了。
3. jmap:把堆内存的"家底"翻出来
3.1 jmap -heap:先看堆配置再说别的
jmap的全称是Java Memory Map,作用是对JVM内存做"透视"。第一件值得做的事就是用jmap -heap看一眼当前堆的配置和实时使用情况:
jmap -heap 28451 Attaching to process ID 28451, please wait... Debugger attached successfully. Server compiler detected. JVM version is 25.321-b07 using thread-local object allocation. G1 GC with 8 thread(s) of collectors (concurrent GC mode) Heap Configuration: MinHeapFreeRatio = 40 MaxHeapFreeRatio = 70 MaxHeapSize = 4294967296 (4096.0MB) NewSize = 1048576000 (1000.0MB) MaxNewSize = 2576351232 (2457.0MB) OldSize = 2097152000 (2000.0MB) ... Heap Usage: G1 Heap: regions = 4096 capacity = 4294967296 (4096.0MB) committed = 4294967296 (4096.0MB) used = 2502295552 (2387.0MB)这个命令的价值不在于提供新信息,而在于确认"JVM实际启动参数到底生效了什么"。现实里经常出现这种情况:-Xmx在启动脚本里写了4G,但通过jmap -heap一看,MaxHeapSize只有2G,说明参数落到了别的JVM上,或者被后一个参数覆盖了。排查内存问题不先确认堆配置,后面所有的分析都可能是空中楼阁。
注意一点,jmap -heap的输出在JDK 8时代能显示完整的堆配置和分代详情,到了JDK 11以后,部分信息被简化,有些场景下直接建议改用jcmd GC.heap_info。但如果你还在用JDK 8,这个命令仍然是快速确认堆状态的利器。
3.2 jmap -histo:谁占着内存不掉头
jmap -heap告诉我们"用了多少",jmap -histo则回答"都是什么对象"。生产环境里最常执行的是带live前缀的版本:
jmap -histo:live 28451 | head -40输出是对象数量和占用空间的降序排列,看完前面二三十行基本就能锁定大头:
num #instances #bytes class name ---------------------------------------------- 1: 4021897 324892712 [B 2: 892341 120856432 [Ljava.lang.Object; 3: 120456 56002408 java.util.HashMap$Node 4: 894561 43128455 java.lang.String 5: 120233 28745512 com.example.order.OrderInfo ...看到[B排第一不要慌,[B是byte数组,很多地方都会用到——文件读取、网络IO缓冲、以及绝大部分对象的底层存储。重点看的是业务对象,比如com.example.order.OrderInfo有12万个实例、每个占用字节不少,加起来占了28MB多,但如果订单量本身就有12万,这也不算异常。真正的异常是那些你没想到会大量存在的类,比如某个工具类的实例竟然是实例数的前五名。
histo还有一个隐蔽的用法:观察变化趋势。先用jmap -histo抓一次快照,记下总实例数和关键类实例数,过段时间再抓一次做对比。如果中间没有大规模请求,某个业务对象实例数却持续翻倍,别犹豫,这通常就是泄漏点。不用导堆文件,不做复杂分析,两步就定位了。
提示:
-histo:live会先触发一次Full GC,生产环境使用前务必斟酌。如果只是看对象分布、不想额外增加GC压力,建议直接jmap -histo不带live,输出的是当前堆里实际存在的对象,也能说明问题。
3.3 jmap -dump:生产环境导堆的正确姿势
当histo能确定异常类、但又看不出引用链路时,就得靠堆快照说话了。导堆命令的规范写法是:
jmap -dump:live,format=b,file=/data/heapdir/heap_$(date +%Y%m%d_%H%M%S).hprof 28451关键参数拆开说。live表示只导出存活对象,导出的文件会小很多,分析时也干净;format=b指定为hprof二进制格式,jvisualvm、Eclipse MAT、jhat都能读;file是导出路径,记得选磁盘空间充足的分区,几百MB的堆导出成hprof后可能涨到1~2GB。
导出之后用Eclipse MAT打开,最常见的分析路径是找Dominator Tree里的重本对象,再右键查看GC Roots引用链。通过引用链能看到这个对象是被谁强引用、从哪段代码创建出来的。这一趟下来,内存去哪了、谁拍板留着它,基本水落石出。
不过这里我得泼一盆冷水:生产环境导堆是有代价的。jmap -dump执行时,JVM会进入安全点,应用线程全面暂停,暂停时间取决于堆大小和对象数量,几百MB的堆可能要停顿好几秒。导完一次大型堆,应用延迟直接白屏几秒是完全可能的。所以规范的操作流程是:先评估影响面,尽量在低峰期执行;导出的命令最好先确认磁盘空间;大堆导出后立刻通知相关方,必要时准备应急预案。如果只想做轻量排查,优先尝试jcmd GC.heap_dump,在多数场景下与jmap -dump等价,但更推荐、兼容性也更好。
4. jstack:线程此刻到底卡在哪
4.1 抓线程栈的命令与线程状态说明
jstack是排查"进程活着但没反应"这类问题的主力工具。基本用法:
jstack -l 28451 > /tmp/jstack_$(date +%Y%m%d_%H%M%S).txt-l参数会输出额外的锁信息——包括线程持有的锁以及等待的锁,排查死锁时必备。抓取结果最好重定向到文件,因为线程多的JVM可能一次性输出几百行,直接看终端很容易刷屏。
打开线程栈文件,每一段代表一个线程,核心结构是这样的:
"http-nio-8080-exec-12" #32 daemon prio=5 os_prio=0 tid=0x00007f3280a2c800 nid=0x2e1b runnable [0x00007f32dc4db000] java.lang.Thread.State: RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) ... "thread-pool-3" #21 prio=5 os_prio=0 tid=0x00007f3280301800 nid=0x2bcf waiting for monitor entry [0x00007f32e0f39000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.order.OrderService.createOrder(OrderService.java:108) - waiting to lock <0x000000076d2a5f70> (a com.example.common.Counter) ... "thread-pool-4" #22 prio=5 os_prio=0 tid=0x00007f3280302000 nid=0x2bd0 waiting on condition [0x00007f32e0e4b000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) ...线程状态是第一步判断依据:
- RUNNABLE:正在执行,但网络IO等待(比如Socket读取)也会显示RUNNABLE,所以看到RUNNABLE不代表"正在消耗CPU",还要看栈顶是否是native方法。如果栈顶是
socketRead0这类网络方法,多半是线程在等网络数据,正常得很。 - WAITING / TIMED_WAITING:通常是等锁、等条件变量、等网络响应。大量线程堆在这里并不一定异常,可能是线程池排队等任务。
- BLOCKED (on object monitor):线程想拿一把锁没拿到,被卡在同步块门口。如果一堆线程BLOCKED在同一把锁上,基本就是锁竞争激烈或者持锁线程卡住了。
看栈信息有个讲究:别只看栈顶,把整个栈从下往上看一遍。栈顶方法往往是无意义的等待点,真正触发问题的业务方法在栈的中间。我曾经排查过一个线程"WAITING"状态的问题,栈顶是park,看着人畜无害,往下一翻才发现它在一个数据库查询的Future.get()上阻塞,再往下是业务代码调用位置,问题根源瞬间清晰。
4.2 定位死锁:一次教科书式的线程栈分析
死锁是jstack最擅长抓的问题之一,甚至用不着手动分析,因为jstack在模式匹配到死锁时,会直接在文件末尾打印一段总结:
Found one Java-level deadlock: ============================= "thread-pool-1": waiting to lock monitor 0x00007f3280882700 (object 0x000000076d2a5f70, a com.example.common.Counter), which is held by "thread-pool-2" "thread-pool-2": waiting to lock monitor 0x00007f3280882b00 (object 0x000000076d2a5f80, a com.example.common.StatsRecorder), which is held by "thread-pool-1" Java-level deadlock detected.这段输出的意思是:thread-pool-1持有Counter锁想要StatsRecorder锁,thread-pool-2持有StatsRecorder锁想要Counter锁,双方各攥着一把不放,互相干瞪眼。
定位到死锁之后,修复方向通常有三:一是调整加锁顺序,让所有线程都按同一个全局顺序获取锁,这是最根本的解法;二是用ReentrantLock的tryLock加超时,拿不到锁不硬等;三是缩小同步块范围,只在真正需要保护的临界区加锁。我遇到过一次因为业务代码在持锁期间调用远程接口而导致的死锁,把同步块外的远程调用抽出去后,问题彻底消失。
值得多提一嘴的是,jstack打印的锁信息是"monitor级别的锁",对于java.util.concurrent里的显式锁不会直接显示"held by"这种描述,但末尾的Locked ownable synchronizers段会给出线索:
Locked ownable synchronizers: - <0x000000076d2a5f70> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)看到这里就知道线程手里还攥着哪些ReentrantLock,配合业务代码能推断出锁的持有路径。
4.3 用jstack响应CPU占用飙高:top -Hp配合法
生产环境最让人头痛的场景是:应用没崩、接口还在响应,但某几个线程把CPU吃得死死的,整个服务时延飙升。这时候光抓jstack没用,你得先知道"谁是高CPU线程",再抓它的栈才有意义。
标准步骤是这样的。第一步,用top -Hp 28451查看进程内每个线程的CPU占用,找到CPU接近100%的那一行,记下它的线程号(PID列),这个号是十进制的:
top - 10:23:45 up 20 days, 2:33, 2 users, load average: 4.70, 5.90, 5.10 Threads: 65 total, 1 running, 64 sleeping, 0 stopped, 0 zombie %Cpu(s): 90.0 us, 5.0 sy, 0.0 ni, 5.0 id, 0.0 wa, 0.0 hi, 0.0 si PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 3721 app 20 0 12.345g 2.345g 18m R 99.3 2.9 23:45.51 java第二步,把高CPU线程号转成十六进制。转换方式用printf:
printf '%x\n' 3721得到e89。第三步,用这个十六进制数在jstack输出里反查线程:
jstack 28451 | grep -A 30 "nid=0xe89"找到对应的线程栈,栈顶如果是一段纯计算代码,比如正则表达式回溯、序列化、循环拼字符串,那就明确锁定了元凶。
这个方法还有个变体,适用于容器环境无法用top -Hp的场景:
# 用 ps 找到线程 CPU 占比高的线程 ps -Lp 28451 -o pid,tid,pcpu,comm | sort -k3 -rn | head -5 PID TID %CPU COMMAND 28451 3721 99.3 java然后同样转十六进制、grep nid。整套链路下来,从"负责的线程是谁"到"它正在跑什么代码",基本不到一分钟。
注意:为了减少误判,抓jstack前建议连续抓2~3次,每抓一次间隔3秒左右。如果某线程每次都出现在昂贵的计算栈里才叫实锤,偶尔出现一次可能只是任务调度抖动。
5. 组合实战:一次线上服务卡死的完整排查过程
5.1 现象与第一反应
去年遇到过一个典型的案例:一个订单处理服务,中午高峰时段突然大面积超时,成员反馈接口从几十毫秒涨到十几秒。登录机器后,ping正常,进程还活着,但CPU负载已经拉满,load average接近核数的三倍。第一反应是抓高CPU线程,用top -Hp定位到两个线程几乎各占99%的CPU。查线程栈后发现,这两个线程都卡在同一个函数——一个自定义的字符串解析工具里,具体是正则表达式在循环中执行替代操作。
事情到这还没结束。按经验,一个正规的服务不可能拿正则去做热路径上有大量并发的文本处理,但这次只是表象。顺着这两个线程继续看,它们的调用栈都指向同一个第三方配置中心客户端的回调逻辑,回调在业务线程池中执行,每次配置刷新都会触发一轮全量配置解析,而解析代码又用了性能极差的正则表达式,最终导致所有线程池线程被拖死。
5.2 三招连用的排查链路
整个过程把三兄弟全用上了,链路清晰得可以复盘:
第一步是jstack抓现场。高峰时段不管三七二十一,先抓两轮线程栈,间隔5秒。对比两轮栈,发现大量线程堆积在同一个业务方法等待队列上,而占CPU的两个线程反复出现在同一个解析工具代码里。
第二步是验证"是不是只有这两个线程在烧CPU"。这是关键,因为如果只有两个线程烧CPU,其他线程应该不受影响,但事实是整个线程池的资源被这两个线程占住,其他任务全部排队等待,响应时间自然全线上涨。说白了,不是线程池线程都被耗尽,而是CPU时间片被一个热点代码路径垄断了,池子里所有任务都在抢那点剩余资源。
第三步是jstat验证GC状态。先排除GC噪音干扰,跑了一遍jstat -gcutil -t 28451 1000 5,确认GC次数和耗时都在正常范围,老年代使用率稳定,没有 Full GC 频繁发生的迹象。这样一来GC因素被排除,问题聚焦在CPU密集代码上。
第四步,为了排除堆里存在异常积累的大对象,顺手用jmap -histo:live导了一次对象分布,看了一遍前30行,也没有意外发现。到此,内存层面被排除,方向收敛到业务代码热点。
整个排查从现象到定位,大概花了20分钟。最后修复很简单:把配置解析工具类里的正则替换改为基于字符串拆分遍历的一次性解析,并且把配置解析从回调线程中挪到单独的异步任务,避免再次拖垮业务线程池。
5.3 整个过程的复盘与前车之鉴
复盘时能提炼出几条经验,单独放出来值得记住:
- 先抓线程栈再想对策。遇到服务卡死,不要急着重启,重启确实能恢复,但问题原因就永远黑箱了。先抓栈、确认方向,再决定要不要快速止血。
- CPU高和线程池阻塞是两码事,但它们会互相引爆。这个案例里,真正致命的是线程池被热点代码占满,导致根本接不住新任务。排查CPU问题时永远要问一句:"这个线程池还有余力处理其他任务吗?"
- jstat的价值主要体现在排除法上。它负责排除GC因素,把问题范围一步步缩小。很多时候"没查出问题"本身就是重要的结论——不是每个故障都伴随GC异常,别被"JVM问题=GC问题"的惯性带走。
- 工具不能替代代码审查。三件套能帮你快速锁定线程和内存的异常,但最终还是要落到业务代码。排查工具的终点,往往是打开源码的那一刹那。
6. 哪些坑我替你踩过了:常用注意事项
6.1 attach fail、权限问题与Docker场景
用这三个工具最常见的报错之一是:
Unable to open socket file: target process not responding or HotSpot VM not loaded这个报错有几个原因。最常见的是-XX:+DisableAttachMechanism被设置了,JVM主动关闭了动态attach能力,这种情况下任何Attach工具都连不上,只能通过改启动参数或用jcmd带参数的方式规避。第二个常见原因是进程属于其他用户,上文提过,切到目标进程的属主再执行就行。第三个原因是容器场景:JDK的attach机制需要读取/tmp/hsperfdata_<user>/<pid>这个文件,而容器内的/tmp可能被清理或配合只读文件系统导致文件不存在,这时可以用jps -l先确认是否能发现目标进程,不行就考虑挂载临时目录或改用jcmd。
还有一类诡异的情况:jmap -dump执行到一半报Error - sun.jvm.hotspot.debugger.DebuggerException: Can not get field value。常见诱因是JVM版本和工具版本不一致——注意,JDK是向下兼容的,不是向上兼容。用JDK 17的jmap去连JDK 8的进程可能出现内部数据结构不匹配,反过来用JDK 8的jmap去连JDK 17的进程基本必挂。排查机器上多版本JDK并存时,一定先java -version确认默认版本,再用与运行版本一致的jmap。
另外必须重点提醒:不要在高并发下的生产环境对jmap -dump掉以轻心。前面说过,dump会触发安全点全停顿,大堆停顿几秒到几十秒都有可能。我在一次事故里见过有人直接导出8G堆,内存和CPU瞬时飙满,监控图直接拉成断崖。导堆前先评估业务容忍度,优先用-live参数限制导出范围为存活对象以压缩体积,能放在低峰期就放在低峰期。
6.2 JDK 9+的变化与替代工具简述
JDK 9开始,Java把原来的tools.jar打散为模块,很多老脚本开始报错;jvisualvm不再打包进默认发行版,JConsole能连的协议范围也变了,但这三兄弟依然保留在发行包里,命令名字没变。只是从JDK 9往后,Oracle建议用jcmd替代部分jmap的工作,比如:
jcmd 28451 GC.heap_dump /data/heap/heap.hprof jcmd 28451 GC.class_histogram jcmd 28451 Thread.printjcmd的优势在于交互方式更统一,信息展示也更结构化,很多jmap -dump、jstack的活它都能干。但老工具并没有被淘汰,很多老系统还在跑JDK 8,这些命令在那边完全够用,而且命令行习惯一旦形成,在快节奏的故障响应中不容易产生歧义。
如果你拿到的是JDK 11以上的进程,jmap -heap的信息展示会有所变化,部分字段被拿掉,这时候优先用jcmd GC.heap_info。jstack在JDK 11以后也能用,但官方更推荐jcmd Thread.print,输出基本一致。做决定前记住一条原则:能用jcmd就优先jcmd,但会读jmap和jstack的输出永远不吃亏,因为那套分析思路是通用的。
还有个容易被忽略的点:从JDK 8u211以后,jmap -help里多了一个-j参数,允许你传JVM参数给工具进程本身。排查一些疑难问题时,比如工具进程启动时默认附加器内存不够,通过jmap -J-Xmx4g这种写法能规避偶尔出现的OOM。大多数场景用不上,但知道有这手,等真遇到时能省不少时间。
回到最初的问题:为什么老牌命令行工具到今天还是金刚不坏?因为它们贴着一行层技术真实运行现场,没有中间层的过滤和美化,你说它是ROOT也好、是后门也好,反正在排查那些"说不清楚哪里不对"的诡异问题时,手里有一把能直接切开JVM的命令行工具,永远比只会在监控页面里点鼠标踏实得多。这份踏实,其实就是这些老工具留下来的最大价值。