Android-SystemUI ANR协程相关问题排查

从一份 ANR trace 层层深挖到根因:SystemUI 蓝牙"输入超时"全景剖析

一次车机 SystemUI 的 Input ANR,表面看是主线程卡了一次蓝牙 binder 调用; 往下挖三层,发现是 64 路协程洪峰 + 双端 binder 线程池饱和; 再往知识层挖,牵出协程取消机制、Dispatchers.IO 线程池共享、binder 线程池排队模型三个容易被误解的知识点。


0. 背景

  • 现象:车机(Android 14,userdebug)SystemUI 无响应,用户触摸无反应,系统弹出 ANR。
  • 素材:一份 ANR trace 文件 anr_2026-07-02-16-15-45-289,约 2.9MB。
  • 涉及模块:SystemUI 内集成的第三方蓝牙面板库(com.vendor.hmi.panellib.bt)。

先说最终结论(后文全部是推导过程):

蓝牙面板刷新逻辑在 64 个协程 worker 上并发发起大量同步蓝牙 binder 调用(getName/getAlias/getBondState),同时主线程在广播回调里也发起了一次同样的同步调用。洪峰把 com.android.bluetooth 的 binder 线程池和 systemui 自己的 binder 回包线程池同时打满,主线程的那次调用排在队尾 10 秒没等到回复,触摸事件无法分发,触发 Input dispatching timed out。


1. ANR trace 的分析方法论

一份 ANR trace 通常长这样:

sql 复制代码
Subject: <ANR 类型和等待对象>
RssHwmKb / RssKb / ...
--- CriticalEventLog ---
----- dumping pid: XXXX at <uptime> -----
----- pid XXXX at <wall clock> -----
Cmd line: <进程名>
Build fingerprint / ABI ...
DALVIK THREADS (N):
"main" prio=5 tid=1 ...
  | sysTid=... state=S ...
  <堆栈>
"thread-name" prio=5 tid=N ...
  ...
----- pid YYYY ...(若干关联进程,含 system_server)
----- end XXXX -----
----- dumping ended at ... -----

我把它总结成五步法,每一步都有明确的"要回答什么问题":

步骤 看什么 回答什么问题
① 看 Subject 头 ANR 类型、进程、等待时长、RSS 谁在什么约束下超时了?先排除内存问题
② 看主线程 "main" 的完整堆栈 直接卡点在哪?(卡在哪个调用、等什么资源)
③ 看全进程线程 所有线程的状态与堆栈聚类 是孤例还是共性?有没有放大器(大量线程堵同一处)、锁死(Blocked)、binder 线程占用
④ 看关联进程 trace 里 dump 的其它进程(服务端、system_server) 直接卡点依赖的"对端"当时是什么状态?对端死锁 or 只是排队?
⑤ 交叉排除与定案 内存/GC/锁/CPU 信号互相印证 排除备选原因,形成完整因果链

几个实战命令(Git Bash / Linux 通用):

perl 复制代码
# 这个文件里都 dump 了哪些进程
grep -n "dumping pid|^Cmd line:" anr_xxx
​
# 提取主线程完整堆栈
awk '/^"main"/,/^$/' anr_xxx
​
# 提取某个进程段(按行号切开)
sed -n '14,3645p' anr_xxx > sysui.txt
​
# 统计"堵在同一地点"的线程 ------ 这一步往往是破案关键
grep -A1 "awaitResultNoInterrupt" sysui.txt | grep "at android.bluetooth" | sort | uniq -c | sort -rn
​
# 看 binder 线程忙/闲:有 executeCommand = 正在处理事务;只有 joinThreadPool = 空闲
awk '/^"binder:/{name=$0; busy=0} name!="" && /executeCommand/{busy=1} /^$/{if(name!=""){print (busy?"BUSY":"idle")" "name; name=""}}' sysui.txt

下面用这套方法逐层分析这份文件。


2. 逐层分析

2.1 第一层:Subject ------ 定性

