ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java线程性能分析实战:用jstack与Arthas定位锁竞争和线程池瓶颈

Java线程性能分析实战:用jstack与Arthas定位锁竞争和线程池瓶颈 1. 先分清“线程慢了”和“线程堵了”瓶颈的真实面孔很多人一碰到接口变慢、CPU飙高第一反应就是“开线程池”“加线程数”结果一顿操作猛如虎问题反而更严重。做线程性能分析这么多年我最深的体会是如果你连线程现在处于什么状态都没搞清楚加再多线程也只是让系统死得更热闹。线程瓶颈表面上都叫“慢”但底层原因可以分成几类线程被阻塞拿不到锁、线程在空转消耗CPU、线程在无限等待某个条件、线程池被任务队列塞满导致新任务被拒绝。每种原因对应的工具、分析路径、优化手段完全不同。1.1 症状背后的两种截然不同的场景先看两种常见现象现象A接口偶尔超时但CPU使用率并不高甚至只有20%左右。你去看监控线程数在涨但线程大多处于BLOCKED或WAITING状态。这种情况不是计算量大而是有锁竞争或者IO等待把线程堵住了。现象B接口持续变慢CPU跑到80%以上GC时间占比很高。线程大多处于RUNNABLE状态并且反复在处理相似的方法。这种情况往往是循环自旋、频繁创建对象触发GC、或者某些方法调用路径过于沉重。这两种现象的排查工具都是jstack、top、Arthas但看的东西完全不一样。现象A要重点看锁等待、线程状态分布、阻塞点现象B要重点看线程栈里的热点方法、调用次数、CPU时间集中在哪个线程。1.2 线程瓶颈的几个典型判定指标我干活的时候会先问四个问题答案出来基本就定了一半方向线程状态分布如何RUNNABLE比例高还是BLOCKED/WAITING比例高线程数是不是已经接近或超过线程池最大值请求响应时间的P99和P50差距多大如果P99是P50的5倍以上大概率存在排队或锁竞争。线程CPU占用是不是集中在少数几个线程上如果所有线程雨露均沾地吃CPU可能是全面的计算压力如果某个线程独占CPU那就是热点。这四个问题结合jstack和监控平台基本能判断出当前线程瓶颈属于“锁等待型”“CPU消耗型”“线程池耗尽型”还是“任务堆积型”。不同的病开不同的药分析的第一步永远是分型。2. jstack Thread Dump定位线程瓶颈的第一板斧说到线程性能分析最朴素也最直接的工具就是jstack。它是JDK自带的命令行工具可以打印JVM进程内所有线程的栈快照。虽然现在有很多花哨的可视化工具但jstack依然是现场排查的基石因为它能给你最原始的“线程现场”数据。2.1 采样命令与三个关键诀窍先看最基本的用法# 先找到Java进程PID jps -l # 打印线程快照 jstack PID # 或者输出到文件方便分析 jstack PID thread_dump_001.txt如果你不确定当前进程是哪一个可以先用jps列出所有JVM进程。线上环境如果需要权限可能会遇到“Unable to open socket file”之类的报错这时候通常需要切换到启动该Java进程的系统用户再执行。这里说几个从实战里总结出来的诀窍比单纯跑命令重要得多第一Thread Dump必须多次采样不要只抓一次。单个dump就像一张静态照片它只能告诉你“这一刻线程在哪里”不能告诉你“线程是不是一直在这里”。标准做法是每隔5到10秒抓一次连续抓5到10次。如果某个线程栈在多次采样中反复出现同一个等待点那才是真正的瓶颈如果只出现一次可能只是瞬时状态。第二抓dump之前先看一下CPU和负载情况。如果CPU已经冲到90%以上先抓两轮dump再说如果负载不高但接口慢也要抓因为很可能线程都卡在锁或者IO上。第三对线程dump文件做状态统计而不要只看单个线程。下面这段脚本可以把dump文件里的线程状态做个聚合grep java.lang.Thread.State thread_dump_001.txt | sort | uniq -c | sort -rn看到的输出大致是这样的 32 java.lang.Thread.State: TIMED_WAITING (parking) 20 java.lang.Thread.State: WAITING (on object monitor) 15 java.lang.Thread.State: RUNNABLE 3 java.lang.Thread.State: BLOCKED (on object monitor)TIMED_WAITING数量多说明大量线程在等待某个超时条件很可能是从连接池取连接超时或者锁等待超时BLOCKED数量多说明锁竞争激烈RUNNABLE数量多但CPU不高则要怀疑自旋或者空转逻辑。2.2 看懂栈里每一行状态、锁和调用链jstack输出里每一段代表一个线程前缀信息非常关键。举个实际的样例http-nio-8080-exec-12 #45 daemon prio5 os_prio0 tid0x00007f8e0805d800 nid0x3a21 runnable [0x00007f8dfbfe9000] java.lang.Thread.State: RUNNABLE at java.util.zip.ZipInputStream.read(Java_java_util_zip_ZipInputStream_00024ZipInputStream.c:322) at java.util.zip.ZipInputStream.closeEntry(ZipInputStream.java:190) at org.apache.catalina.webresources.JarResourceSet.getClassEntry(JarResourceSet.java:...第一行里的nid0x3a21是native线程ID的十六进制形式后面配合top -H能精确锁定到操作系统线程。prio5是线程优先级一般不作为重点。真正要关注的是第二行java.lang.Thread.State它直接告诉你线程当前处于什么状态。线程状态的含义和后续动作可以参考下面这个表线程状态常见含义排查重点RUNNABLE线程正在执行或等待获取CPU看栈顶方法判断是计算型还是自旋型BLOCKED线程等待进入同步块/方法即等待锁看monitor的持有者定位锁竞争WAITING线程无限期等待wait、join、park看等待的对象是谁谁该唤醒它TIMED_WAITING线程带超时等待sleep、wait(timeout)、parkNanos看超时时长和阻塞点NEW线程尚未启动一般不需要关注TERMINATED线程已结束一般不需要关注栈顶方法基本就是阻塞发生的位置。at开头的每一行都是当前线程的调用栈从栈顶往下可以看清楚线程是从哪条调用链走到这一步的。2.3 三连dump法的判定逻辑我平时最常用的判定方法是“三连dump法”间隔5秒连续抓三次dump把三次结果并排对比。如果某个线程在三次dump里全部处于BLOCKED并且都卡在同一个at行那基本可以确定它是被某个锁真正堵死了接下来就去查锁的持有者是谁。如果同一个锁对象在dump里反复出现“waiting to lock”和“locked”说明锁的竞争非常激烈而且持有者换得很频繁。这种往往是临界区过大或者加锁粒度过粗的问题。如果某个线程在三次采样中分别处于不同位置状态也各异那它可能不是瓶颈本身真正的问题反而在于这种线程的数量太多——比如每次请求都新建线程导致线程频繁创建销毁耗费了大量CPU和内存。这种情况下jstack里会看到大量相似栈的线程虽然单个线程没什么问题但架不住量大。3. 从CPU到代码把“最热的线程”映射到“最热的方法”jstack能告诉你线程在“哪里”但如果想知道线程“干了多少活”就需要借助CPU层面的工具。Linux上的top是所有性能排查的基本功而它能做到的远远不止看进程占用这么简单。3.1 用 top -H 找出吃CPU最凶的线程默认的top按进程汇总CPU使用率看不到线程级别。加-H参数后它会切换到线程视图top -H -p PID这里的PID换成Java进程的PID。执行后会看到该进程下所有线程各自的CPU占用按P键可以按CPU占用率排序。你会看到一些线程CPU占用率非常高而另一些基本是0。这些高CPU占用的线程就是接下来要重点分析的对象。top -H列里的PID是操作系统线程ID是十进制的。而之前jstack第一行里的nid0x3a21是十六进制的。要把它们对应起来需要把十六进制转成十进制或者干脆用下面的方法直接在shell里转换printf %d\n 0x3a21输出结果就是14945这个数字和top -H里的PID对应。如果你有很多线程要对照可以在抓完dump后写个简单的脚本来匹配。实际排查中还有一种更省事的办法先跑top -H -p PID记下高CPU的线程PID转成十六进制再去jstack输出里搜索这个nid。搜到之后那一整段栈就是“最热线程”的现场。3.2 从 native ID 到 jstack nid一个十六进制换算这一步是很多人第一次做线程分析时会卡住的地方其实逻辑很简单top -H显示的是native线程ID十进制数字jstack里面的nid0x...是同一个ID的十六进制形式转换公式就是printf %x\n 十进制ID或者反向的printf %d\n 0x十六进制。举例top -H里看到PID为14945的线程CPU占用95%那么转换命令是printf %x\n 14945输出3a61然后去jstack dump里搜nid0x3a61。这就是那个线程的全部栈信息栈顶往下走就是消耗CPU最严重的代码路径。这里有个容易误判的点高CPU线程并不一定是最“应该优化”的线程。曾经排查过一个系统某个GC线程CPU占用极高但根因是业务线程疯狂创建数组对象导致GC线程忙个不停。如果只盯着GC线程去优化方向就完全错了。所以要结合jstack看完整调用链而不是看到高CPU就马上动手改代码。3.3 没有CPU爆表也要找热点怎么办很多场景CPU并不高但接口响应就是差。这时候再用top去抓热点就不太合适了需要换思路。一种是用jstack多次采样对比找出那些状态一致、等待位置一致的线程。另一种是借助async-profiler这类工具做采样分析。它的优势是能直接生成火焰图把CPU采样和Java方法栈关联起来一眼就能看出哪个方法占了最大的时间比例。async-profiler可以理解为“带画面的jstack”它会把线程在不同方法上的CPU耗时以火焰图形式展示。火焰图的横轴是时间占比纵轴是调用栈层次。最顶层的宽条块往往就是真正消耗CPU的地方。结合JFRJava Flight Recorder采集的事件记录还能看到锁等待、IO等待、GC暂停等多维度数据。4. Arthas在线诊断不下线也能拿到线程现场jstack有个麻烦的地方它只能抓静态快照而且需要你登录到服务器执行命令。Arthas是阿里开源的Java在线诊断工具类能直接在运行中的进程里做交互式分析不需要重启服务也不需要额外写采样代码。4.1 thread 命令的三个高频用法Arthas最实用的就是thread命令推荐下面三种高频用法第一种查看当前最繁忙的N个线程thread -n 3它会列出CPU占用最高的三个线程并且直接展示每个线程的栈信息。相比“top -H jstack”的手工对接流程这一步省了不少事。同时输出的结果里会直接标明线程状态方便判定是RUNNABLE还是WAITING。第二种查看指定线程的完整栈thread 4545是线程ID对应jstack里的tid。这种方式适合已经知道目标线程ID想快速看它的调用栈的情况。第三种检测死锁thread -b这个命令会直接尝试找出当前JVM中处于死锁状态的线程输出会明确告诉你“Found one Java-level deadlock”并列出相互等待的线程和锁信息。日常排查死锁时这个命令比翻dump文件效率高很多。还有几个间接好用的参数。比如thread --state WAITING可以只看WAITING状态的线程thread -i 1000可以按固定间隔采样多次并展示变化过程。通过状态过滤你能快速算出“有多少线程在等锁”“有多少线程在睡觉”这对初步判断瓶颈类型非常有用。4.2 结合ognl看线程池活跃情况Arthas的ognl命令虽然学习成本略高但在线程池排查里真的是利器。比如你想看某个线程池当前有多少活跃线程、队列里积压了多少任务可以这样执行ognl -x 3 org.example.MyServiceexecutor.getActiveCount() ognl -x 3 org.example.MyServiceexecutor.getQueue().size()这里类名静态字段名的写法是Arthas的ognl语法。得到活跃线程数和队列大小之后就能直接判断线程池是“已经跑满”“还在排队”还是“大量空闲”。如果活跃线程数长期等于最大线程数而队列在持续增长说明线程池规模的设定明显匹配不上业务流量。这类问题靠jstack也能看出来但Arthas可以直接给出实时数值不用跑采样分析对快速定位很有帮助。5. 死锁现场还原互相等待的线程如何快速定罪死锁是线程性能问题里最麻烦的一类因为它会让一部分线程彻底“冻结”而且通常不会直接报错表现得就像系统突然变慢了一样。5.1 死锁的栈特征死锁的经典结构是两个或两个以上线程各自持有一把锁又在等待对方手里的锁。jstack输出里会出现典型的Found one Java-level deadlock字样当JVM自己能检测到死锁时会在dump最后单独输出一段分析。但如果因为各种原因JVM没有自动检测到deadlock也可以从线程栈内容里人眼识别。关键特征是一个线程栈里同时出现locked: 0x...和waiting to lock 0x...另一个线程栈里也有同样的pair只是两个锁的顺序相反这些线程的状态都是BLOCKED或者WAITING (on object monitor)。比如下面这种结构Thread-A #30 nid0x4a21 BLOCKED waiting to lock 0x00000000c2a1e9f8 (a java.lang.Object) locked 0x00000000c2a1e9d0 (a java.lang.Object) Thread-B #31 nid0x4a22 BLOCKED waiting to lock 0x00000000c2a1e9d0 (a java.lang.Object) locked 0x00000000c2a1e9f8 (a java.lang.Object)两个线程互相等待对方持有的锁这就构成了死锁。5.2 一个经典的排查路径从接口超时到死锁根因之前排查过一个生产问题现象是某个核心接口的P99从50ms一路涨到5秒以上但CPU很低服务也没有报错。当时接到工单的第一反应就是线程被堵住了于是按下面这个路径排查第一步抓jstack。用三连dump法间隔5秒抓了三次。第二步统计线程状态。发现BLOCKED线程数量在三次dump里持续上升从12个涨到31个而且所有BLOCKED线程的等待锁地址高度接近集中在两个monitor地址上。第三步查看这两个锁的持有者。在dump里搜索这两个地址发现一个线程持有锁A等锁B另一个线程持有锁B等锁A。同时栈顶都是同一个方法的waiting to lock。到这里死锁已经实锤了。第四步查代码。根据线程栈里的方法名定位到业务代码发现是一个分布式锁和本地锁嵌套使用导致的先拿本地锁再等远程锁而另一个线程反向操作。这个典型的“锁顺序不一致”问题修起来也简单统一加锁顺序即可。但如果没有前面这一套dump分析要从代码层面找到两个互相等待的锁交叉点难度会大得多。排查死锁时有个习惯建议养成dump文件里的锁地址0x...是定位真凶的线索搜同一个地址在所有线程栈里出现的次数能很快画出锁的竞争关系图。如果某把锁被几十个线程同时等待即使没有死锁也说明这把锁的竞争已经严重拖累了性能。6. 线程池场景瓶颈不在池子本身而在队列和拒绝策略很多性能事故其实是线程池配置不当导致的。线程池本身是个好东西但用的人如果不理解内部机制反而容易在池子上栽跟头。做线程性能分析时线程池是我必查的项目之一。6.1 线程池满的本质含义先说点基础但很多人没真正理解的内容。ThreadPoolExecutor默认情况下创建的核心参数有四个核心线程数、最大线程数、阻塞队列、拒绝策略。线程池的运行逻辑是任务进来时如果当前线程数小于核心线程数直接创建新线程执行如果核心线程已满任务进入阻塞队列排队如果队列也满了才会继续扩容到最大线程数如果最大线程数都用完了队列还是满的触发拒绝策略。所以“线程池满了”这个说法其实包含两种情况一种是工作线程全部跑满另一种是任务队列塞满。前者说明计算或IO占用了全部并发能力后者说明生产能力跟不上消费速度池子的线程创建速度远慢于任务提交速度。这两种情况的优化方向完全不同。线程数跑满要么提高单线程处理效率要么扩大池规模队列塞满则要考虑增加消费者线程数或者在前端做限流削峰。6.2 用线程池指标反推配置合理性在排查线程池问题时建议先采集下面几个数据指标获取方式说明activeCountThreadPoolExecutor.getActiveCount()正在执行任务的线程数poolSizeThreadPoolExecutor.getPoolSize()当前池中线程总数queue.size()ThreadPoolExecutor.getQueue().size()排队中的任务数taskCountThreadPoolExecutor.getTaskCount()累计提交的任务数completedTaskCountThreadPoolExecutor.getCompletedTaskCount()已完成任务数rejectedCount自定义计数器被拒绝的任务数重点关注两个比值activeCount / poolSize代表当前线程的实际利用率queue.size() / queueCapacity代表队列饱和度。如果activeCount长期等于poolSize且队列还在增长说明线程数确实不够。如果activeCount偶尔等于poolSize但队列大部分时间处于低水位说明线程数基本够用反而是任务提交的瞬时峰值太高需要看上游的限流情况。有一个容易被忽视的坑ThreadPoolExecutor的prestartAllCoreThreads()决定了核心线程是否预先启动。默认情况下核心线程是在提交第一个任务时才创建的。如果在流量高峰来临前没有预热线程池高峰期会有一段时间的“线程创建潮”这段时间内新线程创建本身也会消耗资源和时间导致同样配置下表现比预想中差。6.3 一个真实案例IO密集服务的队列惊魂有一次排查一个IO密集型服务接口偶尔出现偶发超时频率不算高但每次都影响用户体验。从jstack看业务线程大量处于WAITING状态集中在读取数据库连接的地方。再查监控数据库连接池的活跃连接数经常打满任务在线程池里排队等待空闲连接。当时线程池的核心线程数配置了50最大线程数也是50阻塞队列用的是LinkedBlockingQueue容量很大。表面上看消息队列堆任务问题不大但实际IO操作全都卡在连接等待上队列任务越多等待越久接口响应越来越慢。优化方案是把核心线程数适度上调同时给数据库连接池扩容并且在业务代码里加上获取连接的超时控制。线程池配置不是越大越好而是要和下游依赖的吞吐能力匹配。线程排再多如果下游连接池只有20个连接那多出来的线程全都在排队等连接。这件事给我的经验是分析线程池问题一定要把线程池、连接池、队列容量当成一个整体来看。单独调其中之一往往只治标不治本。7. 虚拟线程来了重新审视“线程数”与“阻塞”的关系JDK 21正式带来了虚拟线程Virtual Threads这也让传统线程分析的方法论出现了一个重要变化。如果你所在的项目已经开始用虚拟线程或者正在评估是否要迁移那关于线程瓶颈的分析思路也需要更新。7.1 虚拟线程改变了什么传统平台线程由操作系统调度创建成本高所以“线程数”是稀缺资源。虚拟线程由JVM调度用户态实现创建成本低到可以按需分配。这带来的直接结果就是在IO密集型场景下可以大量创建虚拟线程而不会因为线程数量过多导致切换成本爆炸。以前排查“线程数太多”的问题时优化路径多半是合并线程、复用一个池、降低并发数。而虚拟线程出现后“每个请求一个线程”这种设计又变得很合理因为虚拟线程的成本已经低到可以接近轻量级对象。这不代表不需要性能分析了。相反虚拟线程的栈会比普通线程更长因为调度器本身会占用一层栈帧jstack输出的可读性也会有所变化。失败栈看起来更深但没有本质区别。7.2 新的诊断层次平台线程 vs 虚拟线程引入虚拟线程后分析层面多了一个维度平台线程Platform Thread承载虚拟线程的执行虚拟线程阻塞时平台线程会腾出来执行其他虚拟线程。所以在传统jstack里你可能看不到虚拟线程的完整栈而需要借助JFR事件或jcmd的Thread.dump_to_file方式查看。实际排查中看到一个有意思的现象大量虚拟线程处于WAITING或者BLOCKED状态但平台线程全部RUNNABLE。这说明虚拟线程因为IO等待被挂起的操作并不影响平台线程继续执行其他任务。以前“大量线程BLOCKED 系统卡顿”的直觉判断在虚拟线程模型下就不成立了。所以如果你是做性能分析的人要留意一个转变线程状态统计的参考系正在从“总线程数”转向“平台线程的利用率”。虚拟线程的数量已经不代表并发瓶颈平台线程的CPU空闲率、载体线程的忙碌比例反而更重要。同时虚拟线程也不是没有缺点。它在CPU密集型场景下的性能通常不如传统线程配线程池的方式因为没有池化复用、调度开销也存在。个人建议是虚拟线程用于高IO等待、高并发连接场景很划算纯计算场景还是老老实实用固定的平台线程池别盲目跟风。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进