← 返回博客
2026-10-23 08:00:01

从一次接口 P99 抖动 800ms,看清锁升级、AQS 与线程池调优

从一次接口 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 抖动时,你手里就有数据,而不是只能猜。