ini 复制代码
Subject: Input dispatching timed out (d024424 com.android.systemui (server) is not
responding. Waited 10000ms for MotionEvent(deviceId=2, ..., action=MOVE, ...
pointers=[0: (2260.1, 421.7)]), policyFlags=0x62000000).
RssHwmKb: 286120
RssKb: 260700

读出三个信息:

  1. ANR 类型是输入分发超时 :用户在屏幕上滑动(MOVE 事件),SystemUI 的窗口作为触摸的服务端 10 秒没有消费事件,InputDispatcher 判定它死了。输入超时几乎总是意味着主线程被卡住------比"广播超时""服务超时"更直接指向 UI 线程卡顿。
  2. RSS 260MB:内存占用正常,基本排除低内存导致的换页/被冻。
  3. 文件里 suspend all histogram: Sum 6.266ms:dump 时 ART 挂起所有线程只用了 6ms,说明 GC/运行时没有异常,不是 GC 停顿背锅。

2.2 第二层:主线程 ------ 直接卡点

scss 复制代码
"main" prio=5 tid=1 TimedWaiting
  at jdk.internal.misc.Unsafe.park(Native method)
  at java.util.concurrent.CompletableFuture.timedGet(CompletableFuture.java:1915)
  at java.util.concurrent.CompletableFuture.get(CompletableFuture.java:2071)
  at com.android.bluetooth.x...SynchronousResultReceiver.awaitResultNoInterrupt(SynchronousResultReceiver.java:210)
  at android.bluetooth.BluetoothDevice.getName(BluetoothDevice.java:1638)
  at android.bluetooth.BluetoothDevice.getAlias(BluetoothDevice.java:1704)
  at com.android.settingslib.bluetooth.CachedBluetoothDevice.getName(CachedBluetoothDevice.java:573)
  at com.android.settingslib.bluetooth.CachedBluetoothDevice.toString(CachedBluetoothDevice.java:977)
  at java.lang.String.valueOf(String.java:4092)
  at java.lang.StringBuilder.append(String.java:179)
  at com.vendor.hmi.panellib.bt.ui.BTPanelObj.onProfileConnectionStateChanged(BTPanelObj.kt:159)
  at com.vendor.hmi.panellib.bt.data.BTRepositoryImpl.onProfileConnectionStateChanged(BTRepositoryImpl.kt:268)
  at com.android.settingslib.bluetooth.BluetoothEventManager.dispatchProfileConnectionStateChanged(...)
  ...
  at android.app.LoadedApk$ReceiverDispatcher$Args.lambda$getRunnable$0(...)
  at android.os.Looper.loopOnce(Looper.java:205)
  at android.app.ActivityThread.main(ActivityThread.java:8322)

自底向上读(Android 看堆栈要从下往上读调用关系):

  • 主线程正在 Looper.loopOnce 里处理一条消息------是一个广播 :蓝牙 profile 连接状态变化,经 settingslib 的 BluetoothEventManager 分发到了蓝牙面板的 BTPanelObj.onProfileConnectionStateChanged。
  • 业务代码在 BTPanelObj.kt:159 拼一个字符串,把 CachedBluetoothDevice 对象直接 append 进 StringBuilder------这会隐式调用它的 toString()。
  • 而 settingslib 的 CachedBluetoothDevice.toString() 内部调 getName(),它没有用本地缓存 ,而是走 BluetoothDevice.getAlias() → getName()------一个同步 binder 调用 ,最终停在 CompletableFuture.get 等 framework 内部的回调完成。

到这里已经有了初步结论:主线程在广播回调里发起了一次同步 binder 调用,没等到回复。 但一份合格的根因分析还要回答:为什么一次 getName 会 10 秒不返回?继续挖。

2.3 第三层:进程清单 ------ 找对端

perl 复制代码
grep -n "dumping pid|^Cmd line:" anr_xxx

