☰
Android主线程死锁导致ANR:从traces读取到锁排查与防护实战
2026/10/14 5:40:10 网站建设 项目流程

上个月我值班时收到一条线上告警:某页面的“点击无响应”突然翻了几十倍。拉回日志一看,系统在InputDispatcher里直接判了 ANR,主线程栈却只停在某个synchronized块的入口,既没有崩溃堆栈,也没有明显的 CPU 飙高。一开始怀疑是正常卡顿,但重现场景始终复现不出来。后来把/data/anr/下的 traces 完整拆开,才看到两把锁互相咬死的完整链条——主线程死锁导致 ANR,这类问题最难的地方不是解,而是找,因为它在日志里通常伪装成一次普通卡顿。

这篇内容适合所有做 Android 开发的同学,尤其是那些经常跟多线程、线程池、跨进程通信打交道,又苦于线上问题难定位的团队。我会从 ANR 的判定机制讲起,完整还原一次主线程死锁的排查链路,再分享我在修复、防护和后续监控里踩过的坑。通篇是实操向的,没有照本宣科的官方文档洗稿。

1. ANR 的触发规则,比你想象的更严格

很多同学一听到 ANR,第一反应是“主线程干了太多活”。这句话对,但不全对。主线程死锁导致 ANR,和“主线程执行了耗时任务导致 ANR”,在系统层面是两种完全不同的路径,排查手段也完全不同。理解触发规则,是你拿到 traces 之后能看明白的前提。

1.1 系统耳朵一直贴在主线程上:谁在盯着你

Android 系统对 ANR 的判定不是靠“感觉卡了多久”,而是靠几个独立的超时机制在背后计时。最常见的是Input 超时:用户在屏幕上点了一下,InputDispatcher会把事件送到应用进程,如果 5 秒内你的主线程没有完成对上一个事件的处理并消费新事件,系统就会判定input dispatching timed out。这个 5 秒不是从事件到onTouchEvent的时间,而是从InputDispatcher发出事件到收到 finished 信号的时间,中间任何一个环节卡住都算。

除输入之外,系统还给 BroadcastReceiver、Service、ContentProvider 分别设了超时。广播前台 10 秒、后台 60 秒;Service 前台 20 秒、后台 200 秒左右(版本迭代有细微差)。不管是哪种超时,最终的落点都是主线程:因为 Android 要求这些组件的生命周期回调都跑在 main Looper 上,主线程一旦被死锁卡死,任何一个回调都可能是压死骆驼的最后一根稻草。

有一个很反直觉的点:主线程死锁条件下,你看到的 ANR 类型往往是keyDispatchingTimedOut,而不是某个业务模块自己的超时。原因是主线程的 Looper 已经完全不往前推进了,前面积压的 Message 越堆越多,输入事件自然排在最末尾。我在排查过程中见过一种典型情况:明明是一个后台线程池的任务在持锁等待主线程,结果先触发的却是桌面应用切换时的 ANR,因为用户切出去再切回来,应用已经无响应了。

1.2 卡顿和死锁:一个是慢,一个是完全静止

普通卡顿的本质是主线程执行代码太慢,比如在onCreate里做了大 JSON 解析、磁盘 IO、或者调用了一个没有缓存的数据库查询。这种情况下主线程其实还在往里走,只是走得慢,通过Systrace或者SimpleProfiler能直观看到某段函数吃掉了大量时间。

死锁则完全不一样。死锁是主线程停在一个锁的获取动作上,而这个锁正被另一个线程持有着,另一个线程又在等主线程持有着的锁,两条执行流互相等待,谁都不会主动让步。此时主线程的 CPU 占用通常不高,甚至接近于 0,因为线程处于 BLOCKED 状态,不再被调度执行。这是定位时最关键的区分信号之一:如果 ANR 时主线程 CPU 占用很高,多半是算法或 IO 卡顿;如果主线程处于 Blocked 状态且长时间没变化,你就要往锁竞争、死锁方向上想。

实际线上还有一个更模糊的区域:不是完整死锁,而是“长时间锁竞争”。比如主线程被synchronized等了 4.9 秒,锁终于被释放了,然后在下一个锁上又等了 3 秒,最终叠加导致超时。这种问题在 traces 里看起来不像死锁,但分析思路是一样的——你必须找出谁占着锁、占锁线程又在等什么。我在后面章节里展开讲具体怎么读 traces。

2. 一次主线程死锁的完整回放:从业务需求到两把锁咬死

