从一次接口 P99 抖动 800ms,看清锁升级、AQS 与线程池调优
一个订单查询接口,平时 P99 稳定在 120ms。某次大促压测,P99 突然跳到 900ms 以上,CPU 使用率却只有 40%,GC 日志干干净净,没有 Full GC。这种"CPU 不忙、接口很慢"的组合,八成卡在锁和线程池上。排查路径就三步:先看线程在等什么锁,再看锁为什么膨胀,最后看线程池是不是在排队。
一、先定位:线程都堵在哪
jstack 打三次,间隔 5 秒,重点找 BLOCKED 和 WAITING 的线程。当时的快照长这样:
"http-nio-8080-exec-23" BLOCKED
at com.demo.order.OrderCache.get(OrderCache.java:41)
- waiting to lock <0x00000006c0a8f3a0> (a com.demo.order.OrderCache)
"http-nio-8080-exec-24" BLOCKED
at com.demo.order.OrderCache.get(OrderCache.java:41)
- waiting to lock <0x00000006c0a8f3a0> (a com.demo.order.OrderCache)
二十多个 Tomcat 工作线程同时堵在同一把对象锁上。问题代码很朴素:
public class OrderCache {
private final Map<Long, Order> cache = new HashMap<>();
public synchronized Order get(Long id) {
Order o = cache.get(id);
if (o == null) {
o = loadFromDb(id); // 慢查询 50~200ms
cache.put(id, o);
}
return o;
}
}
loadFromDb 在锁内执行,一次回表就把所有读线程串行化。线程池大小是 200,但真正能干活的只有一个。这就是 CPU 空闲、P99 飙升的直接原因。
二、锁为什么救不了你:升级路径的真相
第一反应是"把 synchronized 换成 ReentrantLock 就好了"。没用。锁的互斥性不会因为换个 API 消失。要理解这点,得回到 HotSpot 的对象头。
每个对象头里的 Mark Word 在 64 位 JVM 下是 64 bit,低两位是锁标志位。锁升级是单向的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,降不回去。JDK 15 起偏向锁已默认关闭(JEP 374),所以现在讨论的重点是后两级。
轻量级锁靠 CAS 把 Mark Word 指向线程栈上的 Lock Record。一旦有第二个线程来抢,CAS 失败,就会膨胀成重量级锁,线程进入 ObjectMonitor 的 cxq/EntryList 排队,然后 park() 挂起。挂起和唤醒都是内核态操作,单次开销在微秒级——单个线程无所谓,几十个线程反复争抢时,上下文切换成本会吃掉所有收益。
问题代码的锁持有时间是 50~200ms,这已经远超"轻量级锁值得优化"的窗口。锁升级在这里不是"变慢的原因",而是"锁本身就不该被持有这么久"。锁优化的第一原则永远是缩小临界区,而不是换锁实现。
改法是把 IO 挪到锁外,用分段思路降低冲突:
public class OrderCache {
private final ConcurrentHashMap<Long, Order> cache = new ConcurrentHashMap<>();
public Order get(Long id) {
Order o = cache.get(id);
if (o != null) {
return o;
}
// 只对同一个 key 做串行,不同 key 互不阻塞
return cache.computeIfAbsent(id, this::loadFromDb);
}
}
computeIfAbsent 内部对桶位做 CAS,只有哈希冲突到同一个桶的 key 才会短暂互斥。实测同样的压测流量,P99 从 900ms 回落到 150ms 左右。这里有个坑:computeIfAbsent 的映射函数里绝不能做嵌套更新同一个 map 的操作,会死锁;也不要在里面做长事务,它仍然持有桶锁。
三、换成 AQS 之后,为什么还是慢
缓存改完,P99 降到 200ms,但没到 120ms 的基线。继续看线程栈,这次堵在 ReentrantLock.lock() 上——下单链路里有个限流器,用 AQS 手写的:
public class SimpleRateLimiter {
private final Sync sync = new Sync();
private static class Sync extends AbstractQueuedSynchronizer {
Sync() { setState(100); } // 100 个许可
@Override
protected int tryAcquireShared(int n) {
for (;;) {
int cur = getState();
int next = cur - n;
if (next < 0) return next;
if (compareAndSetState(cur, next)) return next;
}
}
@Override
protected boolean tryReleaseShared(int n) {
for (;;) {
int cur = getState();
if (compareAndSetState(cur, cur + n)) return true;
}
}
}
public boolean tryAcquire() { return sync.tryAcquireShared(1) >= 0; }
public void release() { sync.tryReleaseShared(1); }
}
AQS 的骨架是 state 加一个 CLH 变体的双向队列。tryAcquireShared 返回负数时,线程被包成 Node 入队,然后 LockSupport.park()。这里的问题不是 AQS 慢,而是这个限流器把"排队等待"当成了默认行为——明明是限流,等不到就该快速失败,而不是让 200 个线程全挂起。
改成非阻塞语义:
public boolean tryAcquire() {
return sync.tryAcquireShared(1) >= 0; // 拿不到直接 false,不入队
}
调用方拿到 false 就走降级逻辑。这一步把等待队列从几百个 Node 压到接近零,P99 回到了 130ms 附近。AQS 的正确用法是先想清楚"等还是不等":tryAcquire 系列是非阻塞的,acquire 才会入队阻塞。用错语义,AQS 就是个放大器。
四、最后一块拼图:线程池参数
链路通了,但线程池配置还埋着雷。原来的配置是 corePoolSize=200, queueCapacity=10000, maxPoolSize=200。看似"大池大队列很安全",实际是灾难:队列堆到一万,任务延迟被无限拉长;maxPoolSize 等于核心数,等于永远不扩容;拒绝策略是 CallerRunsPolicy,在 Web 容器里会让 Tomcat 线程去跑业务,雪崩时互相拖死。
线程池扩容的规则要记牢:只有队列满了才会创建超过 core 的线程。队列一万,maxPoolSize 永远用不上。正确做法是队列短、池子可扩、拒绝要快:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
32, 128,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(256), // 短队列,逼出扩容
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
new ThreadPoolExecutor.AbortPolicy() // 快速失败,交给上游限流
);
32 核机器上,CPU 密集型任务核心数取核数左右,IO 密集型按 核数 × (1 + 等待时间/计算时间) 估。这个接口的等待占比约 3:1,32×4=128 是上限,核心 32 只是稳态。队列 256 是刻意压小的——队列越长,任务在池里排队的时间越不可控,监控上看到的 RT 就越假。
收尾
这次抖动没有一行是"JVM 调优"能解决的:锁升级告诉你临界区别放大,AQS 告诉你阻塞语义要想清楚,线程池告诉你队列长度就是延迟。三件事指向同一个判断——并发问题的根因通常在代码结构,不在参数。
下一步建议很具体:给线上线程池加上 getQueue().size() 和活跃线程数的定时上报,再配一个 jstack 定时采样脚本。等下次 P99 抖动时,你手里就有数据,而不是只能猜。