先看第一份现场。系统明确判定发生了输入 ANR,可 Trace 里的主线程既没有卡在业务代码,也没有等待 Java 锁,而是停在 nativePollOnce,看起来只是在等下一条消息。
text
Reason: Input dispatching timed out
(ActivityRecord{... com.example.launcher/.TargetActivity}
does not have a focused window)
"main" prio=5 tid=1 Native
at android.os.MessageQueue.nativePollOnce(Native method)
at android.os.MessageQueue.next(MessageQueue.java:335)
at android.os.Looper.loop(Looper.java:183)
主线程都空闲了,系统为什么还说应用无响应?问题到底出在应用、系统负载、GC,还是 Trace 根本没有拍中现场?这篇不先给结论。我选了三份容易误判的线上样本,按照当时实际可获得的证据,一步步把判断范围收紧。
分析 ANR 最重要的不是找到一个可疑函数,而是区分三件事:系统已经确认的触发事实、Trace 采样时看到的状态,以及仍然缺失的因果链。
这篇专注于把证据链走完整。如果你想先系统理解 Reason、时间线和线程状态应该怎样组合判断,可以先看:Android ANR 到底怎么定位?从触发原理到日志判断的完整方法。
案例一:主线程空闲,系统却在等一个焦点窗口
第一步,先读完整 Reason,不急着给主线程定罪
Input dispatching timed out 只说明输入调度超过了系统期限,真正缩小范围的是后半句:does not have a focused window。
这不是"窗口已经拿到焦点,但某个点击回调执行太久"。它表示 InputDispatcher 在等待一个能够接收输入的焦点窗口。此时,首要对象已经从某个 Java 方法转向 Activity、WindowManager、InputDispatcher 以及窗口焦点状态。
所以,主线程在 nativePollOnce 并不构成反证。应用可能在 Trace 采集时已经恢复,也可能从一开始就不是被长 Java 消息卡住,而是 Activity 已经进入 resumed 流程,窗口却没有在期限内成为合法输入目标。
第二步,把三个时间点摆在一起
这份日志里同时出现了三种时间:
text
CPU 统计区间:2024-05-26 00:27:44.890
-> 2024-05-26 00:28:11.750
Trace 原始时间:1716654492626784
若按微秒时间戳解释:约 2024-05-26 00:28:12.626784
最终日志时间:2024-05-26 00:28:20.58
CPU 区间结束到 Trace 采样相差约 0.88 秒,Trace 到最终日志输出又相差约 8 秒。这个时间差足够让窗口状态变化,也足够让主线程从异常路径重新回到 Looper。
因此,真正需要解释的是超时形成的那段时间,而单份 Trace 只描述它被采集的那一刻。看到 nativePollOnce 后直接写"主线程正常、应用无责",就把两个时间点混成了一个。
第三步,先用基础环境排除最粗的方向
设备是 8 核、6GB 内存。平台记录的系统可用内存约为 3.97GB,目标进程在 CPU 统计区间内占用 5.3%。这两项信息至少不支持"应用堆已经耗尽"或"目标进程持续占满 CPU"。
但这里的关键词是"不支持",不是"已经排除"。可用内存是采样值,进程 CPU 是区间平均值;它们都不能单独覆盖几百毫秒到数秒的窗口建立过程。
第四步,Load 接近 28,CPU 为什么只有 41%
text
Load: 27.91 / 26.13 / 25.27
CPU usage from 26860ms to 0ms ago:
63% system_server: 46% user + 16% kernel / faults: 281806 minor 78 major
48% related-ui-process: 35% user + 12% kernel / faults: 3607 minor
46% surfaceflinger: 27% user + 18% kernel / faults: 493 minor
45% system-launcher: 33% user + 11% kernel / faults: 16785 minor
5.3% com.example.launcher: 4% user + 1.3% kernel / faults: 4764 minor
41% TOTAL: 23% user + 17% kernel + 0% iowait
8 核设备的 1 分钟 Load 接近 28,说明可运行任务与不可中断等待任务的队列压力明显高于核心数。与此同时,41% TOTAL 表示整个采样区间平均仍有较多 CPU 空闲容量;换算成 8 核总能力,大约相当于 3.3 个核心持续繁忙,并不是 8 个核心全部跑满。
这组"高 Load、非满载 CPU"数据很有价值,因为它提示我们不能只盯目标应用:system_server、SurfaceFlinger 和系统桌面同期都较忙,窗口与图形链路可能受到放大影响。但它仍然没有回答"哪个任务在什么时刻阻止了焦点窗口建立"。iowait=0% 也不支持把问题直接归咎于块设备 I/O。
所以这一步得到的是系统环境候选,不是根因。要把它升级为因果结论,还需要更窄时间粒度的线程调度、窗口事务和焦点切换记录。
第五步,GC 数据更像反证
text
Free memory until OOME 494MB
Total memory 19MB
Max memory 512MB
Total mutator paused time: 62.994ms
Total time waiting for GC to complete: 85.246ms
Total GC count: 152
Total blocking GC count: 2
Total blocking GC time: 172.679ms
Free memory until OOME 494MB 只说明应用 Java 堆距离上限还很远,并不代表系统整体内存一定健康。GC 次数、总暂停时间和 blocking GC 总时间又大多是进程启动以来的累计值,不能直接搬到本次 ANR 窗口。
不过,这组数据没有出现一次能够独立解释输入超时的长 GC。结合当时约 3.97GB 的系统可用内存,"Java 堆或 GC 直接造成这次 ANR"的优先级可以下降。
第六步,native 栈里最醒目的函数也可能是误导
text
art::ConditionVariable::WaitHoldingLocks(...)
art::JNI<false>::CallObjectMethodV(...)
android::NativeInputEventReceiver::consumeEvents(...)
android::NativeInputEventReceiver::handleEvent(...)
android::Looper::pollInner(...)
android::Looper::pollOnce(...)
android.os.MessageQueue.nativePollOnce(...)
consumeEvents() 说明采样落在 native 输入接收链附近,但不能证明某个应用输入回调执行了整个超时窗口。Reason 指向"没有焦点窗口",而不是"已有焦点窗口的事件消费者处理超时"。两者不一致时,应先尊重系统状态机给出的具体 Reason,再用窗口时间线判断这段 native 栈属于恢复过程、并发事件,还是采样巧合。
ConditionVariable::WaitHoldingLocks 同样不能望文生义。它是 ART 内部条件变量等待,在 JIT、HeapTaskDaemon、Profile Saver 等正常运行时线程里也会出现。要证明它造成阻塞,至少要找到具体锁对象、持有线程和连续的等待关系;只看到相同函数名,不等于多个线程形成了死锁。
线程头还能提供一条校验:
text
state=S
schedstat=( 52404714067 10496985775 117648 )
utm=3934 stm=1305 core=4 HZ=100
schedstat 约表示该线程从启动以来累计运行 52.40 秒、在 runqueue 累计等待 10.50 秒、经历 117648 次调度;utm + stm 按 HZ=100 换算约为 52.39 秒,与累计运行时间基本对应。这能验证字段口径,却不能证明 10.50 秒都发生在本次 ANR 前。
这起案例,结论能写到哪里
已经确认的直接触发: InputDispatcher 在期限内没有等到目标 Activity 的 focused window,因此发生输入调度超时。
仍未闭环的工程根因: 为什么焦点窗口没有及时建立。同期系统负载较高,窗口和图形相关进程较忙,它们是合理的放大因素,但目前不能直接写成根因。
下一次优先补采: 围绕事件时间保存 mCurrentFocus、mFocusedApp、Activity 的 resumed/visible 状态、window attach、首帧、转场、锁屏和系统窗口覆盖;能复现时,用 bugreport 或 Perfetto 对齐 InputDispatcher、WindowManager、SurfaceFlinger 与线程调度。
第一起案例之所以能把方向收得很窄,不是因为 Trace 更漂亮,而是因为完整 Reason 还在。第二起案例恰好相反:主线程同样空闲,但最关键的 Reason 丢了。
案例二:主线程还是空闲,但这次 Reason 丢失了
第一眼能看到的,只有采样时状态
text
ANR_EXCEPTION
Find process anr, but unable to get anr message.
"main" prio=5 tid=1 Native
| state=S schedstat=( 612313101 492845062 1829 )
| utm=38 stm=22 core=2 HZ=100
native: ... __epoll_pwait
native: ... android::Looper::pollOnce
at android.os.MessageQueue.nativePollOnce(Native method)
这份 Trace 能直接证明的只有一件事:采集这一刻,主线程正在 Looper/native poll 中等待。它没有展示正在执行的业务消息,也没有展示主线程在等待某把 Java 锁。
schedstat 换算后约为累计运行 0.612 秒、累计在 runqueue 等待 0.493 秒、经历 1829 次调度。它们仍然是线程生命周期累计值,不是 ANR 前 5 秒的切片。仅靠这一行,既不能证明主线程遭到 CPU 饥饿,也不能证明它在超时窗口一直空闲。
时间有了,但不知道系统在等什么
text
anr time: 1717008173074 ms
-> 2024-05-30 02:42:53.074
时间戳可以帮助关联日志,却无法替代 Reason。没有 Reason,就无法判断这是输入事件、Service、广播、前台服务启动,还是其他受系统监控的操作。第一起案例里的"窗口焦点"路线,在这里没有依据;"历史慢消息"同样没有依据。
内存数据没有异常,但它只排除了一小部分可能
text
Total bytes allocated 252MB
Total bytes freed 238MB
Free memory until OOME 497MB
Total memory 18MB
Max memory 512MB
Total mutator paused time: 55.048ms
Total time waiting for GC to complete: 554.232us
Total GC count: 29
Total blocking GC count: 0
这组数据不支持"应用堆接近 OOM"或"进程生命周期内存在阻塞式 GC"。基础数据里的可用内存和存储也没有明显异常。但这些仍然只是应用侧摘要,不能排除系统服务、窗口、Binder、调度或瞬时系统内存压力。
两个线程等待同一个对象,是不是死锁
text
"worker-180" ... Waiting
at java.lang.Object.wait(Native method)
- waiting on <0x07e90958> (a java.lang.Object)
at com.thirdparty.client.Worker.run(...)
"worker-182" ... Waiting
at java.lang.Object.wait(Native method)
- waiting on <0x07e90958> (a java.lang.Object)
at com.thirdparty.client.Worker.run(...)
不是,至少这份日志证明不了。Object.wait() 会释放对象 monitor,然后等待其他线程通知;多个线程同时等待同一个条件对象完全可能是正常设计。
死锁需要一张闭环等待图,例如 A 持有 L1 等待 L2,B 持有 L2 又等待 L1。这里既没有主线程依赖 0x07e90958 的证据,也没有对象持有者和反向依赖。因此目前只能写"两个第三方工作线程在同一条件对象上等待",不能写"第三方 SDK 死锁导致 ANR"。
"主线程已经恢复"为什么仍然只是猜测
text
T0:某条消息、Binder 或窗口状态开始异常
T1:系统达到 ANR 期限
T2:异常解除,主线程重新进入 nativePollOnce
T3:系统或第三方平台完成 Trace 采集
这条时间线在机制上成立,也能解释"已经 ANR,主线程却空闲"。但它不是当前日志已经证明的事实。另一种同样成立的解释是:超时发生在窗口或系统组件,主线程从头到尾都没有一条 Java 慢消息。
当 Reason 丢失时,这两条路径无法靠单份空闲栈区分。继续解释更多后台线程,只会制造更多看似合理却无法验证的故事。
这起案例,正确答案就是证据不足
可以确认: Trace 采集时主线程已经处于 native poll;应用堆与累计 GC 数据没有明显异常。
不能确认: 历史慢消息、CPU 抢占、系统问题、窗口问题以及第三方线程死锁。它们都只是候选。
下一次必须补采: 完整 ANR Reason、系统 uptime、Trace 时间、至少前后 30 秒的主线程 dispatch 记录,以及同期窗口、Binder、调度和内存数据。没有这些信息,最严谨也最有价值的结论就是"现有证据不足"。
第二起案例提醒我们,空闲栈不等于无问题,缺失 Reason 也不能靠想象补齐。第三起案例则更有诱惑性:这次主线程不是空闲,而是 Runnable,栈顶还有一个非常显眼的监控框架函数。
案例三:栈顶正好是 LooperMonitor.dispatch
看起来像答案的函数,可能只是公共入口
text
"main" prio=5 tid=1 Runnable
| state=R schedstat=( 7226568560 918028598 8604 )
| utm=584 stm=137 core=2 HZ=100
at com.tencent.matrix.trace.core.LooperMonitor.dispatch(...)
at com.tencent.matrix.trace.core.LooperMonitor$LooperPrinter.println(...)
at android.os.Looper.loop(...)
"matrix_time_update_thread" ... Waiting
at java.lang.Object.wait(Native method)
at com.tencent.matrix.trace.core.AppMethodBeat$3.run(...)
"matrix_x_1" ... TimedWaiting
at java.util.concurrent.SynchronousQueue.poll(...)
at java.util.concurrent.ThreadPoolExecutor.getTask(...)
Matrix 通过 Looper Printer 观察消息分发边界。Looper 在一条消息开始和结束时调用 Printer,LooperPrinter.println() 再进入 dispatch()。也就是说,大量完全正常的主线程消息都会经过这里。
所以,LooperMonitor.dispatch() 出现在栈顶,首先只能说明采样落在监控入口。它没有告诉我们这次调用已经执行多久,也没有区分耗时来自 Matrix 自身,还是来自它正在包裹的业务消息。
两条 Matrix 后台线程也没有给出阻塞证据:一个在 Object.wait() 等通知,另一个在线程池队列里等任务,都是常见的空闲状态。线程名属于某个框架,不等于框架阻塞了主线程。
怎样证明它不只是"碰巧被拍到"
要把 Matrix 从公共入口升级为根因候选,至少需要补到下面一类闭环证据:
- 多次连续采样都停在
LooperMonitor.dispatch()内部同一条耗时路径,而不是只出现一次入口帧; - Matrix 自身的 dispatch 开始与结束时间证明,监控回调覆盖了 ANR 超时窗口;
- 主线程在 Matrix 内部等待一把具体锁,并能找到锁持有者与后续依赖;
- 关闭对应插件或更换版本后,同一复现场景中的耗时稳定消失;
- Perfetto 或方法 Trace 显示 CPU 时间确实消耗在 Matrix 内部,而不是被它包裹的业务消息。
这些证据也解释了为什么"Runnable"不能直接翻译成"正在持续占用 CPU"。ART 的 Runnable 与 Linux 的 state=R 只描述采样时的状态;单点样本仍然不知道这条路径覆盖了几微秒、几毫秒还是整个超时窗口。
这起案例,Matrix 只能保留为候选
可以确认: Trace 采集时,主线程正在 Matrix 的 Looper 监控入口内。
不能确认: Matrix 自身执行了足以触发 ANR 的耗时操作。现有工作线程状态也不能证明 Matrix 与主线程形成锁依赖。
下一步: 读取 Matrix 自己记录的 Message 与 dispatch 耗时,核对监听器和内部回调;再做插件开关或版本 A/B,并用 Perfetto 或连续采样确认时间真正消耗在哪里。
走完三个案例后,再谈证据等级
三份 Trace 最有价值的地方,不是给出了三个根因,而是展示了三种不同的证据上限。
| 案例 | 最强证据 | 当前结论上限 | 不能越过的边界 |
|---|---|---|---|
| 无焦点窗口 | InputDispatcher 的明确 Reason | 直接触发已确认;深层根因待补证 | 高 Load 不能自动变成焦点丢失的根因 |
| 空闲主线程、Reason 丢失 | 只证明采样时位于 nativePollOnce |
现有证据不足 | 不能反推历史慢消息、系统问题或死锁 |
| Matrix 位于栈顶 | 监控入口在采样时执行 | 框架是待验证候选 | 公共包装帧不能独立证明因果 |
在复盘中,我会把结论分成四级:
| 等级 | 证据条件 | 结论写法 |
|---|---|---|
| A:因果闭环 | Reason、时间窗和调用或依赖链一致,能够解释完整超时 | 已确认根因 |
| B:强相关 | 多条同期证据一致,但还缺一段关键链路 | 高概率由某因素触发或放大 |
| C:候选线索 | 只有单次栈、累计指标或相关关键词 | 需要优先验证 |
| D:无法判断 | Reason、时间线或关键现场缺失 | 现有证据不足,并写清下一次采集项 |
同一案例里,不同层次的结论还可能属于不同等级。案例一中,"没有 focused window 导致输入超时"已经有直接系统证据;但"为什么没有 focused window"仍然没有闭环。把触发原因与更深层工程根因拆开,才能避免修复一个并不存在的主线程耗时。
后来我固定只追七个问题
text
1. 系统等待什么?
完整 Reason、进程、组件、Android 版本、targetSdk
2. 异常发生在哪段时间?
uptime、CPU 区间、Trace 时间、上报时间
3. Trace 拍到的是触发现场还是恢复后的状态?
ART 状态、Linux state、schedstat、Java 与 native 栈
4. 主线程在依赖谁?
锁对象与持有者、Binder 接口与对端、窗口与输入目标
5. 系统环境支持哪个候选?
Load、TOTAL CPU、iowait、faults、GC、系统内存、swap、LMKD
6. 有哪些反证?
哪条常见解释与当前数据不一致
7. 结论到哪一级?
已确认事实、候选假设、缺失证据、下一次验证
这七个问题的顺序很重要。Reason 决定调查方向,时间线决定 Trace 是否拍中现场,线程依赖与系统数据才负责验证候选。顺序一旦反过来,我们就很容易先被一个函数名吸引,再回头挑选支持它的数据。
ANR 分析最难接受的结果,往往是"目前还不能定位"。但一个明确说明证据上限、排除错误方向并给出下一次采集方案的 D 级结论,比一个由栈顶关键词拼出来的伪根因更有价值。前者让排查继续向前,后者只会让问题换一个地方再次出现。