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
前台 Service20s
后台 Service200s
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 lockheld 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 弹窗再回溯省力。