trace 一共 dump 了 40 多个进程:system_server、com.android.bluetooth(dump 了两次)、蓝牙相关的若干厂商进程、一堆车机 HAL/系统进程......getName 的服务端就是 com.android.bluetooth(pid 5642)。同时记下时间线:

进程 dump 时刻 相对时差
com.android.systemui (2244) 16:15:45.13 0
system_server (933) 16:15:45.6 +0.5s
com.android.bluetooth (5642) 16:15:55.0 / 55.4 +10s

蓝牙进程的 dump 比案发时刻晚了 10 秒------这个细节后面有用。

2.4 第四层:全进程线程扫描 ------ 破案的关键一步

只看主线程会漏掉真相。对 systemui 段(124 个线程)做堆栈聚类,统计"堵在同一地点"的线程:

perl 复制代码
grep -A1 "awaitResultNoInterrupt(SynchronousResultReceiver.java:210)" sysui.txt \
  | grep "at android.bluetooth" | sort | uniq -c | sort -rn

结果:

less 复制代码
31   at android.bluetooth.BluetoothDevice.getName(BluetoothDevice.java:1638)
18   at android.bluetooth.BluetoothDevice$2.apply(BluetoothDevice.java:2033)   ← getBondState
16   at android.bluetooth.BluetoothDevice.getAlias(BluetoothDevice.java:1702)

65 个线程(64 个协程 worker + 主线程)全部堵在蓝牙同步 binder 调用上。 随便抽一个 worker 看完整堆栈:

php 复制代码
"DefaultDispatcher-worker-28" daemon prio=5 tid=77 TimedWaiting
  at ...CompletableFuture.get(...)
  at ...SynchronousResultReceiver.awaitResultNoInterrupt(SynchronousResultReceiver.java:210)
  at android.bluetooth.BluetoothDevice$2.apply(BluetoothDevice.java:2033)
  at android.app.PropertyInvalidatedCache.recompute(PropertyInvalidatedCache.java:775)
  at android.app.PropertyInvalidatedCache.query(PropertyInvalidatedCache.java:999)
  at android.os.IpcDataCache.query(IpcDataCache.java:360)
  at android.bluetooth.BluetoothDevice.getBondState(BluetoothDevice.java:2076)
  at com.android.settingslib.bluetooth.BluetoothDeviceFilter$UnbondedDeviceFilter.matches(BluetoothDeviceFilter.java:96)
  at com.vendor.hmi.panellib.bt.data.BTRepositoryImpl.getNotBondedDevices(BTRepositoryImpl.kt:122)
  at com.vendor.hmi.panellib.bt.ui.BTPanelObj$refreshState$1.invokeSuspend(BTPanelObj.kt:345)
  at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
  at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:115)
  at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)

64 个 worker 的堆栈全部收敛到同一条业务链路:

scss 复制代码
BTPanelObj.refreshState (BTPanelObj.kt:345)
  └─ BTRepositoryImpl.getNotBondedDevices
       ├─ (kt:122) filter { UnbondedDeviceFilter.matches } → BluetoothDevice.getBondState() → binder
       └─ (kt:128) joinToString { cachedDevice }            → toString → getAlias/getName → binder

每刷新一次,就对扫描到的每台设备发起 2~3 次同步 binder 调用;64 路协程并发执行,就是几百到几千次往返。 主线程那次 getName 只是这场洪峰里普通的一员,排在队尾。

注意线程名的两个细节,后面知识部分会展开:

  • 线程叫 DefaultDispatcher-worker-N,编号恰好 1~64;
  • 堆栈里有 LimitedDispatcher$Worker.run。

2.5 第五层:对端状态 ------ 排除死锁,确认排队

com.android.bluetooth(pid 5642,93 个线程) :

  • main 线程:epoll 空闲等待,正常;
  • binder 线程:binder:5642_1 ~ binder:5642_8 共 8 个,全部空闲 (只有 joinThreadPool,无 executeCommand)。

system_server(pid 933) :32 个 binder 线程全部空闲,BluetoothManagerService 空闲。

