Skip to content

死锁 / 活锁 / 竞态定位

在总览中的位置Java 并发模型总览 §6
相关主题ExecutorService / 线程池 · JMM:happens-before / 安全发布

一句话定位

并发线上问题不要先猜语法点,先分型:卡死先看死锁与线程池饥饿,CPU 高先看活锁 / 自旋重试,偶发脏数据先看竞态与安全发布边界。

代码索引

主题Lab 说明源码
volatile 非原子自增导致的竞态丢更新volatile-vs-atomic-vs-lockVolatileVsAtomicVsLock.java
get-then-put 复合操作竞态、原子 API 修法map-concurrency-compareMapConcurrencyCompare.java
线程池饱和、拒绝策略、调用线程回退执行thread-pool-basicsThreadPoolBasics.java
队列策略导致的排队 / 扩线程 / 拒绝行为queue-strategy-compareQueueStrategyCompare.java

为什么需要

并发 bug 最大的工程代价,不是“难懂”,而是:

  • 本地不稳定复现:你测十次都没出错,线上一天出现一次就足够拖垮业务
  • 症状和根因错位:ANR 看起来像 UI 问题,根因可能是后台锁竞争;接口超时看起来像下游慢,根因可能是线程池饥饿
  • 排查窗口很短:很多问题要靠线程 dump、ANR traces、指标快照、日志时序一次性抓住

如果不先分型,最容易出现三种低效行为:

  1. 看见“卡住”就开始大面积加日志,却不知道该盯锁、队列还是中断
  2. 看见“CPU 高”就怀疑死循环,却没发现是 CAS 活锁 / 重试风暴
  3. 看见“数据偶发不一致”就怪数据库,实际上是本地共享状态竞态

底层机制

1. 四类问题先分清

问题类型典型现象本质
死锁(Deadlock)线程彼此等待,系统卡死不前环状资源等待
活锁(Livelock)线程都在运行,CPU 不低,但没有有效推进一直礼让 / 重试 / 回退
竞态(Race Condition)结果依赖执行时序,偶发错误缺少同步边界或原子复合操作
饥饿(Starvation)任务长期拿不到线程 / 锁 / 资源资源分配不公平或被长期占满

2. 死锁最常见的两个来源

来源 A:多把锁获取顺序不一致

java
synchronized (lockA) {
    synchronized (lockB) {
        doWork();
    }
}

synchronized (lockB) {
    synchronized (lockA) {
        doOtherWork();
    }
}

一旦两个线程分别先拿到 lockAlockB,就会互相等待。

来源 B:线程池内部互相等待

  • 固定大小线程池中的任务 A 调 futureB.get()
  • 但任务 B 还没机会拿到线程执行
  • 表面像“没锁也卡死”,实质是线程池饥饿导致的类死锁

3. 活锁与重试风暴

活锁和死锁不同:

  • 死锁是“谁也不动”
  • 活锁是“大家都在动,但谁也没推进”

典型来源:

  • CAS 高冲突下疯狂重试
  • 两端都收到冲突后立刻回滚再重试,永远撞车
  • 没有退避策略的重试循环

4. 竞态通常长这样

java
// ❌ 看似无害,实则竞态
if (map.get(key) == null) {
    map.put(key, createExpensiveValue());
}

// ✅ 用原子复合 API
map.computeIfAbsent(key, k -> createExpensiveValue());

或者:

java
private volatile int count = 0;
count++; // ❌ 仍会丢更新

竞态最可怕的点不在于“代码复杂”,而在于:

  • 单测可能过
  • 压测不一定稳定复现
  • 线上高并发和特殊时序下才炸

5. 排障顺序:先定位类型,再决定抓手

A. 卡死 / ANR / 请求挂住

先看:

  • 线程状态是否 BLOCKED / WAITING
  • 是否有锁拥有者与等待链
  • 线程池活跃线程数、队列长度、拒绝次数
  • 有没有 Future.get() / CountDownLatch.await() / join() 等阻塞点

B. CPU 飙高

先看:

  • 是否有热点自旋循环
  • 是否有 CAS / 重试 / 空转 polling
  • 是否有极高线程数导致上下文切换

C. 偶发脏数据

先看:

  • 共享对象是否安全发布
  • 是否存在 get-then-put、check-then-act、read-modify-write
  • 是否误把 volatile 当成原子性工具

6. 最常用的排障工具

工具 / 信息源适合看什么备注
线程 dump / jstack / jcmd Thread.print锁等待、线程状态、死锁链卡死问题第一现场
JFR / async-profiler / profilerCPU 热点、锁竞争、等待分布看活锁、自旋、热点方法
ANR traces(Android)主线程被谁阻塞先看 main 线程,再看 Binder / worker
线程池指标activeCount、队列长度、拒绝次数判断饥饿、堆积、扩线程失控
时序日志提交、开始、结束、取消、超时没日志就很难还原现场

7. 最小复现往往比肉眼猜代码更快

当线上问题反复出现时,最有效的做法常常不是继续读大项目,而是:

  • 把相关共享状态抽出来
  • 把线程数缩到最小
  • CountDownLatch / sleep / 屏障控制交错时机
  • 先在 50 行 demo 里稳定复现

这也是仓库里这些 runtime-concurrency labs 的真正价值:不是背结论,而是能把问题缩小到能看懂的规模。

Android / Flutter / Web / Backend 对照

