并发活跃性
并发活跃性关注任务能否持续取得进展,最终完成工作。本文先区分死锁、活锁和饥饿,再说明如何用进度指标和线程栈诊断。最后讨论线程池中的资源等待环,以及 JVM 死锁检测的覆盖范围。
1. 面试核心
先记住这几条,再读正文- 死锁。 死锁中的多个参与者各自持有资源并等待对方释放,因循环等待而无法继续执行。
- 统一加锁顺序。 所有线程遵守统一的加锁顺序,可以破坏锁之间的循环等待。
- 活锁。 活锁中的线程持续改变状态,却因反复让步和冲突而无法完成任务。
- 饥饿。 饥饿表现为整体有进展,个别任务长期得不到资源。卡 · 死锁
- 线程池等待环。 父任务占满线程池的工作线程,再同步等待同池排队的子任务,会形成资源等待环。卡 · 线程池的资源等待环
2. 死锁
死锁要从“谁占有资源,谁在等它”来理解。当多个线程的等待关系连成环,每个线程都在等其他线程先释放资源,就会停滞。
void transfer(Account from, Account to, Money amount) {
synchronized (from) {
synchronized (to) {
move(from, to, amount);
}
}
}
示例先锁住转出账户,再锁住转入账户。甲从账户 1 向账户 2 转账,乙按相反方向转账。若双方各持有一个账户锁,就会一直等待对方释放另一把锁。
检查这类死锁时,需要同时满足以下四个条件:
- 资源由线程互斥占用。
- 线程持有资源时继续等待其他资源。
- 已占资源无法被外部强制剥夺。
- 多个参与者形成循环等待。
破坏其中一个条件,就可以避免这类死锁。业务中常用的办法是统一加锁顺序,让所有线程都先获取排序靠前的账户锁:
Account first = smallerId(from, to);
Account second = first == from ? to : from;
这段代码确定了加锁顺序,后续应依次锁住 first 和 second。排序键要使用唯一、稳定的账户 ID,同一账户也需单独处理。若使用哈希等存在碰撞的排序键,可以引入额外的 tie lock。它是在排序键相同时使用的公共锁,用来串行处理这部分操作。
3. 超时获取锁
获取第二把锁时设置超时,可以给循环等待留出退出路径。ReentrantLock.tryLock(timeout, unit) 在指定时间内获取失败后返回 false。调用方应释放已经持有的第一把锁,再决定是否重试。
如果所有线程立即重试,就容易再次同时取得第一把锁、等待第二把锁。因此,还需要总截止时间、退避和重试上限:
- 总截止时间限制多次尝试合计消耗的时间。
- 退避是在重试前等待一段时间,用来错开竞争者。
- 达到重试上限或截止时间后,返回明确失败,让调用方继续处理。
中断传播和退出时的清理见 任务取消。
4. 活锁
活锁中的线程仍在执行,但执行的工作反复作废。例如,两个线程发现冲突后同时撤销,又同时重试。每一轮都再次冲突,就会一直忙着让步,却没有任务完成。
忙于重试时,线程可表现为 RUNNABLE,CPU 和重试次数都很高。若退避中包含睡眠或阻塞,CPU 也会降低。因此,判断活锁需要看成功完成量,单看线程是否在运行无法确定业务进展。
降低反复冲突的概率,可以从以下竞争行为入手:
- 使用随机退避,让线程在不同时间重试。
- 使用指数退避,在连续失败后逐步拉长等待间隔。
- 给一方确定的优先级,减少双方同时让步。
- 缩短一次尝试占用资源的时间,减少发生冲突的机会。
这些办法还要配合截止时间和重试上限。达到上限时应返回失败,避免无效尝试一直占用资源。
5. 饥饿
饥饿表现为系统整体仍在完成工作,但部分任务长期拿不到所需资源。它们等待的可以是锁、许可、CPU,也可以是线程池中的执行机会。
排查时,可以检查以下饥饿来源:
- 非公平竞争中,新到的线程持续插队,老线程一直无法获取资源。
- 高优先级任务不断占满同一个队列,低优先级任务长期排队。
- 读锁持续被新读者取得,写者难以进入。
- 调度器总是优先执行短任务,大任务被反复推迟。
公平锁可以改善对应锁的获取顺序,但增加的排队约束也会牺牲吞吐量。操作系统如何分配 CPU,仍由线程调度决定。需要隔离不同业务时,可以拆分队列、设置资源配额,或限制单个租户的占用。
6. 进度指标与诊断
诊断要同时回答两个问题:哪些任务没有完成,它们在等谁。可以把以下进度指标与等待证据放到同一条时间线上:
- 记录成功完成率,以及最早进入但尚未完成的任务已经等待多久。
- 记录重试、超时和取消次数。
- 采集锁等待、队列长度及线程栈。
- 对齐 CPU、上下文切换与下游响应。
死锁表现为稳定的等待环,阻塞等待的线程可处于 BLOCKED 或 WAITING,自身 CPU 消耗很低。活锁则表现为反复尝试却没有有效完成。若整体仍有吞吐量,只有部分任务的等待时间不断增长,就应检查饥饿。
7. 线程池的资源等待环
线程池的工作线程也是一种有限资源,任务之间的依赖可以让它耗尽。假设固定线程池只有两个 Worker,也就是两个执行任务的工作线程。两个父任务各占一个 Worker,向同一个池提交子任务,再同步等待子任务的 Future。
这时,子任务需要空闲 Worker 才能执行,父任务却要等子任务完成才会释放 Worker。双方形成资源等待环,即使线程都处于 WAITING,整个池也会停滞。
可以改为组合异步结果,等子任务完成后再安排后续逻辑。这样父任务就能先释放 Worker。也可以让有依赖的任务使用不同执行器,并约束各自容量。单纯扩池后,还需验证最大嵌套并发,避免在更大负载下再次耗尽线程。
8. 死锁检测的覆盖范围
JVM 的死锁检测能帮助定位锁环,完整的业务等待关系还需要人工补充。对平台线程,可用 jcmd <pid> Thread.print -l 查看锁信息。连续采集线程转储后,核对持有者和等待者是否一直相互等待。
ThreadMXBean.findDeadlockedThreads() 检测平台线程中的锁环。它覆盖 monitor,以及支持的 ownable synchronizer。后者指能记录独占持有线程的同步器,例如 ReentrantLock 使用的同步器。
数据库锁、HTTP 调用和 Future 依赖也能与线程等待连成环。连接池的容量限制还会让任务等待可用连接。这些关系需要结合业务调用补全,JVM 无法自动重建整个跨进程依赖图。
Java 21 的 ThreadMXBean 也不覆盖虚拟线程。因此,返回 null 只说明本次没有发现其支持范围内的锁环。
9. 常见问题
9.1 使用 ConcurrentHashMap 就能避免死锁吗?
仍需检查业务的等待关系。ConcurrentHashMap 保证其定义的操作线程安全,业务回调却可能获取外部锁或等待 Future。多个组件按相反顺序访问时,就会形成等待环。
9.2 随机退避能保证任务最终完成吗?
随机退避只降低反复冲突的概率,任务能否完成还取决于调度和竞争协议。因此,它无法单独证明算法一定取得进展。实现时仍需限制总时间和重试次数,并为耗尽预算的任务提供失败路径。
10 · 面试题卡片
10.1 死锁、活锁和饥饿怎样区分
出现公司:拼多多、美团
考察重点
- 能区分循环等待、反复冲突和部分任务长期等待。
- 能结合业务完成量解释线程状态,避免仅凭 CPU 或状态名称判断故障。
- 能说明统一锁顺序、退避和资源配额分别解决什么问题。
参考回答
死锁中,参与者相互等待资源,谁都无法继续。
活锁中,线程仍在改变状态,却因反复冲突而无法完成任务。饥饿则是整体仍有进展,但部分任务长期得不到资源。
处理死锁时,可以统一锁顺序,或用超时获取和释放资源来打破等待环。活锁可通过退避或确定优先级减少冲突,再用重试上限结束失败尝试。饥饿则需要检查获取顺序、资源配额和任务隔离。
诊断时要把线程栈与完成率、重试次数和等待时间对齐。线程状态与 CPU 可以辅助判断,最终仍需确认具体任务是否取得进展。
10.2 线程都在等待 Future,为什么线程池仍会停滞?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:能说明父子任务怎样争用有限的工作线程,以及怎样消除这种循环依赖。
相关内容:线程池的资源等待环。
参考回答
父任务占满工作线程后,再等待同池排队的子任务,就会形成资源等待环。
子任务需要父任务释放线程才能执行,父任务却等待子任务先完成,所以双方无法推进。
可以组合异步结果,让父任务先结束,再在子任务完成后继续执行后续逻辑。也可以隔离父子任务的执行器,并按最大嵌套并发约束容量。
10.3 findDeadlockedThreads 返回 null 能排除死锁吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:能说明 JVM 自动检测哪些锁环,以及哪些任务和资源依赖需要人工补充。
相关内容:死锁检测的覆盖范围。
参考回答
不能排除。
返回 null 只表示本次未发现接口支持的锁环。该接口检测平台线程中的 monitor 和受支持的独占同步器,Java 21 中也不覆盖虚拟线程。
Future 依赖、连接池容量和跨进程调用仍可形成等待环。因此,还要根据任务在等什么、资源由谁占用,补全业务等待图。