systemui 自己的 binder 池 :binder:2244_1 ~ binder:2244_F 共 15 个,15/15 全部处于 executeCommand 状态------100% 忙(被 dump 的 suspend-all 冻结在 JNI 切换点,所以没有 Java 帧可看)。

把三个进程的状态放在一起:

进程 binder 池状态 含义
systemui(客户端) 15/15 全忙 回包路径被海量回调 + 广播投递淹没
com.android.bluetooth(服务端) 池长到 8 个,dump 时全闲 峰值并发约 8;10 秒后排空了
system_server 32 个全闲 只被动接权限校验,不是瓶颈

关键推断:

  1. 不是死锁 。蓝牙进程在案发 10 秒后 binder 线程全部空闲,说明事务最终都被消费完了------是排队排空,纯粹的吞吐问题。
  2. 是双端饱和。客户端 15 个 binder 线程全忙(等下讲为什么回包要消耗客户端自己的 binder 线程),服务端 8 个线程逐个消化洪峰。
  3. 没有任何 Blocked 线程(Java 监视器锁死锁排除),GC 正常,system_server 无恙------其它备选原因全部排除。

2.6 因果链定案

objectivec 复制代码
蓝牙连接过程产生一连串广播(profile 状态变化 / 扫描结果)
        │
        ▼ 每条广播都触发一次 refreshState()(cancel + relaunch"节流")
64 个协程 worker 并发执行 getNotBondedDevices
        │  每台设备 2~3 次同步 binder(getBondState / getAlias / getName)
        ▼
几百~几千次同步往返涌入 com.android.bluetooth(8 线程逐个服务,
每次服务还要嵌套同步调 system_server 做权限校验)
        ▼
结果回调(oneway)回传 systemui 时,又要排 15 个全忙的本地 binder 线程
        ▼
主线程恰好在广播回调里也发了一次 getName → 排在双端队尾
        ▼
主线程 10 秒不回 Looper → MotionEvent 无法分发 → Input ANR

3. 知识点一:协程------先讲清"是什么",再看它为什么没兜住

3.1 协程是什么:可以中途暂停的任务

一句话定义:协程(coroutine)= 一个可以在中途"暂停、之后再继续"的任务。暂停和恢复由程序自己(编译器 + 运行库)在用户态完成,不需要操作系统介入;而线程的调度切换必须由操作系统内核完成。

最容易建立的直觉:线程是操作系统派给进程的工人,协程是贴在工人手里的任务清单。工人可以随时放下手头任务去推进另一项,回头再从断点继续------一个工人可以轮流推进很多任务,任务数远多于工人数。

线程与协程的对比:

线程 协程
谁调度 操作系统内核 Kotlin 运行时(用户态)
切换成本 高:陷入内核、保存完整线程上下文(微秒级) 极低:保存一个小对象(纳秒~微秒级)
栈内存 默认约 1MB 初始几十字节,按需增长
数量级 几百上千个就很吃力 几十万上百万个无压力
关系 协程跑在线程上 一条线程可轮流跑无数协程

注意:协程不是"更轻的线程",也不替代线程------它复用少量线程跑大量任务,是线程之上的一层抽象。

3.2 挂起的本质:一次函数 return

这是理解协程、也是理解本案的关键。一个 suspend 函数:

kotlin 复制代码
suspend fun loadProfile(): Profile {
    val token = getToken()        // 挂起点 1
    val user  = fetchUser(token)  // 挂起点 2
    return user.profile
}

会被编译器改写成状态机 + Continuation(续体,即"剩下的代码"打包成的回调对象) ,近似于:

typescript 复制代码
class LoadProfileStateMachine implements Continuation {
    int label = 0;      // 当前执行到第几步
    Object result;      // 上一步的返回值

    Object invokeSuspend(Object resume) {
        switch (label) {
            case 0: label = 1; return getToken(this);   // 直接 return!
            case 1: token = resume; label = 2;
                    return fetchUser(token, this);      // 又 return!
            case 2: return ((User) resume).profile;
        }
    }
}