理论讲完,来一个我从真实案例中脱敏重组的场景。它非常典型,因为涉及的东西几乎每个 App 都有:一个全局单例容器、一套数据库缓存、一条从网络 SDK 回调回来的线程。

2.1 业务背景与最初的代码

假设我们有一个AppConfig单例,负责启动时读取本地配置、初始化网络模块,并且提供一个缓存接口供主线程和后台线程共同调用。代码大概是这个样子的:

object AppConfig { val lockA = Any() // 配置读写锁 val lockB = Any() // 缓存读写锁 @Volatile private var configReady = false fun init() { synchronized(lockA) { // 读取本地配置,耗时 IO,理论应放子线程 configReady = true } } fun getConfig(key: String): String? { synchronized(lockA) { synchronized(lockB) { return getFromCache(key) } } } fun refreshCacheFromNetwork() { // 假设这个函数在后台线程回调里执行 synchronized(lockB) { synchronized(lockA) { // 把网络返回的数据写进缓存 } } } }

问题眼尖一点就能看出来:getConfig先拿lockA再拿lockB,而refreshCacheFromNetwork先拿lockB再拿lockA。两个线程一旦并发走到不同入口,就会形成经典的 ABBA 死锁。代码里每一行单独看都是对的,加上注释还显得条理清晰。

实际线上触发路径是:用户打开页面,主线程调用getConfig,拿到了lockA,正在尝试获取lockB;同一时刻后台网络回调正好完成,在子线程里执行refreshCacheFromNetwork,拿到了lockB,正准备回头拿lockA。两把锁就这么咬死了。

2.2 死锁的四个必要条件:代码里全部踩中

计算机系统课里讲死锁需要四个必要条件:互斥、持有并等待、不可剥夺、循环等待。这套理论放到 Android 业务代码里一点都不过时,对照这个案例:

  • 互斥:lockA和lockB都是synchronized监视器锁,同一时刻只能有一个线程持有。
  • 持有并等待:主线程握着lockA,没有释放,就等着lockB;后台线程握着lockB,也不释放,等着lockA。
  • 不可剥夺:Java 的synchronized显然不支持强行从别的线程手里抢锁。
  • 循环等待:主线程 → 等lockB→ 后台线程 → 等lockA→ 回到主线程,形成一个闭环。

四个条件同时成立,死锁必然发生。而且这个案例里的锁顺序不是靠文档约定的,是不同开发者在不同需求阶段分别写出来的。A 同学写了getConfig,B 同学写refreshCacheFromNetwork,两人没有互相 review 锁的获取顺序,线上就埋雷了。

提示:排查这类问题时,dump出来的线程栈如果出现waiting to lock,一定要顺着lock owner的 tid 找下去,直到所有等待关系形成一个环,才能断定确实死锁。中间有任何一个环节是运行状态而不是等待状态,都不能叫死锁。

2.3 为什么“加日志”会让定位变得更难

我在这个问题的排查初期,犯过一个几乎所有 Android 开发者都会犯的错:给关键流程加了大量Log.d,想通过日志时间戳看卡在哪里。加完以后,问题反而更难复现了。

原因是多线程并发问题对时序极其敏感。Log.d本身有磁盘 IO 和字符串拼接的开销,会改变线程调度的相对快慢。原来主线程可能 10 次里 5 次先进getConfig,加了日志以后,后台线程反而更多地先进入refreshCacheFromNetwork,死锁的触发窗口被整体打乱了。更糟的是,日志系统如果也用锁做了多线程保护,等于又多了一把全局锁,改变了整个竞争格局。

所以当你怀疑死锁型 ANR 时,第一选择不是加日志复现,而是直接从线上拿 traces。traces 是系统在 ANR 发生时自动抓取的线程快照,所有线程的栈和锁关系都在里面,不需要你提前埋点。这也是为什么我在下一章要专门讲怎么把 traces 读透。

3. 从 /data/anr 拿到的主线程栈,我是这样读完一整份证据

很多开发者在 ANR 排查时只翻一行主线程栈,看到卡在某个方法就以为是那个方法的锅,结果修复上线问题依旧。因为死锁的主线程栈,只是整张拼图的一块,你必须把其他线程栈一起读出来才能还原全貌。

3.1 获取 traces 的正确姿势:adb bugreport 和 DropBox

线上问题的 traces 获取方式要分两种情况。如果你的 App 还在开发调试阶段,设备连着电脑,直接执行:

# 触发 ANR 后,从 Debug 设备拉取系统生成的 trace 文件 adb shell "dumpsys dropbox --print data_app_anr" > anr_dropbox.txt # 或者直接尝试拉取系统目录 adb pull /data/anr/traces.txt ./traces.txt

需要注意,/data/anr/目录在多数非 root 设备上没有权限读取。开发者常用的办法是通过adb bugreport,它会把/data/anr/下的信息整体打包进 zip 包:

adb bugreport bugreport.zip # 解压后找 bugreport-日期-时间.zip 或者直接看目录下的 anr 相关文件

如果是线上用户的设备,你拉不到实机,只能依赖各家的 Crash/APM 系统。很多平台会把 ANR 时候的main线程栈自动采集上传,但只会采集主线程。换句话说,死锁需要的“其他线程栈”往往没传上来。这时候我的建议是二选一:要么在 APM 系统里配置 ANR 发生时同时上传全量线程 dump,要么在代码里做一次“自杀式”兜底,比如自定义一个UncaughtExceptionHandler之外的主线程阻塞检测器,检测到主线程 Looper 长时间没有新消息时主动Thread.dumpStack()并上报。

3.2 主线程栈的语法:Blocked、waiting to lock、held by

拿到完整 traces 以后,第一份必看的当然是 main 线程。典型的主线程死锁栈长这样:

"main" prio=5 tid=1 Blocked | group="main" sCount=1 dsCount=0 flags=1 obj=0x... self=0x... | sysTid=12345 nice=-10 cgrp=default sched=0/0 cgrpApps=1000 | state=S schedstat=( 0 0 0 ) utm=0 stm=0 core=4 HZ=100 | stack=... java.lang.Thread.State: BLOCKED at com.example.config.DiskLruCache.get(AppConfig.kt:123) - waiting to lock <0x0c41b2e8> (a java.lang.Object) held by thread 23 at com.example.config.AppConfig.getCache(AppConfig.kt:100) at com.example.config.AppConfig.getConfig(AppConfig.kt:88) at com.example.ui.MainActivity.onResume(MainActivity.kt:45)

这里面最核心的一行是waiting to lock <0x...> held by thread 23。它告诉你三件事:对象地址、锁对象类型、当前持有者的线程编号 tid=23。千万不要只看这一行就开始改代码,你得顺着 tid=23 继续找第 23 号线程在干什么。

3.3 顺着 lock owner 反向推演,直到形成闭环

继续往上翻,找到 tid=23 的线程栈:

"OkHttp-Dispatcher" prio=5 tid=23 Waiting java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(Native Method) - parking to wait for <0x0c41b3f8> (a java.util.concurrent.locks.ReentrantLock) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:...) at com.example.config.AppConfig.refreshCacheFromNetwork(AppConfig.kt:130) - waiting to lock <0x0c41b3f8> (a java.util.concurrent.locks.ReentrantLock) held by thread 1

到这里闭环出现了:主线程 id=1 在等 tid=23 持有的<0x...b2e8>,线程 23 在等主线程持有的<0x...b3f8>。两个线程互相等,系统不可能自动打破这个僵局。这就是一份完整的死锁证据链。

我当时排查时还有一个细节值得提醒:看到held by thread 1时,不要以为“主线程就是罪魁祸首”,主线程这是“受害者”。真正要改的是谁定义了锁的获取顺序,以及为什么会在持锁的情况下发起跨线程调用。我在后续复盘时把这套推演画成了一幅简单的持有-等待表:

线程已持有锁正在等待锁等待的锁被谁持有
main (tid=1)lockBlockA线程 23
OkHttp-Dispatcher (tid=23)lockAlockB主线程

表格一列出来,死锁结构一目了然。

4. 修复方案:先消循环等待,再解决锁外等待

找到死锁不是结束,怎么修才是真正的考验。我在这个问题上做过三轮迭代,前两次都不彻底,第三次才真正稳定。这一节把三个阶段都写出来,重点讲清楚每个方案的取舍逻辑。

4.1 最小改动:统一两把锁的获取顺序

最直接的思路是打破循环等待条件。既然死锁形成于两种相反的获取顺序,那就规定全项目内所有线程必须按照lockA → lockB的顺序拿锁,禁止反向。改完后,即使两个线程同时竞争,也只会出现一个线程在等另一个线程的早期锁,而不会形成环形。

代码上可以这样约束:

