公司动态
调用栈差异分析:从线程转储对比到线上问题根因定位
之前排查线上 CPU 飙升问题时我对着连续抓取的几份线程转储反复翻看眼睛都快看花了才确认是某个业务线程异常重试导致热点调用。后来把多次抓取的调用栈放在一起做差异对比问题瞬间就清晰了。这也是本篇博客想分享的核心主题——调用栈差异分析Call stack diffs。无论你是在分析死锁、定位高 CPU 根因还是在对比版本升级后的性能回退掌握“调用栈差异对比”这套方法都能帮你更快从海量堆栈信息里找到那一点点关键变化。1. 调用栈与调用栈差异分析1.1 什么是调用栈调用栈Call Stack是程序运行期间从当前正在执行的方法开始一直回溯到线程入口方法的一整条方法调用链。每当一个方法被调用时JVM 或者操作系统会把这次调用的相关信息压入栈帧Stack Frame中方法返回时再弹出栈帧。对 Java 开发者来说最直观的调用栈就是异常堆栈java.lang.NullPointerException: null at com.example.OrderService.createOrder(OrderService.java:120) at com.example.OrderController.submit(OrderController.java:55) at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)这段信息告诉我们OrderService.createOrder的第 120 行抛出了空指针而它是由OrderController.submit第 55 行调用触发的。这就是一条标准的调用栈信息也是我们排查问题最常用的线索。1.2 什么是调用栈差异分析调用栈差异分析是指对同一进程在不同时刻采集的调用栈或者对不同进程、不同版本在相同场景下采集的调用栈进行逐行或结构化对比找出其中发生变化、出现或消失的栈帧从而定位问题根因。举个例子某线程池为什么突然没有响应我们可以每隔 3 秒抓一次线程转储如果该线程每次执行的方法都不同说明它还在正常跑业务如果每次抓到的调用栈都停留在同一个方法上那就说明线程很可能卡死在这个位置了。这种“看两次栈哪里不同”的方法就是最简单、最实用的call stack diffs思路。1.3 为什么不能只看单份调用栈很多新手遇到问题时会抓一份线程转储看到某个线程栈指向了某段代码就立刻断定问题出在那里。但单份调用栈只是一瞬间的快照存在很强的不确定性一个线程可能只是恰好在这一瞬间路过某段代码。线程可能正处于安全点、GC 停顿或 IO 等待栈不能代表它的真实运行状态。多线程问题如死锁、循环等待往往需要多份快照互相印证。对比多份调用栈相当于给问题加了“时间轴”你可以观察到线程状态的变化过程排除偶发因素找到真正稳定的热点。2. 调用栈差异分析的典型应用场景2.1 死锁与线程阻塞定位死锁是 Java 并发中最经典的问题两个线程各自持有一把锁同时等待对方释放锁导致互相永远等待。检查死锁时如果只抓一次线程转储虽然也能看到Found one Java-level deadlock提示但配合多次转储对比可以更清楚地看到线程是否长期停留在同一把锁上确认死锁是否正在持续以及影响范围有多大。2.2 CPU 飙升热点定位线上 CPU 飙升时最常被想起的命令就是top -Hp找出高 CPU 线程再通过jstack查看该线程的调用栈。但 CPU 瞬间波动很快一次抓取的栈可能正好错过了热点代码。实际做法是连续抓取 3 到 5 次线程转储对比这些转储中高 CPU 线程的调用栈如果它们高度一致才能确定热点方法如果每次都不一样说明线程在大量循环需要结合采样工具进一步分析。2.3 线程池耗尽与任务堆积排查当线程池中的线程全部被阻塞任务占满时新任务无法执行服务表现为“假死”。通过多次抓取线程转储并对比差异可以观察到线程池内线程是否长时间停留在相同栈帧。例如所有线程都卡在等待数据库连接的位置说明连接池被耗尽所有线程都卡在获取分布式锁的位置说明锁竞争异常激烈。这些结论都必须依赖多份调用栈的对比才能下得准确。2.4 版本升级后的性能回退对比同一个接口在 v1.0 版本响应时间是 10ms升级到 v1.1 后变成了 500ms。为了定位变慢原因可以在两套相同压测环境下分别对同一接口进行采样采集服务端线程的调用栈再对比两个版本的调用栈差异。这样可以直接看到新版本是否多调用了外部服务、是否新增了串行等待逻辑、执行路径是否变长。这种对比方式尤其适合微服务链路中“不知为何变慢”的问题。2.5 异常堆栈变化分析当系统开始出现新的异常或者异常频率突然增加时对比历史异常堆栈和当前异常堆栈可以快速判断异常行为是否发生了变化。例如之前空指针抛在UserService.getUser第 88 行现在抛在第 91 行说明代码改动后逻辑路径已经变化或者调用链从“直接调用”变成了“经过缓存再调用”这些都能通过堆栈 diff 快速发现。3. 环境准备与调用栈采集方法3.1 环境与工具本文示例以 Java 环境为主涉及的工具如下JDK推荐使用 JDK 8 及以上版本jstack、jcmd均为 JDK 自带命令。Linux 服务器或本地终端环境。Python 3用于编写调用栈对比脚本。版本并不需要严格固定重点是掌握思路只要能拿到线程转储文本后续对比方法都是通用的。如果你用的是 JDK 11 以上版本jstack依然存在功能基本一致。3.2 jstack 采集线程转储jstack是最常用的线程转储命令。用法如下# 先找到 Java 进程 PID jps -l # 输出线程转储到文件 jstack pid dump1.txt # 3 秒后再抓一次 jstack pid dump2.txt获得的两份文本文件就是后续进行调用栈差异对比的基础素材。需要注意的是jstack在目标 JVM 有权限限制或使用容器部署时可能无法连接这时候可以使用jcmdjcmd pid Thread.print dump3.txt这两条命令输出的内容都是标准线程转储格式上略有差异可以用于对比分析。3.3 Linux 下 kill -3 自动输出在 Linux 环境下也可以使用kill -3 pid命令向 JVM 发送 SIGQUIT 信号JVM 会主动把当前线程转储输出到标准输出stdout。如果应用是通过nohup或 systemd 启动的线程转储通常会输出到日志文件中。这种方式的好处是不需要额外连接工具适合在生产环境临时采集。坏处是输出位置不固定需要提前确认应用的标准输出和错误输出配置。3.4 通过代码自动采集堆栈除了外部命令也可以通过 Java 代码直接获取当前 JVM 所有线程的调用栈适合需要自动化采集的场景。核心代码如下import java.util.Map; public class StackDumpUtil { public static void dumpAllStacks() { MapThread, StackTraceElement[] stacks Thread.getAllStackTraces(); for (Map.EntryThread, StackTraceElement[] entry : stacks.entrySet()) { Thread thread entry.getKey(); System.out.println(\ thread.getName() \ thread.getState()); for (StackTraceElement element : entry.getValue()) { System.out.println( at element); } } } }代码逻辑很简单Thread.getAllStackTraces()返回当前所有存活线程的快照。遍历每个线程时先打印线程名和状态。再遍历其栈帧数组逐行打印调用栈。这种方式的优点是无需额外工具可以嵌入测试脚本、定时任务或故障演练系统中定时将线程转储落盘。3.5 采集通用注意事项采集调用栈时建议遵循以下几个原则多次采集至少连续采集 3 次每次间隔 3 到 5 秒避免一次快照的偶然性。保留原始文件对比前先保存原始转储方便事后查证。附带时间戳和场景描述文件名里带上时间、接口名或压测场景避免后续混淆。采集时保持负载如果是压测性能问题采集时一定要保持压测继续切勿先停流量再采样否则得到的栈会是空闲状态的栈。4. 完整实战编写调用栈差异对比脚本4.1 准备一个阻塞示例程序为了更直观地演示调用栈差异对比我们先准备一个模拟死锁的 Java 程序。程序创建了两个线程分别持有lockA和lockB然后互相等待对方释放锁。// 文件路径src/main/java/DeadlockDemo.java public class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) throws Exception { Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(T1 acquired lockA); sleep(100); synchronized (lockB) { System.out.println(T1 acquired lockB); } } }, Worker-T1); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(T2 acquired lockB); sleep(100); synchronized (lockA) { System.out.println(T2 acquired lockA); } } }, Worker-T2); t1.start(); t2.start(); t1.join(); t2.join(); } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }这里的关键点Worker-T1先拿lockA然后去拿lockB。Worker-T2先拿lockB然后去拿lockA。两个线程在没有协商好的情况下就会产生循环等待形成死锁。4.2 编译运行并采集两次线程转储在命令行执行javac DeadlockDemo.java java DeadlockDemo程序启动后会陷入死锁一直不退出。此时新开一个终端找到进程 PID 并采集线程转储jps -l # 假设 PID 是 12345 jstack 12345 dump1.txt sleep 3 jstack 12345 dump2.txt采集完成后dump1.txt和dump2.txt就是我们用于对比的原始素材。4.3 使用 diff 命令快速找差异对于文本文件最直接的对比方式就是diffdiff dump1.txt dump2.txt输出结果中会标记两份文件中不同的行。如果两次采集之间的状态完全一致说明线程全部稳定停在同一位置如果某些线程的调用栈发生了变化diff会帮助我们快速定位。但diff有一个明显问题它按“行”进行比较而线程转储中存在大量噪声例如线程的nid、内存地址、时间信息等这些内容只要有一丁点变化就会产生大量差异干扰真正的调用栈变化判断。因此更推荐使用结构化解析脚本。4.4 编写 Python 脚本做结构化对比下面编写一个简单的 Python 脚本读取两份线程转储将每个线程的调用栈解析出来再按线程名进行比对最终列出调用栈发生变化的线程。# 文件路径compare_dumps.py import re import sys from collections import OrderedDict def parse_dump(file_path): 解析 jstack 生成的线程转储文件。 返回值为 dict线程名 - 该线程的完整调用栈行列表 threads OrderedDict() current_thread None current_stack [] with open(file_path, r, encodingutf-8) as f: for raw_line in f: line raw_line.rstrip(\n) # 匹配线程头信息例如 # Worker-T1 #12 prio5 os_prio0 tid0x... nid0x... waiting for monitor entry m re.match(r^(.?).*, line) if m and line.strip().endswith((:, Java-level deadlock, waiting for monitor entry)): if current_thread is not None: threads[current_thread] current_stack current_thread m.group(1) current_stack [line] elif current_thread is not None: # 过滤空行保留栈帧 if line.strip(): current_stack.append(line) else: # 空行表示一个线程转储结束 if current_thread is not None: threads[current_thread] current_stack current_thread None current_stack [] # 处理最后一段 if current_thread is not None: threads[current_thread] current_stack return threads def compare(base, target): 对比两份线程转储 1. 找出只存在于 base 的线程 2. 找出只存在于 target 的线程 3. 找出共同线程中调用栈发生变化的线程 base_names set(base.keys()) target_names set(target.keys()) only_base base_names - target_names only_target target_names - base_names common base_names target_names changed [] for name in sorted(common): if base[name] ! target[name]: changed.append(name) return only_base, only_target, changed if __name__ __main__: if len(sys.argv) ! 3: print(Usage: python compare_dumps.py dump1 dump2) sys.exit(1) base parse_dump(sys.argv[1]) target parse_dump(sys.argv[2]) only_base, only_target, changed compare(base, target) print( 对比结果 ) print(base 线程数: {}.format(len(base))) print(target 线程数: {}.format(len(target))) print(仅存在于第一次转储的线程: {}.format(len(only_base))) for name in sorted(only_base): print( - name) print(仅存在于第二次转储的线程: {}.format(len(only_target))) for name in sorted(only_target): print( - name) print(调用栈发生变化的线程数: {}.format(len(changed))) for name in changed: print( - name)脚本的核心逻辑分两部分parse_dump函数通过正则匹配线程头识别线程名开头的行并把后续的栈帧行收集到当前线程名下。compare函数基于线程名求集合找出只出现一次的线程以及共同线程中调用栈内容不同的线程。这个脚本只做“差异检测”不输出每个线程具体的栈帧差异这样输出的结果更聚焦适合快速判断哪些线程异常。4.5 运行与结果解读执行对比脚本python3 compare_dumps.py dump1.txt dump2.txt在死锁示例中预期输出大致如下 对比结果 base 线程数: 12 target 线程数: 12 仅存在于第一次转储的线程: 0 仅存在于第二次转储的线程: 0 调用栈发生变化的线程数: 0为什么会这样因为死锁后两个业务线程Worker-T1、Worker-T2都稳定停在等待锁的状态JVM 自身的一些后台线程也没有任何活动两次快照的调用栈几乎完全一致。这一致性本身就是最强的问题信号说明线程长时间没有推进大概率发生了阻塞。如果程序是正常执行的两次采集之间线程的调用栈通常会有变化。比如某个线程第一次栈在methodA第二次栈在methodB说明线程正在按预期向前执行。只要观察“变化线程数”和“一致线程数”的对比就能快速判断系统是否卡死。5. 调用栈差异分析进阶常见问题排查5.1 常见问题速查表问题现象常见原因解决思路多次转储中某线程调用栈完全一致线程被阻塞处于锁等待或 IO 等待查看线程状态结合锁信息定位等待对象高 CPU 线程每次栈都不同线程内部存在高频循环或递归使用Arthas或 profiler 做采样统计jstack 无法连接目标进程PID 写错、权限不足、容器隔离检查jps输出切换用户或使用jcmd转储文件特别大diff 结果杂乱文件中包含地址、时间等无关信息使用结构化解析脚本聚焦调用栈行所有线程都卡在socketRead外部系统未响应连接被占满检查下游服务、数据库、Redis 等依赖只有部分线程栈发生改变可能是正常业务并发执行结合业务高峰期判断是否异常5.2 死锁的准确识别在jstack输出中如果存在死锁文件末尾通常会出现类似这样的内容Found one Java-level deadlock: Worker-T1: waiting to lock monitor 0x0000000000001234 (object 0x00000000abcdef01, a java.lang.Object), which is held by Worker-T2这是 JVM 自带的死锁检测提示。配合调用栈差异分析时我们应该注意死锁场景下多次转储的调用栈几乎不会变化因此如果连续 3 次转储中两个线程的栈都完全一致可以高度怀疑死锁已形成。再结合jstack的Found one Java-level deadlock信息就可以确认根因。5.3 同栈不同名的线程怎么处理应用中经常出现由线程池创建的大量相似线程它们的线程名可能带有递增编号例如http-nio-8080-exec-1、http-nio-8080-exec-2。结构化对比时这些线程名不完全相同会被当成不同线程处理导致判断不准。解决办法有两种在对比脚本中将线程名中的数字部分去掉按“前缀归一化”做匹配。在业务代码中给线程池设置固定名字前缀利用ThreadFactory统一命名。在实际项目中我更推荐第二种因为它从源头上提升了线程转储的可读性也方便日志和监控系统做聚合。5.4 多次采集结果不一致该怎么办如果同一线程在多次转储中调用栈都不一样未必是异常也可能是正常业务并发。此时需要结合线程状态判断线程状态为RUNNABLE且栈不断变化说明线程正在快速执行循环或频繁切换任务。线程状态为WAITING、TIMED_WAITING但栈发生变化关注等待的对象是否被频繁唤醒。线程状态为BLOCKED栈基本不变线程在等待锁锁竞争是主要瓶颈。如果栈的变化完全没有规律建议结合采样型 profiler例如async-profiler统计每个方法的热度占比用数据辅助判断。6. 最佳实践与工程建议6.1 线程命名规范化线程转储中线程名是识别线程身份的“身份证”。如果线程名毫无意义例如Thread-1、Thread-2在对比分析时会非常痛苦。建议在创建线程池时自定义ThreadFactoryimport java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class NamedThreadFactory implements ThreadFactory { private final String prefix; private final AtomicInteger counter new AtomicInteger(1); public NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread thread new Thread(r, prefix - counter.getAndIncrement()); thread.setDaemon(true); return thread; } }这样线程转储中会出现biz-order-thread-1、biz-order-thread-2这种名字一眼就能看出线程归属什么业务模块。6.2 采集策略与自动化手动敲命令适合临时排查但线上问题往往发生在凌晨的故障窗口等研发被叫起来再敲命令就晚了。建议在项目运维脚本中固化一个“自动采集线程转储”的动作触发条件可以是接口超时率达到阈值。CPU 使用率持续 90% 以上超过 2 分钟。健康检查失败。收到内存告警。自动采集流程建议触发后每 3 秒抓一次连续抓 5 次输出到指定目录同时附带时间戳和触发原因。这样故障发生后的第一手资料已经被完整保留下来再做调用栈差异分析时分析素材充足且真实。6.3 工具选型与脚本纳入项目调用栈对比脚本不要只停留在个人电脑上建议纳入项目仓库统一管理。原因很简单脚本会随着项目结构调整而调整例如线程名前缀变化、新增业务模块后比对规则也可能需要变化。把脚本放到tools/目录下并在 README 中说明用法整个团队都能复用。另外如果是压测场景可以将线程转储对比结果作为性能回归的辅助指标两次压测中相同线程名若出现完全一致的调用栈说明执行路径没有发生非预期变化若新增了大量socketRead等待则需要重点检查下游依赖耗时。6.4 安全与权限注意事项调用栈分析属于进程诊断手段在生产环境使用时需要注意安全边界执行jstack、jcmd前确认你有该进程的合法运维权限不要尝试访问非授权进程。线程转储中可能包含业务参数、类名、方法名和局部变量信息保存到日志时要注意脱敏避免敏感业务信息外泄。不要在高峰期无节制地连续采集大量转储采集本身也会对 JVM 造成轻微影响建议每次最多采集 5 份每份间隔 3 秒以上。6.5 结合日志和监控一起分析调用栈差异只是问题定位的一个视角最好与日志、监控指标联动。例如确认线程卡在redis.clients.jedis的相关方法时同时查看 Redis 监控指标和慢日志。确认线程卡在java.net.SocketInputStream.socketRead0时同时查看对端服务的响应时间和 TCP 连接数。确认线程卡在synchronized或ReentrantLock时结合日志搜索锁相关的业务打印判断锁持有者是谁。单靠调用栈能定位到“卡在哪里”但想要回答“为什么会卡”必须依靠完整的数据链。7. 总结调用栈差异分析不是一个新的框架也不是某个特定工具的功能而是一种非常基础且实用的排障思路。它把一次性的线程快照升级成了带有时间维度的对比数据让我们能够从动态变化中识别静态阻塞、从偶发信息中提取稳定热点。本篇从调用栈的基础概念讲起介绍了调用栈差异分析的典型场景演示了jstack、jcmd等多种采集方式并手写了一个 Python 脚本用于结构化对比两份线程转储。最后补充了线程命名规范、自动采集策略、安全权限等工程经验。如果你也经常被线上线程问题折磨建议先从“连续抓 5 次转储再做一次对比”开始。你会发现原本扑朔迷离的并发问题瞬间清晰了很多。也欢迎在评论区分享你使用调用栈差异分析排查问题的经验一起交流进步。