维度并发问题类型AndroidFlutterWebBackend
卡死死锁 / 饥饿 / 阻塞等待ANR、主线程等锁、Binder 线程耗尽主 isolate 被重任务拖住event loop 被长任务卡住请求线程池饥饿、锁等待
CPU 高活锁 / 自旋 / 重试风暴后台重试、过多线程争抢isolate 内忙循环主线程 busy loopCAS 热点、轮询、自旋
脏数据竞态 / 发布问题ViewModel / repo / 缓存状态错乱消息模型下少见共享竞态主要是异步顺序竞态缓存、状态机、并发 map 高频出现
排障入口dump、指标、日志ANR traces + Logcat + profilerDevTools timelinePerformance paneljstack + profiler + metrics

常见场景

1. Android 主线程等待后台结果

常见反模式:

  • 主线程直接 Future.get()
  • 主线程 CountDownLatch.await()
  • 主线程为了“等后台先做完”而拿锁等待

结果:

  • UI 卡顿甚至 ANR
  • 线程 dump 中 main 线程处于 WAITING / BLOCKED

2. 列表页快速滑动导致旧任务与新任务竞态

比如:

  • 缩略图任务 A 还没完成
  • 用户已经滑到新 item,任务 B 又提交了
  • A 结果晚到,却把新 item 的 UI 覆盖掉

修法通常是:

  • 用请求 token / 版本号校验“结果是否仍然有效”
  • 旧任务可取消时及时取消
  • 对预览任务池使用小队列 + DiscardOldestPolicy

3. Backend 缓存 miss 后重复构建

对应 Labmap-concurrency-compare

多个线程同时发现缓存 miss,如果还在写 if (get == null) put(),就会:

  • 重复创建昂贵对象
  • 甚至出现中间态暴露或覆盖

修法:

  • computeIfAbsent
  • 必要时外加更高层的去重 / 单飞(single flight)机制

4. 线程池看似没死锁,实际已饥饿

对应 Labthread-pool-basics · queue-strategy-compare

典型现象:

  • 请求越来越慢
  • 线程池活跃线程一直满
  • 队列越来越长或拒绝越来越多
  • 代码里没有显式 synchronized,却像“卡死”一样没推进

这时先别只盯锁,要看:

  • 线程池是否过小
  • 任务是否在池内相互等待
  • 队列策略是否导致过度排队

常见坑

现象修法
多把锁顺序不统一偶发死锁统一锁顺序,必要时 tryLock(timeout)
线程池任务互相等待看起来像死锁,实际是饥饿拆池、避免池内互等、给等待加超时
无上限重试CPU 高、日志刷屏、没结果退避、限次、熔断
volatile 误当原子性计数偏小、状态错乱改用 Atomic / 锁
没有线程命名dump 无法快速看出来源自定义 ThreadFactory
只靠肉眼读代码,不抓现场排障反复猜先拿线程 dump / ANR traces / 指标快照
用 sleep“修复”竞态本地像好了,线上继续炸建真实同步边界或版本校验

与相近概念对比

概念线程在干什么外部现象常见修法
死锁相互等待资源卡住不动统一锁顺序、超时、拆分资源
活锁一直运行但反复冲突CPU 高、无进展退避、限次、改竞争模型
竞态执行顺序决定结果偶发脏数据、重复执行原子 API、锁、不可变状态、版本校验
饥饿长期拿不到线程/锁某些任务一直等不到资源拆池、限流、调公平性/优先级

对应实验

Lab说明源码
volatile-vs-atomic-vs-lockvolatile-vs-atomic-vs-lockVolatileVsAtomicVsLock.java
map-concurrency-comparemap-concurrency-compareMapConcurrencyCompare.java
thread-pool-basicsthread-pool-basicsThreadPoolBasics.java
queue-strategy-comparequeue-strategy-compareQueueStrategyCompare.java

当前仍缺:死锁最小复现实验、活锁/退避对比实验、线程 dump / ANR 定位专属练习

复习检查题

  1. 为什么“线程都卡住了”不一定是传统死锁?

    :因为没有显式锁也可能出现类死锁现象,比如固定线程池里的任务相互 Future.get(),导致所有工作线程都在等待尚未开始执行的任务。这本质上是线程池饥饿,不一定是锁的环路等待。

  2. 死锁和活锁最直观的区别是什么?

    :死锁是线程彼此等待,几乎不再推进;活锁是线程一直在运行、CPU 可能不低,但因为不断礼让、回退、重试而没有有效进展。

  3. 为什么竞态问题常常“本地没事,线上才炸”?

    :因为竞态依赖具体时序,而本地线程调度、机器负载、并发量都和线上差很多。只要时序窗口够窄,就可能在本地十次都过、线上高并发下偶发失败。

  4. Android 排查 ANR 时,为什么要先看 main 线程,再看 Binder / worker?

    :因为 ANR 的用户表象是主线程没有及时响应。先看 main 线程是在等锁、等 Future、等 Binder,还是忙于长任务;再顺着等待链去找是哪个后台线程/线程池/锁把它拖住。

  5. 为什么说线程命名和线程池指标是并发排障的“低成本高收益投入”?

    :因为很多并发问题的第一现场就是线程 dump 和指标快照。线程名能让你立刻看出是哪个业务池出问题,活跃线程数、队列长度、拒绝次数能快速区分是锁、饥饿还是堆积,远比事后猜代码高效。

速记

  • 先分型:卡死看锁/饥饿,CPU 高看活锁,自增错看竞态。
  • 并发排障先拿现场:线程 dump、ANR traces、指标、时序日志。
  • 没显式锁也会“像死锁”,线程池饥饿是高频元凶。
  • 最小复现 > 盲猜大项目。