object LockOrder { // 全局统一顺序:先 lockA,再 lockB,禁止逆序 val lockA = Any() val lockB = Any() }

但这个方法有个致命弱点:它依赖人的自觉和生产规范,运行期没有任何强制手段。新同学接手后可能在refreshCacheFromNetwork里想当然地先锁lockB,死锁又回来了。所以我把这个方案定为“短期止血”,长期还得配合静态检查和运行时监控。

4.2 关键调整:业务逻辑拆分,持锁时不发起跨线程调用

在我这个案例中,真正的病灶不是锁顺序本身,而是refreshCacheFromNetwork这个函数在持锁的情况下跑了一段完整的网络回调更新流程。网络回调过来的线程,本质上是 SDK 内部线程池的线程,它执行的是我们自己的代码,但生命周期完全不可控。

正确的做法是把锁的粒度细化。网络数据到达后,先做数据转换,生成一份不可变的CacheEntry,再对缓存加锁写入。写入动作要短平快,绝对不在锁里执行网络 IO、数据库事务、或者调用其他可能反向触发主线程代码的接口。修改后的伪代码如下:

fun refreshCacheFromNetwork(entry: CacheEntry) { // 入口线程可能来自任意线程池 synchronized(lockB) { diskCache.put(entry.key, entry.value) } } fun getConfig(key: String): String? { val entry = synchronized(lockB) { diskCache.get(key) } return entry?.value }

这里有个容易被忽视的意图:所有函数在锁内部干的事情,必须在微秒到毫秒量级内完成,任何可能阻塞的调用都要出现在锁外面。把大的同步块拆成小的临界区,死锁发生的概率会指数级下降,因为线程持锁等待的时间窗口被极限压缩了。

4.3 引入带超时的锁,为最坏情况留后手

Java 原生的synchronized一旦进入阻塞,只有拿到锁或者被打断(实际上不可打断)才能出来。对于核心链路,我后来更推荐ReentrantLock.tryLock(timeout):

val configLock = ReentrantLock() fun getConfigWithGuard(key: String): String? { if (!configLock.tryLock(2, TimeUnit.SECONDS)) { // 上报超时错误,同时决定兜底策略(返回旧缓存或提示稍后重试) return cachedFallback(key) } try { // 临界区代码 } finally { configLock.unlock() } }

tryLock的好处是即使锁顺序又被人写反了,至少主线程最多只等 2 秒,不会直接引发 ANR。最坏的情况是这次请求没拿到最新数据,但应用不会整体失去响应。线上系统永远需要“最后一层保险”,不能只依赖编码纪律。

5. 死锁防护的长期手段:开发期、测试期、线上三条线

修好一次死锁不代表以后不会再犯。真正稳妥的团队,会在开发期就用工具把死锁扼杀在 MR 阶段,在测试期用自动化手段加压复现,在线上靠监控自动识别疑似死锁。这一节我把三条线的落地方式拆开讲。

5.1 开发期:给锁排序,用静态检查守住底线

我经历过这个案例后,做了两件事。第一件是写了一份《多线程锁使用规范》放在团队 Wiki 里,里面明确列出:所有涉及两把及以上锁的代码,必须在类注释里画出锁获取顺序图;顺序一旦定了就不能改,任何人要新增锁路径必须同步更新文档。第二件事是在 CI 里加了一个自定义的Detekt或者 Android Lint 规则,检查所有synchronized(lockA)嵌套synchronized(lockB)或反向嵌套的代码,发现逆序直接构建失败。

Lint 规则看起来简单,但非常有效:

// 伪代码:检测嵌套 synchronized 的锁顺序 node.accept(object : DetectorVisitor() { override fun visitSynchronized(node: Node) { val innerLock = findInnerSynchronizedLock(node) if (lockOrderViolation(node.lock, innerLock)) { report("锁顺序违反全局约定") } } })

注意静态规则只能抓“代码结构上的逆序”,抓不到运行时通过变量传递的动态锁获取。但对于绝大多数业务代码,锁对象基本都是固定的成员变量或全局单例,静态检查覆盖面已经足够大。

5.2 测试期:用压力测试和定时线程 dump 让死锁现形

死锁是并发问题,单次执行很难复现。我在回归阶段会把核心页面的自动化用例改成多线程并发场景:比如同时启动三个线程池,一个反复getConfig,一个反复刷新缓存,一个做页面跳转。配合低端测试机(性能越差越容易暴露竞争窗口)跑一轮 Monkey 和自定义并发脚本。每次跑完脚本后,如果系统没有直接 ANR,我会主动触发一次线程 dump,检查是否存在waiting to lock循环。

触发 dump 的方式很简单:

# 找到应用进程 pid adb shell "ps -A | grep com.example.app" # 发送 SIGQUIT 信号,让 ART 虚拟机输出线程栈 adb shell "kill -3 <pid>" # 再拉取 traces adb pull /data/anr/traces.txt ./stress_traces.txt

我在压力测试脚本里每次跑完都人工看一眼 dump,重点找Blocked状态的线程和waiting to lock的循环依赖。连续跑 10 轮没有发现死锁,才算这个场景通过。

5.3 线上:自建主线程健康看护,不等系统判 ANR

系统 ANR 监控只能告诉你“已经 ANR 了”,没法告诉你“它可能正在走向死锁”。我在线上维护过一个简单的 watchdog:往主线程 Handler 发一条空消息,要求它 3 秒内移除一个标志位。如果标志位没被移除,说明主线程已经超过 3 秒没执行 Looper 消息了,此时立刻DumpThreads(全量线程 dump)并上报。

class MainThreadWatchdog(private val uiHandler: Handler) { private val checkRunnable = Runnable { val traceInfo = collectAllThreadStackTraces() // 上报 traces 到 APM 后台 } fun scheduleCheck() { uiHandler.post { // 正常情况这个标志位会被移除 // 但如果主线程卡死,这条消息根本不会执行 } // 延时后检查标志位 } }

这条防线的好处是不需要用户真正触发 5 秒输入超时,3 秒的阻塞就能提前暴露,而且上报的是“案发第一现场”的全量线程栈,排查价值极高。我后来就是靠这个 watchdog 提前拦截了一次锁顺序被改坏导致的隐患。

注意:watchdog 设计时,检查线程必须不能用和业务线程相同的锁,否则你自己的检查逻辑也会成为死锁的一部分。简单做法是用一个独立的单线程 Executor,专门执行检查任务。

5.4 两个值得警惕的“经验之谈”

在这个问题的评论区,我经常看到两种听起来很有道理但实际会误导人的说法:

第一种是“主线程不要拿锁,问题就全解决了”。这话说得太满。主线程完全不拿锁在这个生态里不现实,任何View相关操作、SharedPreferences的某些实现、系统服务的 Binder 调用,内部都在拿锁。更合理的要求是:主线程拿锁的时间必须短,拿锁以后绝不允许再发起会回调到任意线程的阻塞请求。

第二种是“synchronized不好,全换成Lock就完了”。换锁解决不了顺序问题,ReentrantLock一样会死锁,只是让你多了tryLock这个逃生通道。锁的公平性、可重入性、性能开销都不一样,换不换锁取决于具体场景,而不是因为听到“ synchronized 性能差”就无脑迁移。

6. 一次 ANR 复盘沉淀出的几条实用建议

最后聊一点不太容易量化、但对排查效率影响极大的经验。这类主线程死锁问题,最大的成本不在代码修改,而在“确认这是一次死锁”的过程。如果你也遇到了,我的建议是先把 traces 里所有tid梳理一遍,让每条线程的锁关系形成一个有向图模型。锁之间的关系一旦闭环,不要犹豫,直接按死锁来排查,别先去优化某个方法耗时。

我在实际维护过程中还发现一个细节:很多 ANR 的触发场景不是首次进入页面,而是页面经历onPause、onResume之后发生了后台数据更新,再回来时主线程和后台线程突然撞车。所以进入后台、切回前台、网络回调密集这三个时间点,是设计并发回归测试时最容易踩中死锁的场景。你可以在发布前专门做一轮“前台后台快速切换 + 网络实时刷新”的交叉测试,比单纯跑接口自动化更能发现隐藏问题。

另一个实用的经验是:团队里要有一个稳定的“标准 ANR 排查手册”,把adb bugreport怎么用、traces 文件里waiting to lock怎么找、dropbox 日志和 Looper 统计怎么对照写成可复用的 checklist。我见过太多组员在排查 ANR 时从上往下从头翻到尾,翻完后不知道下一步该做什么。这份手册不需要多复杂,它真正解决的是在紧张维护的时间里,大家能稳定地按同一套步骤把问题定位到锁级别。

多线程代码写多了以后,我最大的体会是:死锁不是靠小心就能完全避免的,因为代码总在演进,锁的拓扑关系总有一天会超出原设计者的记忆范围。可靠的防线从来不是某一个人的聪明,而是“统一锁顺序 + 短临界区 + 运行时兜底 + 自动化检查”这套四层组合拳。这套组合拳打完,再遇到主线程死锁导致 ANR 的告警,你至少能从容地打开 traces,平静地顺着held by thread一行一行读下去,直到找到那个真正的环。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询