"挂起"的本质就是一次函数 return :把"接下来要做的剩余工作"打包成 Continuation 交给对方,将来结果就绪后再调用 invokeSuspend(result) 从断点恢复。函数既然返回了,当前线程立刻空出来,可以去做别的协程------这就是协程轻量的根源。恢复后跑在哪条线程由 Dispatcher 决定,所以一个协程暂停前后完全可能换了线程。

本案 trace 里的 BTPanelObj$refreshState$1.invokeSuspend(BTPanelObj.kt:345) 正是编译器为 refreshState 协程体生成的状态机类及其入口方法------每个协程在线程堆栈里都以这种面目出现。

挂起(suspend)与阻塞(block)必须分清,这是本案的病根:

scss 复制代码
delay(1000)         // 挂起:函数 return,工人去干别的,到期回来继续
Thread.sleep(1000)  // 阻塞:工人被绑住 1 秒,什么都干不了

delay() 是挂起函数;而 Thread.sleep()、同步 binder 调用、CompletableFuture.get()、阻塞 IO 都是阻塞调用 ------它们钉死线程,协程机制对它完全无感。阻塞调用会把"轻量的协程"退化成"重量级的线程占用" :协程的两条核心契约------"挂起不占线程"和"取消在挂起点生效"------在阻塞调用面前全部失效。

3.3 核心组件速览

  • CoroutineScope(作用域) :协程的生命周期容器(viewModelScope、自建的 CoroutineScope(...))。作用域销毁时,里面所有协程一起取消,不会泄漏。
  • Job(任务句柄) :launch 的返回值,就是本案代码里的 job。可以 cancel()、查 isActive、join() 等待。
  • 结构化并发:Job 构成一棵树------父取消会级联取消所有子协程,子协程异常也按规则上报父级。抓住根,整棵树都跑不掉。
  • Dispatcher(调度器) :决定协程跑在哪个线程池(Main/Default/IO/limitedParallelism)。trace 里的 DefaultDispatcher-worker-N 就是宿主线程名,LimitedDispatcher$Worker.run 这一帧则暴露了受限调度器的存在(下一节展开)。

3.4 回到本案:这份"节流"代码为什么失效

应用侧的刷新代码形如:

kotlin 复制代码
private fun refreshState() {
    if (!needRefreshUI) return
    if (job?.isActive == true) {
        job?.cancel()              // "节流":取消旧任务
    }
    job = ioScope.launch {
        try {
            val adapterItems = mutableListOf<BTBaseItem>()
            // 获取设备列表、过滤、拼名......
        } catch (e: Exception) { ... }
    }
}

trace 证明这个"节流"完全没生效:同一时刻 64 个 job 的协程体都在飞。原因是取消是协作式的:cancel ≠ interrupt。

job.cancel() 只做两件事:打个"已取消"标记 + 让协程在下一次到达挂起点时 抛 CancellationException。它不像 Thread.interrupt() 那样强行打断执行中的代码。推论:

  • 代码里没有任何挂起点 时,被 cancel 的协程会把剩下的代码原封不动跑完才结束;
  • 线程正堵在阻塞调用 (同步 binder、CompletableFuture.get)时,cancel 连线程都释放不了。

getNotBondedDevices 里的 filter 和 joinToString 全是普通阻塞代码、零挂起点------所以每次广播 cancel 掉旧 job 后,旧 job 照样把列表里所有设备的 binder 调用发完为止。64 条事件 = 64 路"僵尸刷新"同时在飞。这不是节流(throttle),是想做去抖(debounce),而因为无挂起点,连去抖都没发生。

让取消生效的梯子:循环里 ensureActive() / isActive 检查 → yield() → 阻塞包 runInterruptible → 根本解法是换异步 API。


4. 知识点二:用了 IO 调度器,线程为什么叫 DefaultDispatcher

