主线程明明是空闲的,为什么仍然 ANR?3 个真实 Trace 的证据链复盘

先看第一份现场。系统明确判定发生了输入 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 从公共入口升级为根因候选,至少需要补到下面一类闭环证据:

  1. 多次连续采样都停在 LooperMonitor.dispatch() 内部同一条耗时路径,而不是只出现一次入口帧;
  2. Matrix 自身的 dispatch 开始与结束时间证明,监控回调覆盖了 ANR 超时窗口;
  3. 主线程在 Matrix 内部等待一把具体锁,并能找到锁持有者与后续依赖;
  4. 关闭对应插件或更换版本后,同一复现场景中的耗时稳定消失;
  5. 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 级结论,比一个由栈顶关键词拼出来的伪根因更有价值。前者让排查继续向前,后者只会让问题换一个地方再次出现。

参考资料

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui