Java

锁竞争不高却吞吐掉得厉害,先查线程切换还是下游等待?

锁竞争指标不高,不等于并发链路就健康。吞吐下降时,更有价值的是把线程切换、队列波动、连接池等待、下游阻塞和伪并发边界放进同一段证据里看清楚,再判断问题是线程在空耗 CPU,还是业务线程长期卡在等待型瓶颈上。

  • Java
  • 并发
  • 线程池
  • 锁竞争
  • 性能排查
17 分钟阅读

线上吞吐掉下来时,很多团队第一反应会先看两类指标:

  • CPU
  • 锁竞争

这当然没错。但特别容易把人带偏的一类现场是:

  • 吞吐明显下降
  • 任务耗时在拉长
  • 队列开始有点抖
  • 但锁竞争指标并不高
  • 甚至看起来几乎没有严重锁争用

这时很容易出现一句很有迷惑性的判断:

锁也没怎么抢,那并发层应该没什么问题吧?

这句话经常只对一半。

因为 吞吐下降并不一定靠“高锁竞争”来表现。真实线上里,更常见的两条主线其实是:

  • 线程没有在争同一把大锁,但一直在切来切去,做了很多无效调度
  • 线程看起来也不忙争锁,却大量卡在下游、连接池、网络、Future、队列或别的等待点上

也就是说,这类问题真正要分清的,通常不是“有没有锁”,而是:

线程到底是在被频繁切换耗掉了吞吐,还是根本没有在做有效工作,而是在等下游、等连接、等任务结果?

想先抓住现场重点,可以先记住一句更直接的话:

先看线程多数时间落在 RUNNABLEWAITING 还是 BLOCKED;再把上下文切换、线程池 active / queue、连接池 pending 和下游 RT 对齐到同一段时间窗里,基本就能看出是调度空耗在吃吞吐,还是等待型瓶颈在把线程挂住。

如果锁竞争不高但吞吐掉了

你现在看到的现象更适合先看哪条线为什么
锁竞争不高,但吞吐、线程池、连接池、下游等待一起变差继续看本文这正是“锁没怎么抢,但线程和下游一起把吞吐拖下去”的现场
你已经确认主要是分布式锁热点 key、粒度、续约问题先看《分布式锁没报错但吞吐变差,应该先查续约还是锁粒度?那篇更适合确认锁热点、粒度和续约是不是主因
你还没判断这个场景要不要用分布式锁先看《分布式锁什么时候该用,什么时候不该用先分清是不是互斥问题
你当前最严重的是重复消费、重复副作用没有收住先看《幂等校验明明通过了,为什么消息还是会重复消费?那篇更适合先把重复副作用这条线收住
你当前最严重的是 MQ backlog 压不下去、团队想直接扩消费者先看《消息越堆越多,为什么常常不是消费者数量不够,而是下游变慢了?那篇更适合先确认是不是下游变慢把消费侧拖住了

先确认这是不是你眼前那种吞吐问题

如果你现在看到的是锁竞争并不高,但吞吐、线程池、连接池和下游 RT 一起变差,这篇就值得先看。

我不是想讨论分布式锁该不该上,也不打算展开幂等和 backlog 那几条线。

我更想先把一个常见误判掰正:

锁没怎么抢,不等于并发层就没问题;线程也可能一直在切、一直在等,最后把吞吐空耗掉。

先把这条判断立住,后面才不会过早得出“不是锁问题所以先别看并发链”的结论。

先做一轮快速判断

  1. 先看线程状态分布,分清线程主要是在 RUNNABLEWAITING 还是 BLOCKED
  2. 再看 pidstat -wvmstat 有没有明显切换升高。
  3. 然后把线程池 active / queue、连接池 pending、下游 RT 放进同一时间窗对齐。
  4. 如果线程主要停在连接获取、RPC、Future.get()、数据库调用上,优先怀疑等待型瓶颈。
  5. 只有当线程真的在 CPU 前高频切换、任务很碎、Runnable 线程过多时,再把主线收敛到调度空耗。

一、为什么“锁竞争不高”会制造错觉

因为很多人默认把吞吐下降理解成:

  • 大锁冲突
  • synchronized 打满
  • ReentrantLock 抢不到

这当然是吞吐变差的一种经典原因,但远远不是唯一原因。

真实工程里,更常见的几类吞吐下降其实长这样:

1. 线程并没有被大锁卡住,但频繁被调度

例如:

  • 线程数开得太多
  • CPU 核数不够承接当前 runnable 线程规模
  • 任务很碎、很短、切换非常频繁
  • 上下文切换成本开始吃掉有效工作时间

这时你看到的不是高锁竞争,而是:

  • runnable 线程不少
  • CPU 未必完全打满
  • 吞吐却提不上去

2. 线程不在争锁,而是在等资源

例如:

  • 等数据库连接
  • 等慢 SQL
  • 等下游 RPC
  • 等 Redis
  • 等网络建连
  • 等 Future 汇总结果

这时线程池看起来有活跃线程,但它们没有在推进工作,而是在等待型瓶颈上耗时间。

3. 任务模型本身是伪并发

例如:

  • 线程很多,但热点 key、热点租户、热点分区把任务串行化
  • 线程池很大,但真正有效并发度极低
  • 任务外面没锁,里面却依赖同一条下游链路或同一连接资源

这时锁竞争指标当然不高,但吞吐照样会掉。

所以“锁竞争不高”最多只能说明:

  • 没有明显的大锁争用证据

它不能说明:

  • 调度没有空耗
  • 线程没有等待
  • 并发模型真的高效

二、第一步别先选边:先分清线程是在跑、在切,还是在等

如果你现在就碰到了这种现场,第一刀不要先切“线程切换”还是“下游等待”,而是先把线程行为分型。

至少先回答这三个问题:

  1. 线程主要处在 RUNNABLE、WAITING、TIMED_WAITING,还是 BLOCKED?
  2. CPU 时间主要花在线程执行上,还是上下文切换和等待上?
  3. 高峰窗口里,线程池活跃数、队列、连接池、下游 RT 有没有一起变化?

更建议一起看哪些证据

  • top -Hp
  • pidstat -t -w
  • vmstat
  • jstack
  • 线程池 active / queue / reject
  • 连接池 active / pending
  • 下游 RT / timeout / QPS

这几组证据放在一起,比单看“锁竞争率”有解释力得多。

三、更像线程切换空耗时,常见会有什么信号

如果问题更偏线程切换,通常会看到下面这些特征。

1. 上下文切换明显升高

这是最直接的信号之一。

例如:

  • pidstat -w 看到自愿或非自愿上下文切换明显抬高
  • vmstat 里 cs 指标异常活跃
  • CPU 不一定 100%,但调度很忙

2. 线程数明显偏多,Runnable 线程堆在 CPU 前面

这类场景经常发生在:

  • CPU 核数有限
  • 线程池开得太激进
  • 短任务被拆得过碎
  • 多个线程池同时高活跃

这时即使没有大锁,线程之间也会因为抢 CPU 时间片而频繁切换。

3. jstack 看起来没有明显阻塞点,但热点线程位置分散

如果你多次抓栈发现:

  • 没有特别明显的大量 WAITING 在同一资源上
  • 线程都在跑各种短逻辑
  • 热点位置来回跳

那就要考虑调度空耗和线程模型问题,而不是先去怀疑下游。

4. 吞吐下降伴随 CPU 使用效率变差

这类问题最典型的感受是:

  • CPU 不是完全没空
  • 但吞吐没有跟着 active 线程数上涨
  • 甚至线程越多,系统越“热闹”但越不出活

这时候就很像线程在忙着切换,而不是忙着完成任务。

四、更像下游等待时,常见会有什么信号

另一条更高频的主线,是线程都没有被大锁卡住,但被等待型瓶颈拖住了。

1. 线程大多停在等待、网络、连接池、Future 或数据库调用上

这在 jstack 里通常很明显。

常见停留点包括:

  • 连接池获取连接
  • JDBC 调用
  • Socket read
  • HTTP client 调用
  • Redis client 调用
  • Future.get() / CompletableFuture.join()
  • 队列 take / poll

2. 连接池、下游 RT、慢 SQL、超时率一起抬头

这是最有解释力的一组信号。

例如:

  • 锁竞争不高
  • CPU 也没特别炸
  • 但连接池 pending 上升
  • 下游 RT 变长
  • 线程池 active 居高不下

这时吞吐下降的根因通常不在锁,也不在调度,而在线程迟迟释放不出来。

3. queue 不长,但单任务耗时在变长

这种现场非常典型。

任务一进线程池就能拿到线程,但拿到之后:

  • 在数据库里等
  • 在下游里等
  • 在 Future 汇总结果时等

这时整体吞吐还是会掉,只是队列和锁都不一定很刺眼。

4. 线程状态更像“有活跃数,但没有效推进”

这句话很关键。

吞吐下降不是因为线程不工作,而是因为:

  • 线程看起来都在忙
  • 实际却在忙着等别人

五、一个特别常见的误区:把“没有锁争用”误判成“并发层没问题”

并发问题从来不只等于锁问题。

更准确地说,并发层至少还包括:

  • 线程数和 CPU 核数的关系
  • 任务粒度
  • 线程池隔离和共享
  • 上下文切换成本
  • 队列行为
  • Future / 回调依赖关系
  • 下游等待传播

所以如果你只拿“锁竞争不高”来给并发层开绿灯,很容易漏掉两类高频问题:

1. 线程模型不经济

例如:

  • CPU 密集任务开了大量线程
  • 短任务拆得太碎
  • 多个线程池彼此争 CPU
  • 线程池参数不匹配任务类型

2. 等待型并发问题

例如:

  • 业务线程都在等下游
  • 汇总线程都在等子任务
  • 连接池和线程池互相放大等待
  • 本地没锁,但外部链路把并发压扁了

六、一个更稳的排查顺序

如果你在线上碰到“锁竞争不高,但吞吐明显掉”的问题,我更建议按下面顺序走。

第 1 步:先看线程状态分布

先回答:

  • BLOCKED 多不多
  • WAITING / TIMED_WAITING 多不多
  • RUNNABLE 多不多

如果 BLOCKED 并不突出,就先别把主线放在锁上。

第 2 步:再看上下文切换

重点看:

  • 线程切换是否显著升高
  • Runnable 线程规模是否过大
  • 线程数是否远超 CPU 适配范围

如果切换很高,说明吞吐下降可能和调度空耗有关。

第 3 步:把线程池、连接池、下游 RT 放进同一窗口

看是否存在这类联动:

  • 线程池 active 高位
  • queue 不一定长
  • 连接池等待变长
  • 下游 RT 抬升
  • SQL 或 Redis RT 抖动

如果这些一起变差,更像等待型瓶颈。

第 4 步:看是否存在伪并发边界

例如:

  • 热点 key
  • 单租户热点
  • 分区串行化
  • Future 汇总等待慢分支
  • 某个下游单连接 / 单资源限制

这一步能解释很多“锁不高但吞吐就是起不来”的现场。

第 5 步:最后才决定优化方向

  • 如果更像切换空耗,优先回到线程数、任务粒度、线程池模型
  • 如果更像下游等待,优先回到连接池、数据库、缓存、RPC、Future 汇总链路

七、一个典型例子:为什么锁不高,吞吐还是从每秒 2000 掉到 800

假设某个异步消费服务出现下面的现象:

  • 吞吐从 2000/s 掉到 800/s
  • 锁竞争监控几乎没明显异常
  • 线程池 active 很高
  • queue 只轻微抖动

第一眼很多人会先排除并发层问题。

继续往下看:

  1. pidstat -w 发现上下文切换比平时高了一截,但不是唯一异常
  2. jstack 发现大量 worker 线程停在数据库连接获取和 HTTP 下游调用上
  3. 连接池 pending 明显抬升
  4. 某次发布后,每个任务新增了两次下游回查
  5. 线程虽然没被锁住,但大量时间都花在等连接和等下游

这时链路就很清楚:

  • 吞吐下降不是因为本地大锁
  • 也不完全是因为线程切换空耗
  • 真正主因是等待型瓶颈把线程长期占住
  • 上下文切换只是线程规模和等待传播下的伴生现象

这个例子特别能说明:

“先查线程切换还是下游等待”不是拍脑袋选边,而是要靠线程状态、切换数据和下游证据一起定。

八、关键误判:这类问题最容易在哪些地方走偏

误判 1:锁竞争不高,就直接排除并发问题

并发问题远不只锁竞争,还包括调度、线程模型、Future 依赖和伪并发边界。

误判 2:上下文切换高,就默认全是线程数太多

切换高可能是结果,不一定是起点。

如果线程是被下游等待拖住,线程规模被动膨胀后,切换也会跟着高。

误判 3:只看 CPU,不看线程状态

CPU 只能告诉你忙不忙,不能告诉你线程到底在跑什么、等什么。

误判 4:只看线程池,不看连接池和下游

很多吞吐下降问题真正的起点在线程池外。

误判 5:把“线程都活跃”理解成“系统有高效并发”

活跃线程高,不等于有效推进高。很多时候只是很多线程一起卡住。

九、FAQ:这类问题里最常被问到的几个问题

1. 锁竞争不高,是不是说明先不用看并发层?

不是。

更该看线程状态、上下文切换、队列行为和下游等待,而不是只用锁竞争一项做结论。

2. 怎么快速判断更像线程切换还是下游等待?

最实用的方法是同时看:

  • pidstat -w / vmstat 的切换信号
  • jstack 的线程停留点
  • 连接池和下游 RT 是否同步变差

3. queue 不长但吞吐掉了,说明什么?

常见说明两件事:

  • 任务拿到线程后执行链变慢了
  • 真正瓶颈不在池内排队,而在执行中等待或伪并发边界

4. 这种问题要不要先扩线程池?

通常不建议先扩。

如果根因在下游等待或连接池,扩线程池只会把更多并发压向同一个等待点。

如果证据已经把你带到这些岔口

如果你已经确认问题更像等待型协作放大,下一步就按当前最强证据继续收:

十、按现有证据分岔到下一篇

如果你已经接受一个前提:吞吐下降不一定要靠高锁竞争来证明,那后面的文章也别按专题名翻,直接按你手里最硬的证据选。

十一、最后总结:别急着给并发层开绿灯,先看线程为什么没把活做完

锁竞争不高却吞吐掉得厉害,最容易让人误判的地方就是:

  • 没看到明显大锁
  • 就顺手把并发层排除掉

但真实现场更常见的是另外两种情况:

  • 线程在频繁切换中把 CPU 时间片空耗掉了
  • 线程没有被锁住,却长期卡在连接池、下游、Future、网络或数据库等待上

所以更实用的顺序不是先争论“到底算不算锁问题”,而是:

先看线程多数时间是在跑、在切,还是在等;再把上下文切换、线程池、连接池、下游 RT 和任务模型摆到同一段证据里,最后决定该回头收线程模型,还是先治理等待型瓶颈。

顺序一旦立住,很多原本会被一句“锁竞争不高”带偏的吞吐问题,就能更快收敛到真正的慢点上。