trace 里 64 个线程全是 DefaultDispatcher-worker-N,ioScope 明明是 IO 调度器------这不是用错了,而是:

Dispatchers.IO 和 Dispatchers.Default 底层共享同一个线程池(同一个 CoroutineScheduler),池内线程统一命名 DefaultDispatcher-worker-N。

两者的区别不是"两批线程",而是对同一批线程的并发配额:

scss 复制代码
// kotlinx.coroutines 的等价语义
val IO = Default.limitedParallelism(64)   // 最多 64 个线程跑阻塞任务
// Default:最多约 CPU 核数个线程跑 CPU 任务

这次 trace 留下了两个铁证:

  1. 堆栈里的 LimitedDispatcher$Worker.run 是任务经过 limitedParallelism 视图派发的特征帧------Dispatchers.IO 正是这样实现的;
  2. 堵住的 worker 恰好是 worker-1 ~ worker-64 共 64 个 ,正好等于 IO 的默认并发上限(max(64, CPU核数))。如果用的是 Default,上限约等于核数(8 左右),不可能堵出 64 个。

连带伤害 :Dispatchers.IO 是全进程共享的。蓝牙面板把 64 个 IO 槽位全部钉死在 binder 等待上之后,systemui 里任何其它用 Dispatchers.IO 的代码(磁盘、网络、SharedPreferences)一起饿死------这就是一个"后台刷新"能拖垮整个 SystemUI 的原因之一。


5. 知识点三:binder 线程池与排队模型

基本规则

  • 每个进程一个 binder 线程池,线程名 binder:<pid>_<n>;
  • 按需创建,只增不减 :事务到达且现有线程全忙且未达上限时,驱动要求进程再起一个。所以 trace 里看到的 binder 线程数 ≈ 历史峰值并发(蓝牙进程 8 个 = 峰值并发 8;systemui 15 个且全忙 = 打满上限);
  • 上限写死 :普通 app 默认 15 个(改不了),system_server 特批 31 个。

一次调用的完整旅程

BluetoothDevice.getName 的两段式结构值得单独画出来:

scss 复制代码
systemui 调用线程                          com.android.bluetooth
──────────────────                        ─────────────────────
mService.getName(attribution,                binder 线程执行 getName:
                device, recv)       ──►       ① 权限校验(同步嵌套调 system_server)
│ [BC_TRANSACTION, 同步]                      ② 取设备名(有锁)
▼                                             ③ recv.send(name)  [oneway 回调]──┐
ioctl 睡眠等待                       ◄── 方法返回 [BC_REPLY]                     │
recv.awaitResultNoInterrupt()                                                  ▼
  等 CompletableFuture complete  ◄── systemui 自己的某个 binder 线程执行
                                     ResultReceiver.onReceiveResult(回调送达)

三个要点:

  1. 一个事务占死一个服务端线程直到回复;服务期间嵌套的同步调用(权限校验)继续占着这个线程。
  2. 回包要消耗客户端自己的 binder 线程 。这次 65 个等待者全部停在 CompletableFuture.get(而不是 __ioctl),说明同步方法调用本身已返回------它们卡在"oneway 回调送回来被执行"这最后一步,而 systemui 的 15 个 binder 线程当时 100% 忙。
  3. oneway 事务走对端异步队列(缓冲约 1MB);同步事务继承调用方优先级;A 同步调 B、B 又同步调 A 且两边线程池全满 = binder 死锁------这就是"在广播回调里做同步 binder"高危的原因。

排队数学:为什么 64 路会堵

binder 排队不怕 64 个等待者,怕的是总往返次数超过双端吞吐:

yaml 复制代码
总往返数 ≈ 64 worker × ~20 台设备 × 2~3 次 ≈ 2500~4000 次
服务端吞吐 ≈ 8 线程 ÷ 每次调用 5~20ms(权限校验嵌套 + 连接期锁竞争)
排空时间 ≈ 2~12 秒,且事件还在不断触发新一轮

