先说结论压测里最折磨人的从来不是高并发本身而是高并发底下藏着的死锁和内存泄露。这两个问题很会挑时候平时单测、小流量联调全都正常一到压测现场就现原形QPS先是冲高然后突然回落线程池排队数飙升紧接着Full GC频率翻倍最后事务成功率一路走低。这篇文章就拿一个完整案例拆给你看从压测场景设计、线程栈 dump、堆转储分析到修复后的回归验证一条线讲到底不绕弯子。不管你是在做测试开发、后端研发还是刚入门性能测试这套定位思路和命令都能直接照抄。1. 案例全景压测之前先想清楚三件事先说背景。这次压测的对象是模拟项目X一个典型的微服务交易链路核心节点有网关服务、账户服务、订单服务。压测目标是在指定线程数下把订单创建接口的QPS稳定推到2000同时TP99响应时间控制在200毫秒以内不允许出现事务失败和内存异常。听起来不复杂但真实执行的时候问题接二连三冒出来。压测之前我习惯先逼自己回答三个问题第一这次压测要验证什么指标第二用多少并发线程去跑第三监控点位布在哪里这三个问题不落实后面出了问题只能对着白屏猜谜。第一个问题好答验证的是接口容量和稳定性所以设计了三个阶梯场景5分钟快速压测摸底、30分钟中时长压测观察波动、6小时长稳压测检查内存和行为稳定性。第二个问题要用到一个很实用的估算公式并发线程数约等于目标QPS乘以平均响应时间单位秒。目标QPS是2000假设单请求平均RT是40毫秒换算成秒就是0.04乘积约80。但线上接口不可能理想化所以我把线程数从80起步每轮按比例翻倍往上加一直压到瓶颈出现。第三个问题最关键监控点位必须覆盖四层系统层的CPU、内存、IOJVM层的GC日志、堆内存、线程栈应用层的QPS、RT、错误率以及调用链路的耗时分布。监控参数必须在压测前就配好。JVM这边我给服务加了这些启动参数-Xms4g -Xmx4g -Xmn1g -server -XX:UseConcMarkSweepGC -XX:CMSParallelRemarkEnabled -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps这套参数的逻辑很简单堆大小固定4G新生代1GOOM时自动导出堆快照GC日志单独落盘。这些不是可选项是排查问题的必需品。没有GC日志后面分析内存泄露的时候你连第一手现场证据都没有。1.1 压测热身基线结果与瓶颈初现先跑了一轮5分钟快速压测线程数从80开始按50%递增。前面三档都很顺利QPS从800爬到1500平均RT在40-60毫秒之间错误率为零。到第四档并发线程数加到300左右QPS不仅没继续往上走反而从1500掉到了1200平均RT直接飙到800多毫秒错误率升到3%。第一反应是系统资源打满了去看监控CPU才用了40%内存余量充足IO也不高。这就很蹊跷资源没用满性能却上不去典型的应用层阻塞特征。我立即把线程数降到300维持住现场然后连做了三次线程栈 dump间隔5秒。正是这三份线程栈撕开了第一个问题的口子。这时候不要急着改代码先确认是不是服务之间出现了互相等待。性能测试里有一个基本原则任何性能问题先拿监控数据说话再拿线程栈和堆栈佐证最后才是改代码验证。顺序不对很容易把时间浪费在猜测上。1.2 结果曲线的异常信号把压测数据拉成图表之后问题更清楚了。QPS曲线不是平缓爬升而是爬到一个高点后突然掉头向下同时RT曲线开始加速上翘活跃线程数迅速逼近线程池最大值。这个组合信号非常典型如果系统资源没有耗尽那么大概率是应用内部出现了锁竞争或阻塞等待。为了验证是锁的问题还是死锁我对比了三次线程栈 dump。第一次 dump 里有少量线程处于WAITING状态第二次明显增多第三次出现大量BLOCKED状态线程。仅仅看QPS曲线你能猜到是锁问题但猜不到锁在哪线程栈 dump 的价值就在于直接把阻塞位置和锁对象指到你的面前。2. 死锁排查实录线程池爆满背后的锁竞争这轮排查是从一份线程栈串开始的。我先后导出了三份栈文件用文本工具打开之后直接搜索deadlockJVM 其实很贴心如果真的检测到死锁会在栈文件末尾打出一段专门的Found one Java-level deadlock信息。这份 dump 里虽然没有那行字出现但线程状态分布已经很不正常大量业务线程卡在java.lang.Thread.State: BLOCKED等着同一个监视器锁。再往下翻两个线程的栈构成了一个经典的循环等待线程A持有账户服务的锁等待订单服务的锁 线程B持有订单服务的锁等待账户服务的锁这画面太熟了就是教科书式的死锁现场。为什么JVM没有在dump里直接打Found one Java-level deadlock因为死锁检测依赖循环等待形成的闭环而这里由于加了锁超时或部分抢锁逻辑没有形成完全闭环但线程互相等待的时间已经长到把线程池活活拖垮了。2.1 线程栈分析两把锁的互相钳制打开两份 dump 对比看线程A的栈很长重点看最后几行。关键信息长这样http-nio-8080-exec-36 waiting to lock 0x000000076b2f3e50 (a java.lang.Object) locked 0x000000076b2f3a28 (a java.lang.Object) - waiting to lock 0x000000076b2f3e50 - locked 0x000000076b2f3a28线程B那边刚好相反先锁住了0x000000076b2f3e50又在等0x000000076b2f3a28。两个锁对象互相钳制谁也不让谁。这时候再往上翻栈帧定位到业务代码的具体行号就找到了加锁的地方。我把两个服务的代码拉出来对比找到问题根因加锁顺序不一致。账户服务里是先锁用户对象再锁账户对象订单服务里是先锁账户对象再锁用户对象。这个顺序差异在单线程下毫无存在感高并发下一旦两个请求同时交叉访问就死锁了。这类案例在业务代码里非常常见尤其是涉及多服务互相调用的交易场景。我最想提醒的是加锁顺序这种问题代码审查的时候极难发现因为两个方法单独看都很合理只有把调用关系串起来才会看到那把循环等待的锁。2.2 根因代码还原写个简化版还原当时看到的代码形态// 账户服务转账 public void transfer(long userId, long accountId) { synchronized (userLock(userId)) { // 先锁用户 synchronized (accountLock(accountId)) { // 再锁账户 // 执行转账 } } } // 订单服务下单 public void createOrder(long userId, long accountId) { synchronized (accountLock(accountId)) { // 先锁账户 synchronized (userLock(userId)) { // 再锁用户 // 创建订单 } } }两个请求如果同时进行一个拿着用户锁等账户锁另一个拿着账户锁等用户锁循环等待成立线程池里的业务线程越积越多最终全部阻塞。修复方式有两种取向。第一种最直接统一全局加锁顺序比如约定先锁用户再锁账户所有服务都遵守同一个顺序。第二种稳妥一点用ReentrantLock的tryLock代替synchronized加锁时传入超时时间拿不到锁直接返回失败或重试从机制上破坏死锁条件。两个方案我都有实践经验如果业务链路短、服务内部可控统一顺序成本最低跨服务跨团队的话tryLock更值得选因为能防止其他团队后续写代码时破坏顺序约定。2.3 修复与回归验证这次修复选的是统一加锁顺序改动区域集中在这两个方法里把订单服务里的锁顺序调整成和账户服务一致。改动量不大但风险要控制到位所以走的还是老流程代码走查、单测、接口联调然后重跑压测。回归压测的结果很干脆同样是以300并发压测QPS稳定在1900-2000区间平均RT从800毫秒降回55毫秒错误率归零。再核查线程栈BLOCKED线程数量恢复正常线程池利用率回归健康区间。死锁问题到此结束。但压测并没有停因为紧接着的内存泄露问题正在6小时长稳场景里等着我。3. 内存泄露排查实录越来越高的老年代死锁问题修复之后6小时长稳压测继续跑。前两个小时一切正常堆内存曲线虽然有锯齿状波动但整体平稳。到第三个小时开始不对劲了。GC日志里Full GC的间隔越来越短一开始是15分钟一次到第五个小时变成3分钟一次。用jstat -gcutil看老年代的使用率数值从40%一步步涨到90%即使Full GC跑完释放的内存也很有限内存曲线呈现出典型的阶梯式上升。这是我判断内存泄露的标准信号对象只进不出老年代只涨不降新生代清理根本帮不上忙。内存曲线出来后第四个小时的时候我做了一次堆转储第五个小时又做了一次。两次快照对比是定位内存泄露的关键手法。如果只是活对象的正常波动两次堆的总体量和对象分布应该差不多如果某些对象在两个快照之间数量大增这类对象就是最大的嫌疑人。对这次转储文件我用的是MATMemory Analyzer来归类分析。打开第一个快照先看Overview再切到Dominator Tree按Retained Heap排序。排在最前面的对象是一种我业务上很眼熟的缓存类占用的保留堆高达87%。当时心里就有了底问题八成出在这个类的使用方式上。3.1 堆转储报告谁是内存大户在MAT里直接右键这个类选择Find Suspects或者直接用Path to GC Roots看引用链很快找到了持有它的根一个静态的ConcurrentHashMapkey是请求上下文IDvalue是自定义的请求上下文对象。按说这类缓存用完就应该清掉但它清了没清干净更准确的说是根本就没清掉。引用链上的关键信息是这样的class RequestContextCache - static java.util.concurrent.ConcurrentHashMap - value: RequestContext - referenced by: ThreadLocal问题开始清晰了代码里有一个请求上下文的缓存机制每次请求进来的时候生成一个上下文对象放进静态Map里请求结束再从Map里remove。但remove使用的key和put的key不一致导致插入的上下文对象永远停留在Map里。这个bug在功能测试阶段完全不会被发现因为功能测试只关心请求能不能成功没人去数上下文对象数量。但压测每秒钟产生几万个请求每漏一个上下文对象Map里就多一个无法回收的引用几百万个对象堆积下来堆内存自然爆炸。3.2 代码层面的修复逻辑改完之后的代码逻辑简化为这样public class RequestContextCache { private static final MapString, RequestContext CACHE new ConcurrentHashMap(); public void startTrace(String requestId) { CACHE.put(buildKey(requestId), new RequestContext(requestId)); } public void endTrace(String requestId) { CACHE.remove(buildKey(requestId)); // 之前这里少调了 buildKey } private String buildKey(String requestId) { return requestId : Thread.currentThread().getName(); } }之前的问题就出在endTrace里没走同一个key构建逻辑直接用原始requestId去remove而put的时候用的是拼接后的key两个key对不上对象就漏在Map里了。这是我这几年见过的最隐蔽的泄露类型修复起来说穿了也就一行代码但定位过程要跨过GC日志、堆转储、引用链分析好几道坎。这背后还有一个更值得说的点static Map当缓存用的时候生命周期和JVM同样长一旦对象放进去就得保证有人能把它拿出来。这个约定在并发高的代码里非常脆弱。所以我现在看到static集合的第一反应就是去查它的写入和删除路径是否成对存在尤其是key的生成规则是否一致。修复代码之后我还顺手建议产品侧能否在压测前清理这个缓存的残留数据避免脏数据拖累后续验证。实际更重要的一步是把remove逻辑做成finally块里必须执行的动作确保异常路径下也能清理而不是只在正常流程里处理。再有就是如果这个缓存真的需要在内存中长时间保留最好换成带过期策略的Guava Cache并且用WeakReference做value包装这样可以杜绝对象被永久持有。3.3 修复后验证GC曲线全面回落重新跑6小时长稳压测这次的监控数据让我踏实了不少。Full GC间隔从15分钟逐渐稳定到25分钟以上老年代使用率稳定在45%-55%区间不再只涨不降。用jmap -histo:live再看对象排行那个上下文类的实例数回归到了正常水平之前的几百万实例消失不见。到这一步内存泄露问题也闭环了。回头看其实死锁和内存泄露这两类问题虽然表象完全不同但排查方法论是高度统一的先通过监控指标判断问题域再用JVM工具拿到现场证据最后通过代码引用链定位根因修复后立刻回归验证。这套方法在后面复盘时被我固化成了团队性能测试的标准动作。4. 复盘用一套可复用的性能测试方法论单看这两个问题的定位过程好像是在处理具体案例但背后其实可以抽出一套完整的操作框架。这套框架我后来反复用来处理线上性能事故效果很稳定我把它拆成几条给各位参考。4.1 五步定位框架从现象到根因不管遇到什么性能问题都按这个顺序走不要跳步。第一步确认资源水位。先看CPU、内存、IO、网络四件套如果这些指标都正常问题大概率是应用层阻塞如果某个资源打满那先朝资源瓶颈去查。第二步看应用层指标。QPS、RT、线程池活跃数、队列长度这几个指标能定位出问题是卡在处理链路还是堆积在入口。第三步取现场。线程问题用jstack连续取多份内存问题用jmap拿到堆快照JVM的实时状态全靠这一步。第四步对比分析。线程栈做前后对比多个堆快照做对象数量对比让差异自己暴露出来。第五步修复加回归。改代码之后必须重新跑一遍压测确认指标真的回到预期别修了一半就收工。这五步里最容易被忽视的是第三步。很多同学一上来就翻代码猜罪魁祸首但代码是不会告诉你它自己卡在哪里的现场证据才是第一位的。我见过太多人凭感觉改了一晚上代码最后发现根因和猜测完全不是一回事。4.2 监控工具组合压测现场的信息获取工具这块没有完美的单一工具我习惯的组合是这样的压测工具负责制造流量APM监控负责抓调用链耗时命令行工具负责JVM底层的证据采集最后用分析工具把dump文件里的引用关系理清楚。压测这块我没有特别迷信某个工具内部用哪个顺手就用哪个关键是线程数、循环次数、压测时长这些参数能灵活控制。真正需要强调的是JVM分析这一层这几个命令建议刻在脑子里场景命令用途查看GC情况jstat -gcutil pid 5000每5秒输出各代使用率和GC次数抓线程栈jstack -l pid stack1.txt输出线程栈带锁信息连续取多份导出堆快照jmap -dump:live,formatb,fileheap.hprof pid导出当前存活对象到hprof文件查看堆对象排行jmap -histo:live pid | head -50快速看哪些类占用对象数最多OOM自动转储启动参数里配置便于事后分析用这几条命令的时候有一个细节非常重要无论jstack还是jmap -dump都会对应用造成短暂停顿配合-l或者-dump:live触发全堆扫描停顿可能持续几秒。所以我在压测现场取证据都会选在压测的波动期而不是高峰期尽量不影响QPS采样的准确性。4.3 参数选择的依据和思考列举几个我这次压测里挑出的重点参数讲讲为什么这么选。堆大小-Xms4g -Xmx4g压测前其实我观察过这台压测机的内存资源和这个服务的常规负载4G是一个在够用和留有余量之间取平衡的值。堆太大Full GC的时间会更长堆太小还没开始压就可能内存不足。新生代1G也是同样的思路按对象产生速率预估后定的。并发线程的估算公式线程数≈目标QPS×平均RT。注意这个公式估算的是稳态并发量实际压测时我会从这个值开始然后逐档上调去找性能拐点。很多人一上来干脆把线程数翻到几千那样压出来的不是系统性能而是压测工具和网络链路的性能。Full GC频率的评判标准我一般以GC日志里的日志间隔为基本观察维度如果Full GC间隔持续缩短几乎可以断定有对象堆积问题这时候就不该继续压测了先把问题定位清楚再说。5. 常见问题与避坑心得这一节是我最想写的也是因为这个案例里踩过太多坑。现在回头看后来的顺畅排查其实是拿之前的磕磕绊绊换来的。5.1 死锁排查的三个常见错误第一个错误只取一份线程栈。死锁和阻塞可不是一秒钟就结束的快节奏事件但单份dump只能反映一个瞬间容易被误判成偶发。正确的做法是间隔数秒连续取多份重点关注线程状态和锁信息的变化趋势。第二份、第三份dump里还是同样一堆线程卡在同一批锁上这才能确认是持续性的死锁。第二个错误只盯BLOCKED线程。实际上WAITING状态的线程很多也是死锁链条的一部分因为它们可能在等待通知或条件变量。线程栈里不能只筛BLOCKED要把WAITING、TIMED_WAITING一起看才能拼出完整的等待关系。第三个错误拿到死锁信息后不继续看业务代码只想着靠重启或者调大线程池去糊弄。调线程池只能让进程晚一会崩溃死锁还在那吞吐量照样上不去。死锁的根本是锁的申请顺序不把加锁顺序统一这个问题会反复出现。5.2 内存泄露定位的实用技巧说几个在堆分析里特别实用的技巧都是我实际用下来帮我省过时间的。第一对比堆快照的时候不要只盯对象数量要看对象净增量。比如一个类的实例数增加了50万个但里面存的都是小字符串可能只占几十MB另一个类只增加了1万个实例但每个实例都持有一个大集合那后者才是真正的内存杀手。用MAT的Dominator Tree按Retained Heap排一下优先看保留堆占比高的对象。第二找不到根因时把dump文件里的引用链拉出来看。右键对象选Path to GC Roots它会把从GC Root到目标对象的整条引用路径显示出来。很多对象之所以无法回收不是因为业务代码直接保存了它而是被某个静态集合、ThreadLocal、或者事件监听对象间接持有了。这条链路会让你看到真正隐藏起来的根。第三ThreadLocal使用要注意。ThreadLocal线程局部变量最大的坑就是在使用结束后没有调用remove()清理。配合线程池的时候线程对象本身会复用如果不清理ThreadLocal上个请求的对象就一直被线程持有着下个请求又往里面放新对象这也是典型的内存泄露来源之一。用完即调remove或者至少用try-finally包裹住宿主的清理逻辑这是最基本的习惯。第四不是所有内存涨都是泄露。GC本身的分代回收也会让老年代缓慢波动尤其在对象存活率高的场景下老年代上涨是正常的但如果Full GC之后内存回收比例很低而且隔一段时间又涨回去那才是真正的泄露形态。5.3 防止死锁和内存泄露再次出现的机制修复完一个案例只能在时间轴上画一个句点。真正防止它们再次出现我总结下来是三个动作代码规范、上线检查、常态压测。代码规范方面加锁顺序约定要写进团队的开发规范里所有涉及多锁的地方必须评审。像这次这种跨服务加锁的场景每次改动都需要标注清楚锁的获取顺序做到一眼就能看出是否一致。静态集合的使用同理Entry的put和remove必须成对出现禁止在代码里创建只写不删的全局缓存。上线检查方面我建议把GC参数、堆转储参数、线程dump脚本做进发布流程的必配项哪怕只是普通的迭代上线这些诊断钩子也必须带着走。很多性能事故之所以定位不出原因就是因为现场没有被采集下来等重启之后什么都查不到了。常态压测方面稳定环境里的性能回归测试不能只在版本大改前做一次。死锁和内存泄露很多时候是被代码重构、加需求、加缓存这类小改动引进来只跑功能和接口测试根本看不见只有至少每周一轮的小型压测才能兜底。我自己的习惯是每个服务交付前至少跑一轮30分钟的中等负载压测同时在压测完成后把GC日志和线程栈归档。归档出来的数据就是最好的资产下次再出问题直接翻历史数据对比是不是又出现同样的台阶形内存曲线、同样的线程阻塞组合一眼就能认出来。最后再分享一个小技巧也算是我这几年最深的体会性能问题定位最忌讳猜。每一个结论都需要有监控数据、线程栈、堆转储这些现场证据做支撑。把取证的命令和脚本提前准备成一份checklist压测前花10分钟确认好GC日志参数、堆转储参数、dump脚本就位现场的每一分钟才真正花在定位根因上而不是手忙脚乱地补工具、补参数。这套习惯救过我太多次希望这篇文章里拆出来的重复过程也能帮你省下几个加班的夜晚。
阅读完成 · 觉得有帮助?