Appearance
死锁 / 活锁 / 竞态定位
在总览中的位置:Java 并发模型总览 §6
相关主题:ExecutorService / 线程池 · JMM:happens-before / 安全发布
一句话定位
并发线上问题不要先猜语法点,先分型:卡死先看死锁与线程池饥饿,CPU 高先看活锁 / 自旋重试,偶发脏数据先看竞态与安全发布边界。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
volatile 非原子自增导致的竞态丢更新 | volatile-vs-atomic-vs-lock | VolatileVsAtomicVsLock.java |
get-then-put 复合操作竞态、原子 API 修法 | map-concurrency-compare | MapConcurrencyCompare.java |
| 线程池饱和、拒绝策略、调用线程回退执行 | thread-pool-basics | ThreadPoolBasics.java |
| 队列策略导致的排队 / 扩线程 / 拒绝行为 | queue-strategy-compare | QueueStrategyCompare.java |
为什么需要
并发 bug 最大的工程代价,不是“难懂”,而是:
- 本地不稳定复现:你测十次都没出错,线上一天出现一次就足够拖垮业务
- 症状和根因错位:ANR 看起来像 UI 问题,根因可能是后台锁竞争;接口超时看起来像下游慢,根因可能是线程池饥饿
- 排查窗口很短:很多问题要靠线程 dump、ANR traces、指标快照、日志时序一次性抓住
如果不先分型,最容易出现三种低效行为:
- 看见“卡住”就开始大面积加日志,却不知道该盯锁、队列还是中断
- 看见“CPU 高”就怀疑死循环,却没发现是 CAS 活锁 / 重试风暴
- 看见“数据偶发不一致”就怪数据库,实际上是本地共享状态竞态
底层机制
1. 四类问题先分清
| 问题类型 | 典型现象 | 本质 |
|---|---|---|
| 死锁(Deadlock) | 线程彼此等待,系统卡死不前 | 环状资源等待 |
| 活锁(Livelock) | 线程都在运行,CPU 不低,但没有有效推进 | 一直礼让 / 重试 / 回退 |
| 竞态(Race Condition) | 结果依赖执行时序,偶发错误 | 缺少同步边界或原子复合操作 |
| 饥饿(Starvation) | 任务长期拿不到线程 / 锁 / 资源 | 资源分配不公平或被长期占满 |
2. 死锁最常见的两个来源
来源 A:多把锁获取顺序不一致
java
synchronized (lockA) {
synchronized (lockB) {
doWork();
}
}
synchronized (lockB) {
synchronized (lockA) {
doOtherWork();
}
}一旦两个线程分别先拿到 lockA 和 lockB,就会互相等待。
来源 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 / profiler | CPU 热点、锁竞争、等待分布 | 看活锁、自旋、热点方法 |
| ANR traces(Android) | 主线程被谁阻塞 | 先看 main 线程,再看 Binder / worker |
| 线程池指标 | activeCount、队列长度、拒绝次数 | 判断饥饿、堆积、扩线程失控 |
| 时序日志 | 提交、开始、结束、取消、超时 | 没日志就很难还原现场 |
7. 最小复现往往比肉眼猜代码更快
当线上问题反复出现时,最有效的做法常常不是继续读大项目,而是:
- 把相关共享状态抽出来
- 把线程数缩到最小
- 加
CountDownLatch/sleep/ 屏障控制交错时机 - 先在 50 行 demo 里稳定复现
这也是仓库里这些 runtime-concurrency labs 的真正价值:不是背结论,而是能把问题缩小到能看懂的规模。
Android / Flutter / Web / Backend 对照
| 维度 | 并发问题类型 | Android | Flutter | Web | Backend |
|---|---|---|---|---|---|
| 卡死 | 死锁 / 饥饿 / 阻塞等待 | ANR、主线程等锁、Binder 线程耗尽 | 主 isolate 被重任务拖住 | event loop 被长任务卡住 | 请求线程池饥饿、锁等待 |
| CPU 高 | 活锁 / 自旋 / 重试风暴 | 后台重试、过多线程争抢 | isolate 内忙循环 | 主线程 busy loop | CAS 热点、轮询、自旋 |
| 脏数据 | 竞态 / 发布问题 | ViewModel / repo / 缓存状态错乱 | 消息模型下少见共享竞态 | 主要是异步顺序竞态 | 缓存、状态机、并发 map 高频出现 |
| 排障入口 | dump、指标、日志 | ANR traces + Logcat + profiler | DevTools timeline | Performance panel | jstack + 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 后重复构建
对应 Lab:map-concurrency-compare
多个线程同时发现缓存 miss,如果还在写 if (get == null) put(),就会:
- 重复创建昂贵对象
- 甚至出现中间态暴露或覆盖
修法:
computeIfAbsent- 必要时外加更高层的去重 / 单飞(single flight)机制
4. 线程池看似没死锁,实际已饥饿
对应 Lab:thread-pool-basics · queue-strategy-compare
典型现象:
- 请求越来越慢
- 线程池活跃线程一直满
- 队列越来越长或拒绝越来越多
- 代码里没有显式
synchronized,却像“卡死”一样没推进
这时先别只盯锁,要看:
- 线程池是否过小
- 任务是否在池内相互等待
- 队列策略是否导致过度排队
常见坑
| 坑 | 现象 | 修法 |
|---|---|---|
| 多把锁顺序不统一 | 偶发死锁 | 统一锁顺序,必要时 tryLock(timeout) |
| 线程池任务互相等待 | 看起来像死锁,实际是饥饿 | 拆池、避免池内互等、给等待加超时 |
| 无上限重试 | CPU 高、日志刷屏、没结果 | 退避、限次、熔断 |
用 volatile 误当原子性 | 计数偏小、状态错乱 | 改用 Atomic / 锁 |
| 没有线程命名 | dump 无法快速看出来源 | 自定义 ThreadFactory |
| 只靠肉眼读代码,不抓现场 | 排障反复猜 | 先拿线程 dump / ANR traces / 指标快照 |
| 用 sleep“修复”竞态 | 本地像好了,线上继续炸 | 建真实同步边界或版本校验 |
与相近概念对比
| 概念 | 线程在干什么 | 外部现象 | 常见修法 |
|---|---|---|---|
| 死锁 | 相互等待资源 | 卡住不动 | 统一锁顺序、超时、拆分资源 |
| 活锁 | 一直运行但反复冲突 | CPU 高、无进展 | 退避、限次、改竞争模型 |
| 竞态 | 执行顺序决定结果 | 偶发脏数据、重复执行 | 原子 API、锁、不可变状态、版本校验 |
| 饥饿 | 长期拿不到线程/锁 | 某些任务一直等不到资源 | 拆池、限流、调公平性/优先级 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| volatile-vs-atomic-vs-lock | volatile-vs-atomic-vs-lock | VolatileVsAtomicVsLock.java |
| map-concurrency-compare | map-concurrency-compare | MapConcurrencyCompare.java |
| thread-pool-basics | thread-pool-basics | ThreadPoolBasics.java |
| queue-strategy-compare | queue-strategy-compare | QueueStrategyCompare.java |
当前仍缺:死锁最小复现实验、活锁/退避对比实验、线程 dump / ANR 定位专属练习。
复习检查题
为什么“线程都卡住了”不一定是传统死锁?
答:因为没有显式锁也可能出现类死锁现象,比如固定线程池里的任务相互
Future.get(),导致所有工作线程都在等待尚未开始执行的任务。这本质上是线程池饥饿,不一定是锁的环路等待。死锁和活锁最直观的区别是什么?
答:死锁是线程彼此等待,几乎不再推进;活锁是线程一直在运行、CPU 可能不低,但因为不断礼让、回退、重试而没有有效进展。
为什么竞态问题常常“本地没事,线上才炸”?
答:因为竞态依赖具体时序,而本地线程调度、机器负载、并发量都和线上差很多。只要时序窗口够窄,就可能在本地十次都过、线上高并发下偶发失败。
Android 排查 ANR 时,为什么要先看 main 线程,再看 Binder / worker?
答:因为 ANR 的用户表象是主线程没有及时响应。先看 main 线程是在等锁、等 Future、等 Binder,还是忙于长任务;再顺着等待链去找是哪个后台线程/线程池/锁把它拖住。
为什么说线程命名和线程池指标是并发排障的“低成本高收益投入”?
答:因为很多并发问题的第一现场就是线程 dump 和指标快照。线程名能让你立刻看出是哪个业务池出问题,活跃线程数、队列长度、拒绝次数能快速区分是锁、饥饿还是堆积,远比事后猜代码高效。
速记
- 先分型:卡死看锁/饥饿,CPU 高看活锁,自增错看竞态。
- 并发排障先拿现场:线程 dump、ANR traces、指标、时序日志。
- 没显式锁也会“像死锁”,线程池饥饿是高频元凶。
- 最小复现 > 盲猜大项目。