到达率 > 服务速率时队列只增不减,主线程那一次排在队尾,10 秒超时。ANR 的三个必要条件:总事务量巨大(洪峰)× 单次延迟被放大(权限校验+锁)× 主线程自己也发了一次同步调用。缺一个都不至于死。


6. 修复方案

按"治本 → 治标"排序:

  1. 主线程广播回调去掉同步调用 :BTPanelObj.kt:159 拼 CachedBluetoothDevice 的日志行删除或改用本地缓存字段------无论洪峰是否存在,这一行单独就足以 ANR。
  2. 列表刷新零 binder 化 :getNotBondedDevices 里的 getBondState/getName/getAlias 全部换成 CachedBluetoothDevice 的本地缓存字段(settingslib 会随广播更新缓存)。一次刷新 binder 调用数降到 0~个位数,双端线程池自然闲置。
  3. 串行化 + dirty 标记代替 cancel+relaunch :用 Mutex 或单线程 dispatcher;刷新中再来的事件只置标记,跑完后补一次。从结构上保证同一时刻只有一路刷新。
  4. 循环内 ensureActive() :如果暂时做不到第 2 条,至少让 cancel 能拦住后续的 binder 调用(注意它救不了已经堵住的线程,只能止损)。
  5. 超时回退 :catch TimeoutException 后回退缓存名,别让异常上抛。

7. 可复用的 ANR 分析检查清单

  • Subject:ANR 类型(input/broadcast/service)?进程?超时阈值?RSS 是否异常?
  • 主线程:卡在什么调用上?等的是什么(锁/binder/CPU)?从下往上读调用链
  • 全线程聚类:多少线程堵在同一地点?(孤例 vs 洪峰)
  • 有没有 Blocked 线程(Java 锁死锁)?GC/suspend 指标是否正常?
  • binder 线程:数量(≈峰值并发)、忙/闲(executeCommand vs joinThreadPool)
  • 对端进程:直接卡点依赖的服务在 trace 里的状态(死锁 vs 排队)
  • 时间线:各进程 dump 时刻的差值(晚 dump 的进程状态只能作参考)
  • 交叉验证:把每个备选原因找到"排除/确认"的证据,最后拼出完整因果链

8. 一句话总结

ANR 的表面是"主线程卡了 10 秒",根因是"协程的取消契约被阻塞调用打破 + 一次刷新被放大成几千次 binder 往返 + 双端线程池吞吐耗尽"。 分析上,真正定案的不是主线程堆栈(那只是案发现场),而是全线程聚类发现的 64 路洪峰和对端 binder 池的忙闲对照;修复上,唯一根本的方向是把 binder 往返次数降下来,而不是调整线程或调度器参数。

相关推荐
雷一言说1 小时前
Spring AI 2.0:RAG 文档分块大小策略选择
java·spring
XIAEE1 小时前
Java 世界历险记:从一行 System.out.println 到整个 Spring 宇宙
java·spring
XIAEE1 小时前
微前端狂想曲:我试图让三个不同门派的前端住进同一栋楼
java·spring
XIAEE1 小时前
Linux 生存指南:从"终端恐惧症"到"命令行老司机"
java·spring
XIAEE1 小时前
实战教程:手机端死活调不出摄像头?聊聊安全上下文(Secure Context)
java·spring
程序员清风1 小时前
堆与优先队列:从 Top K 到任务调度
java·数据结构·算法
方舟小孩1 小时前
开源办公软件海豹办公 1.9.15 实战 从模板异常到 Android WebView 资源回归
android
XIAEE1 小时前
ES6 与 Node.js 群侠传:那些年我们追过的语法,和那些年把我们追杀的 API
java·spring
XIAEE1 小时前
WebRTC 探险记:我是如何让两个浏览器"看对眼"的
java·spring
XIAEE1 小时前
UniApp 本地打包通关记:Android 是地狱难度,iOS 是隐藏 BOSS
java·spring