Android ANR 深度治理:从主线程卡顿根因到 ANR Trace 全链路分析方法论
线上收到一条 ANR 上报,打开 traces.txt 一看:主线程卡在 nativePollOnce,看起来是空闲等待——这其实是最容易误判的情况。ANR 的归因远不是”主线程在干什么”这么简单,它涉及信号触发时机、消息队列积压、Binder 调用链路和锁竞争的交叉分析。
本文从 ANR 的信号机制出发,串联三类主要根因,最后给出一套 traces.txt + Perfetto 对齐的分析流程。
ANR 的触发机制:不是超时就报,而是信号驱动
很多人以为 ANR 是”主线程 5 秒没响应就弹窗”,这个理解不够准确。
Android 用的是**看门狗(Watchdog)+ 信号(Signal)**机制。以输入事件为例:InputDispatcher 在派发触摸事件时启动一个计时器,超过 5 秒没有收到应用的 finished 回调,就向目标进程发送 SIGQUIT(signal 3)。收到信号后,ART 虚拟机的 signal handler 负责 dump 当前所有线程的堆栈,写入 /data/anr/traces.txt。
不同场景的超时阈值:
| ANR 类型 | 超时时长 |
|---|---|
| 输入事件(触摸/按键) | 5s |
| 前台 Service | 20s |
| 后台 Service | 200s |
| BroadcastReceiver(前台) | 10s |
| BroadcastReceiver(后台) | 60s |
这里有个关键细节:SIGQUIT 发出的时刻,与主线程真正开始卡顿的时刻之间存在时间差。traces.txt 里的堆栈是信号触发那一刻的快照,不是卡顿起点。误判的根源正在于此。
traces.txt 解读:三段式定位
拿到 traces.txt,先找到目标进程的 main 线程,看三个维度。
线程状态
"main" prio=5 tid=1 Sleeping
| group="main" sched=0/0 handle=0x...
| sysTid=12345 nice=-10 cgrp=default
| held mutexes=
Sleeping 表示线程在等待,Blocked 表示等待锁,Native 表示在执行 native 代码。held mutexes 非空时,说明主线程持有锁——这时要去找谁在等这把锁。
Java 调用栈
at android.os.MessageQueue.nativePollOnce(Native method)
at android.os.MessageQueue.next(MessageQueue.java:335)
at android.os.Looper.loop(Looper.java:183)
看到 nativePollOnce 不要急着下结论说”主线程空闲”。这只代表信号触发时主线程在 epoll 等待——真正的问题可能是消息处理完了在等下一条,也可能是某条消息执行太久已经结束,刚好被抓到空闲状态。
Binder 线程关联
如果 Java 栈里有 BinderProxy.transact,就要去找同进程或对端进程的 Binder 线程池状态:
"Binder:12345_3" prio=5 tid=15 Blocked
at com.example.SomeService.heavyQuery(...)
- waiting to lock <0x0a1b2c3d> (SomeService.class)
held by thread 8 ("AsyncTask #1")
这种情况下,调用链是:主线程 → Binder 调用 → 对端 Binder 线程等锁,实际根因在另一个线程持有锁。
三类根因分析
根因一:MessageQueue 积压
这类问题通常出现在消息处理耗时不均匀的场景。单条消息执行 2 秒不会触发 ANR,但如果队列里堆积了 50 条消息,每条执行 100ms,累计就超了。
定位时,在 Looper.loop() 里加埋点不够,需要用 Looper.getMainLooper().setMessageLogging() 监控每条消息的执行时长:
Looper.getMainLooper().setMessageLogging { log ->
if (log.startsWith(">>>>> Dispatching")) {
// 记录开始时间
startTime = SystemClock.uptimeMillis()
} else if (log.startsWith("<<<<< Finished")) {
val cost = SystemClock.uptimeMillis() - startTime
if (cost > 100) { // 超过 100ms 上报
reportSlowMessage(log, cost)
}
}
}
踩过的一个坑是:这个 log 回调在生产环境里会有额外的字符串格式化开销,建议只在 Debug 包或灰度包里开启,不要全量上线。
线上更推荐用 Choreographer 的 FrameCallback 计算帧间距,间接推断主线程是否存在长时间阻塞。
根因二:Binder 调用超时
Binder 同步调用会阻塞主线程直到对端返回。问题来源有两类。
对端进程繁忙:系统服务(AMS、WMS、PackageManager)在高负载下响应变慢,所有 getSystemService 系列调用都可能成为定时炸弹。
// 这个调用看起来无害,实际是同步 Binder
ActivityManager am = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE);
am.getRunningAppProcesses(); // 不要在主线程调用
Binder 线程池耗尽:默认线程池大小是 15 个线程,大量并发 Binder 请求打满线程池后,新的调用会排队等待。traces.txt 里可以看到多个 Binder 线程都处于 Blocked 状态。
诊断命令:
# 查看进程的 Binder 线程数量
cat /proc/<pid>/status | grep Threads
# 从 traces.txt 里统计 "Binder:" 开头的线程数
grep -c "Binder:" traces.txt
根因三:锁竞争
这是最难定位的一类,因为持锁的线程往往看起来在”正常工作”,只是工作时间太长。
traces.txt 里的关键信息是 waiting to lock 和 held by thread,手动构建等待图:
main → 等待锁 A(SharedPreferences$EditorImpl.this)
← 由 thread-12 持有
thread-12 → 等待锁 B(SQLiteDatabase.this)
← 由 thread-8 持有
thread-8 → 正在执行 DB 查询(native 层)
如果图里有环,就是死锁;如果是链状,找链尾那个正在干活的线程,它就是实际根因。
在实际项目中我发现,SharedPreferences 的 apply() 是高频触发锁竞争的场景。apply() 虽然是异步写磁盘,但在 Activity.onStop() 和 Service.onStop() 时,系统会等待所有 apply() 完成——这个等待发生在主线程,是同步的。
// 危险:频繁调用 apply() 在 onStop 时可能阻塞主线程
prefs.edit().putString("key", value).apply()
// 在 onStop 场景下,考虑提前刷新或用 DataStore 替代
Perfetto 对齐:从静态快照到动态时序
traces.txt 是事发那一刻的快照,Perfetto 能给你事发前后的时序全貌。两者结合才能真正还原卡顿现场。
线上 ANR 上报时同步采集 Perfetto trace 是最理想的,但实际操作成本较高。复现场景时可以用以下命令采集:
adb shell perfetto \
-c - --txt \
-o /data/misc/perfetto-traces/trace.pftrace \
<<EOF
buffers: { size_kb: 63488 fill_policy: RING_BUFFER }
data_sources: { config { name: "linux.process_stats" } }
data_sources: { config { name: "linux.ftrace"
ftrace_config {
ftrace_events: "sched/sched_switch"
ftrace_events: "sched/sched_blocked_reason"
ftrace_events: "binder/binder_transaction"
}
}}
duration_ms: 10000
EOF
在 Perfetto UI 里,重点关注三个信号。
sched_blocked_reason:线程状态从 running 变成 sleeping 时,这个 ftrace 事件会记录阻塞原因。如果是 D 状态(不可中断睡眠),说明在等 I/O 或内核锁,这类情况在 Java 层的 traces.txt 里完全看不到。
binder_transaction:可以看到 Binder 事务的发起方和接收方,以及每笔事务的耗时。主线程发出的 Binder 调用如果耗时超过 1 秒,在这里会很明显。
主线程的 Running 时间占比:主线程长时间不在 Running 状态,说明它不是在算东西,而是在等什么。结合 traces.txt 的等待锁信息,可以精确定位是哪把锁。
对齐技巧:从 logcat 里找 ANR in 那条日志,拿到 ANR 发生的系统时间戳,在 Perfetto 时间轴上定位对应位置,然后往前看 5~10 秒的线程调度情况。
线上治理闭环
光能分析不够,还要能在线上持续监控和归因。我在项目里落地的方案是三层结构。
第一层:实时监控。主线程 Watchdog,每隔 1 秒向主线程 post 一个心跳消息,如果 3 秒内没有回应就捕获当前堆栈上报。这个方案在 ANR 真正触发之前就能抓到信号。
class MainThreadWatchdog(private val threshold: Long = 3000L) {
private val handler = Handler(Looper.getMainLooper())
private val monitorThread = HandlerThread("watchdog").also { it.start() }
private val monitorHandler = Handler(monitorThread.looper)
fun start() {
scheduleHeartbeat()
}
private var lastBeat = SystemClock.uptimeMillis()
private fun scheduleHeartbeat() {
handler.post { lastBeat = SystemClock.uptimeMillis() }
monitorHandler.postDelayed({
val gap = SystemClock.uptimeMillis() - lastBeat
if (gap > threshold) {
// 采集主线程堆栈并上报
reportStall(gap)
}
scheduleHeartbeat()
}, 1000)
}
}
第二层:ANR 本地采集。捕获 SIGQUIT 信号,在 native 层接管 signal handler,dump 完整线程堆栈后通过 socket 传回 Java 层结构化上报。Matrix(腾讯开源)的 AnrCanary 模块实现了这套逻辑,可以直接集成。
第三层:归因分类。上报的堆栈按根因自动打标签:包含 nativePollOnce 且无 pending 消息,标记为疑似误报;包含 waitForCondition,归为 Binder 等待;包含 waiting to lock,归为锁竞争。分类后按类型分配给不同团队处理,避免所有 ANR 堆在一个队列里无人认领。
治理 ANR 最大的挑战不是技术,是噪音。traces.txt 快照可能抓到的是恢复期而不是卡顿期,堆栈指向的是现象而不是原因。建立”快照 + 时序”的双维度分析习惯,同时在上报数据里带上触发前 3 秒的消息队列日志,能显著提升归因准确率。我更倾向于把 Watchdog 心跳阈值设在 2 秒而不是 5 秒——提前发现问题,总比等 ANR 弹窗再回溯省力。