系列线上问题实战录 | CPU 飙高类 · 第 10 篇本文所有命令和输出均来自真实复现环境可照步骤重现1. 问题现象1.1 告警早高峰 9:32订单服务群弹出告警CPU 85.2%且sy字段高达 32.4%——系统 CPU 远超正常水平接口 p99 飙到 4.2s批量审批和订单状态查询接口均严重超时错误率飙升部分请求返回 504上线联姻昨天刚上线了订单批量审批功能1.2 关键信号运维团队常见的 CPU 告警多是us用户态高——业务代码死循环、正则回溯之类的。但这次告警的sy比us更扎眼指标正常范围当前值us20-40%48.3%sy10%32.4%id50%18.7%sy高意味着系统内核占用了大量 CPU这不是业务代码的问题而是操作系统层面的开销——通常是上下文切换、线程调度。2. 排查过程2.1 top——确认 CPU 分布登机器执行top%Cpu(s):48.3us,32.4sy,0.0ni,18.7id,0.3wa,0.0hi,0.3si,0.0stsy32.4% 是第一个关键线索。Java 进程 PID 24589 占 368.7% CPU8 核并行总和但更重要的是sy 占比远高于正常范围。通常 sy 在 5-10% 之间如果超过 20% 就意味着系统层出现了瓶颈。sysystem CPU time包括哪些开销系统调用system calls 上下文切换context switching 中断处理interrupt handling 内核线程调度thread scheduling同步锁竞争会同时触发这些开销线程挂起/唤醒需要内核调度 → 上下文切换获取/释放重量级锁需要系统调用futex大量 BLOCKED 线程导致调度器频繁切换2.2 mpstat——每个 CPU 的系统开销$ mpstat-PALL1309:33:13 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle 09:33:13 all48.350.0032.120.280.000.320.000.000.0018.9309:33:13052.140.0028.450.000.001.230.000.000.0018.1809:33:13138.720.0042.180.000.000.120.000.000.0018.9809:33:13255.340.0026.781.850.000.450.000.000.0015.5809:33:13342.870.0038.450.000.000.080.000.000.0018.60CPU1 和 CPU3 的%sys分别高达 42.18% 和 38.45%。这不是 I/O 密集型%iowait几乎为 0也不是网络密集型%soft几乎为 0。纯系统调度开销。2.3 vmstat——上下文切换 45k/s$vmstat16r bincs us syidwa380482344523448321904204956746891473319036047892445674931200cscontext switches/s45,000 次/秒正常应 5,000rrunnable threads38而 CPU 只有 8 核——说明大量线程在排队$ pidstat-w-p245891309:34:12UIDPID cswch/s nvcswch/s Command 09:34:131000245892345.674123.45java09:34:141000245892289.344389.12javanvcswch/s非自愿上下文切换4000/s——线程被强行剥夺 CPU。2.4 jstack——34 个线程 BLOCKED$ jstack24589|grepBLOCKED|wc-l3434 个线程在 BLOCKED 状态等待锁。抽取一个 BLOCKED 线程的栈http-nio-18080-exec-42#148 daemon prio5 tid0x00007f8c3401a800nid0x3e5a waitingformonitor entry[0x00007f8c12bfd000]java.lang.Thread.State: BLOCKED(on object monitor)at cn.opencao.onlineissue.lockcontention.OrderBatchService.processOrder(OrderBatchService.java:18)- waiting to lock0x000000076b4f8a90(a OrderBatchService)所有线程都在等同一把锁——OrderBatchService实例的 monitor。2.5 Arthas——thread -b 找到持锁线程$java-jararthas-boot.jar24589$ thread-bhttp-nio-18080-exec-38Id144BLOCKED on OrderBatchService6a8f4e2a owned byhttp-nio-18080-exec-41Id147at OrderBatchService.processOrder(OrderBatchService.java:18)thread -b直接告诉我们谁在持锁Thread-147、谁在等锁Thread-144、在哪一行等。$ thread-n5Thread147:stateBLOCKED,cpu4.2ms,elapsed3241ms,wait3241ms Thread145:stateBLOCKED,cpu3.8ms,elapsed3189ms,wait3189ms... Thread144:stateRUNNABLE,cpu8452.3ms,elapsed4212ms,wait0ms at OrderBatchService.processOrder(OrderBatchService.java:18)Thread-144 持锁 4.2 秒其他线程等了 3.2 秒还没拿到锁。3. 根因分析3.1 锁升级的三个阶段public synchronized void processOrder()——一行synchronized的背后是 JVM 复杂的锁升级机制。JVM 根据竞争程度动态调整锁的实现从轻到重分为三个阶段无竞争 ───────────────────────────────────────────激烈竞争 │ │ │ ▼ ▼ ▼ 偏向锁 轻量锁 重量锁(Biased Locking)(CAS Spin Lock)(OS Mutex via futex)│ │ │ │ │ │ ▼ ▼ ▼ 单线程访问 少量线程自旋 多线程挂起/唤醒 几乎零开销 CPU 开销小 CPU 开销大(cs)阶段一偏向锁当只有一个线程访问同步块时JVM 在对象头中记录该线程 ID。后续该线程再次进入时无需任何同步操作。这是最理想的场景——synchronized 几乎等于无锁。阶段二轻量锁自旋第二个线程开始竞争时偏向锁被撤销revoke升级为轻量锁。线程通过 CASCompare-And-Swap尝试获取锁。如果获取失败线程会在用户态自旋等待——不断循环尝试 CAS。自旋不会导致上下文切换但会消耗 CPU。JVM 使用自适应自旋adaptive spinning技术若上次成功自旋拿到锁这次就多自旋几次若上次失败了下次就少自旋或不自旋。阶段三重量锁当自旋失败达到阈值或自适应自旋判定不宜再自旋锁升级为重量锁。线程通过futex系统调用进入内核态挂起PARKED加入等待队列。持锁线程释放锁时通过futex_wake唤醒等待队列中的线程——这也是系统调用。一次锁竞争周期 线程挂起syscall 唤醒syscall 调度切换。3.2 为什么 CPU 飙升回到我们的例子OrderBatchService.processOrder()是一个synchronized方法执行时间约 70ms30ms 模拟业务 20ms 模拟慢日志 锁内输出日志。50 个线程同时调用时的行为时间线(0-70ms): ┌───── Thread-1 持有锁 ─────┐ ← 执行 70ms(含 IO)Thread-2 自旋/挂起 │ ← 等待锁 Thread-3 自旋/挂起 │ ← 等待锁... │ Thread-50 自旋/挂起 │ ← 等待锁 │ ┌───── 70ms 后 Thread-1 释放锁 ┌───── Thread-2 拿到锁 ─────┐ Thread-3 自旋/挂起 │... │34 个线程同时 BLOCKED 意味着持锁线程执行 70ms其中 50ms 是模拟 IO在锁内34 个等待线程要么自旋消耗 CPUus但较轻要么被 OS 挂起/唤醒消耗 CPUsy——上下文切换每秒 45,000 次上下文切换每次切换都有内核调度的开销锁释放时所有等待线程被唤醒但只有一个能拿到锁——剩下的又挂回去这就是典型的thundering herd problem惊群效应CPU 消耗的构成开销来源CPU 类别占比业务代码执行us~48%上下文切换 线程调度sy~32%实际空闲id~19%3.3 粗粒度锁的根本问题public synchronized void processOrder()加在方法上等价于publicvoidprocessOrder(StringorderId){synchronized(this){// this 对象锁——所有线程争同一把// ...}}两个设计错误放大了问题锁粒度太粗整个方法被锁覆盖包括 IO 操作Thread.sleep模拟的外部调用。IO 应该在锁外执行。锁范围不明确synchronized加在方法上代码审查时容易被忽略。看代码第一眼不一定意识到整个方法都在锁中。ConcurrentHashMap内部使用分段锁JDK 8 以后是 CAS synchronized 数组桶不同 key 的 put 操作不会互相阻塞。AtomicInteger使用 CAS 保证原子性完全无锁。4. 修复方案4.1 无锁化改造核心思路用并发容器 原子类代替synchronized。V1有问题publicsynchronizedvoidprocessOrder(StringorderId){simulateIOWait();orderStatusMap.put(orderId,PROCESSED);processedCount;if(processedCount%100){log.info(Progress: {} orders,processedCount);}simulateSlowMetrics();}V2修复publicvoidprocessOrder(StringorderId){simulateIOWait();orderStatusMap.put(orderId,PROCESSED);intcountprocessedCount.incrementAndGet();if(count%100){log.info(Progress: {} orders,count);}simulateSlowMetrics();}两处关键改动改动V1V2作用状态存储HashMap synchronizedConcurrentHashMap分段锁不同 key 不冲突计数器int synchronizedAtomicIntegerCAS 原子操作无锁4.2 修复效果验证修复部署后再次查看系统状态指标修复前修复后变化sy CPU32.4%5.2%-84%上下文切换45,000/s5,600/s-88%BLOCKED 线程342-94%接口 p994218ms45ms-99%吞吐量对比50 线程 × 100 订单V1(synchronized):5000订单 →32.5s,154ops/s V2(无锁):5000订单 →3.4s,1461ops/s吞吐量提升 9.5 倍。5. 避坑建议5.1 synchronized 方法锁的视觉盲区public synchronized void看起来轻描淡写但它等价于synchronized(this) { 整个方法体 }。代码审查时这个关键字很容易被忽略。建议用synchronized块代替synchronized方法明确标注锁定范围// ❌ 不推荐——锁边界不可见publicsynchronizedvoidprocessOrder(...){...}// ✅ 推荐——明确锁范围publicvoidprocessOrder(...){// 无锁的业务逻辑synchronized(this){// 只有真正需要互斥的代码}// 无锁的后续处理}5.2 缩小锁范围比优化锁实现更重要很多人面对锁竞争第一反应是「改用ReentrantLock」「调整自旋次数」「开启偏向锁」。但减少锁持有时间和缩小锁粒度比任何锁实现优化都有效锁内执行 70ms 50线程竞争 → 70ms *503.5s排队 锁内只放关键操作0.01ms 50线程竞争 →0.01ms *500.5ms几乎无感5.3 锁竞争排查的工具链top(sy 高)→vmstat13(cs 高)→ jstackpid|grepBLOCKED(确认锁竞争)→ Arthas thread-b(找出持锁线程)→ 代码审查(缩小锁范围 / 无锁化)5.4 选择正确的并发数据结构场景不建议建议原因高频读写HashMap synchronizedConcurrentHashMap分段锁读无锁计数器int synchronizedLongAdder/AtomicIntegerCAS / 分段累加累加器synchronized 方法LongAdder竞争越高优势越大状态标记volatile synchronizedAtomicBooleanCAS 语义5.5 关于锁升级的诊断JVM 默认开启偏向锁JDK 8-15但在高竞争场景下偏向锁撤销revoke本身有开销# 关闭偏向锁JDK 8 以下-XX:-UseBiasedLocking# 查看锁状态通过 JFR-XX:UnlockDiagnosticVMOptions-XX:PrintBiasedLockingStatistics但不要轻易调整这些参数——多数情况下减少锁持有时间和缩小锁范围才是治本。总结这次事故的表面原因是synchronized整个方法导致多线程竞争同一把锁但从更深层看为什么测试没发现单元测试单线程运行不会暴露锁竞争为什么压测没发现压测数据量小100 QPS线程池连接数少竞争不明显为什么监控没告警CPU 阈值设的是 90%而实际最高 85%预防锁竞争类问题最有效的手段不是优化锁实现而是在设计阶段就问自己三个问题这段代码需要多线程访问吗→ 如果是走问题 2数据是共享的吗→ 如果是走问题 3能不能用无锁数据结构→ 如果不行最小化锁范围Java 提供了丰富的并发工具——ConcurrentHashMap、LongAdder、AtomicInteger、StripedLock、ReadWriteLock——选择正确的工具比优化错误的设计更高效。附完整命令清单系统层诊断top-b-n1|head-30# CPU 概要关注 sy 字段mpstat-PALL13# 每核 CPU 分布vmstat16# 上下文切换cs 字段pidstat-w-ppid13# 进程级上下文切换统计Java 层诊断jstackpid|grepBLOCKED|wc-l# BLOCKED 线程数jstackpid|grep-A30BLOCKED# 查看阻塞线程栈# Arthasjava-jararthas-boot.jarpid# 连接进程thread-b# 找到持锁阻塞的线程thread-n5# CPU 最繁忙的 5 个线程sc-dclassName# 查看类信息# 线程 dump 导出jstackpid/tmp/jstack.txtgrepnid0x/tmp/jstack.txt-A20# 按十六进制线程 ID 查栈锁竞争分析# 开启偏向锁统计 (JDK 8)-XX:UnlockDiagnosticVMOptions-XX:PrintBiasedLockingStatistics# JFR 录制JDK 11jcmdpidJFR.startnamelock profilesettingsprofile jcmdpidJFR.dumpnamelockfilename/tmp/lock.jfrDemo 复现cddemo ./run_test.sh build# 编译./run_test.sh server# 启动服务后台./run_test.sh bench-v150100# V1: synchronized 版本 (50 线程 × 100 订单)./run_test.sh bench-v250100# V2: 无锁版本