跳到主要内容
约 7 分钟3 张卡

并发活跃性

并发活跃性关注任务能否持续取得进展,最终完成工作。本文先区分死锁、活锁和饥饿,再说明如何用进度指标和线程栈诊断。最后讨论线程池中的资源等待环,以及 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 依赖、连接池容量和跨进程调用仍可形成等待环。因此,还要根据任务在等什么、资源由谁占用,补全业务等待图。

11. 参考资料​