常见ANR原因分类:到底是谁在卡主线程?

做过ANR排查的人都有个共同感受:明明感觉没做什么,系统就给你弹了个“无响应”弹窗。其实说白了,ANR的本质就是主线程被阻塞了超过5秒(Activity)或10秒(BroadcastReceiver)。但主线程为什么会被堵?我根据自己的实战经验,把最常见的原因分了五大类。

嗯,我梳理了一个分类图,先看整体结构,再一个一个拆开讲。

常见ANR原因分类 主线程耗时操作 锁竞争 Binder调用阻塞 高内存压力 GC停顿 网络请求 IO读写 复杂计算 死锁 锁等待 系统服务调用 ContentProvider 频繁分配 Bitmap泄漏 STW停顿 大量对象 说明:各原因之间可能叠加出现,例如高内存压力会触发频繁GC,进而导致主线程停顿

一、主线程耗时操作——最常见,也最容易被忽视

说白了,主线程就一条路,你把这条路堵了,车(消息)就过不去。我见过太多新人在主线程里做这些事:

  • 网络请求——比如直接用HttpURLConnection在UI线程请求数据,网络又慢,5秒瞬间就过去。
  • IO读写——读写文件、数据库查询,尤其是没加索引的SQLite查询,一查就是几百毫秒。
  • 密集型计算——比如在主线程解析大JSON、做图片裁剪、循环遍历上万条数据。
⚠ 我曾经踩过的坑: 在一个图片编辑App里,我习惯在主线程直接调用Bitmap.compress()输出到文件。结果用户选了一张4K照片,压缩了将近8秒,直接报ANR。后来才意识到,IO和计算必须丢到子线程去。

怎么监控?我建议在Looper.getMainLooper()里加一个自定义Printer,打印每条消息的执行耗时。超过200ms就上报为潜在风险。

二、锁竞争——你等我也等,大家一起等

多线程环境下,锁是必需品。但如果在主线程里持有一个锁,而子线程也拿着另一个锁互等,就会死锁。即使没死锁,单是锁等待也会让主线程卡住。

// 典型死锁场景
// 线程A(主线程)
synchronized (lock1) {
    // 同时想要lock2
    synchronized (lock2) { ... }
}
// 线程B
synchronized (lock2) {
    // 同时想要lock1
    synchronized (lock1) { ... }
}

我个人的习惯是:锁的粒度要小,并且绝不让主线程去等一个长时间被占用的锁。如果真的需要同步,用ReentrantLock配合tryLock(timeout),超时就抛出警告。

💡 避坑指南: 我曾经在项目里用了一个全局的线程池,所有任务都共用同一个Object锁来同步日志写入。结果主线程偶尔也要写日志,就出现锁竞争。解决办法很简单——给日志写队列单独加一把锁,或者用Log.isLoggable提前判断。

三、Binder调用阻塞——你以为只是发了个消息?

Binder是Android进程间通信的基石。但主线程同步调用Binder方法时,如果服务端响应慢,主线程就会一直等。最常见的场景:

  • 系统服务调用:比如LocationManager.getLastKnownLocation()、PackageManager.getInstalledApplications()
  • ContentProvider查询:跨进程的ContentResolver.query(),如果Provider在另一个慢进程里,主线程就挂住了。

我记得有一次排查一个ANR,traces文件显示主线程卡在BinderProxy.transact()上,回调的却是WifiManager.getScanResults()。后来才知道,那个系统服务底层正在处理大量WiFi扫描结果,Binder线程池都满了,主线程排队等了好几分钟。

解决方案?对于自己写的Binder调用,可以设置oneway标志,让调用变成异步。但系统API没办法改,只能尽量不在主线程调,或者加超时重试机制。

四、高内存压力——一个“温柔”的ANR推手

高内存压力本身不会直接导致ANR,但它会引发一系列连锁反应。当App的Java堆接近上限时,系统会频繁触发GC。而在ART下,虽然大部分GC是并发的,但仍有STW(Stop-The-World)的暂停阶段。

我来列一下常见的内存压力场景:

场景 影响
频繁分配大量小对象(比如循环中new对象) 触发Young GC,每次几十毫秒
Bitmap对象未及时回收 占用Native内存,触发Global GC,可能会STW数百毫秒
Fragment/Activity泄漏 堆内存持续增长,系统不断尝试GC,越来越慢

我习惯在性能监控中加入Runtime.getRuntime().totalMemory()和freeMemory()的采样,当堆使用率超过85%时自动报警。另外,记得用LeakCanary排查泄漏,这是基本功了。

五、GC停顿——被遗忘的“时间黑洞”

ART下的GC虽然比Dalvik提升很多,但依然有暂停。尤其是并发标记阶段的最后一步——重标记(Remark),需要暂停所有线程去修正引用关系。这个暂停时间取决于存活对象数量和堆的大小。

实测数据: 在一次项目优化前,我拍过log。一个列表页快速滑动时,GcReason=AllocStall导致的STW时间达到120ms。虽然单次没到5秒,但连续滑动过程中,GC次数增加,用户就会明显感到掉帧,进而引发ANR(因为事件滞留太多)。

怎么应对?

  • 减少对象分配频率——用对象池、复用数据类型(如SparseArray替代HashMap<Integer, Object>)。
  • 避免在onDraw里new对象——这会导致屏幕每帧都触发GC。
  • 使用android:largeHeap="true"?我不推荐。那只是延长了GC触发时间,并没有减少GC代价,反而可能让卡顿更集中。

小结:一张表看清所有原因

我把这五类原因的关键点和排查手段汇总成表,方便你日常复习:

原因分类 典型场景 主线程直接阻塞原因 我的排查手段
主线程耗时操作 网络、IO、计算 CPU执行耗时 = 主线程忙等 TraceView / SystemTrace看函数耗时
锁竞争 死锁、锁等待超时 线程处于BLOCKED状态 看traces.txt是否有lock信息
Binder调用阻塞 系统服务、ContentProvider 线程处于WAITING on Binder 抓取Binder线程池状态
高内存压力 大Bitmap、内存泄漏 间接引起频繁GC Heap dump + MAT分析
GC停顿 大量对象分配 STW暂停所有线程 看logcat中GC日志的时间戳

嗯,基本上当你碰到一个ANR,先把traces.txt拉出来,看主线程堆栈停在哪个方法上。再对照上面五类,八九不离十。下一类经验,我再跟你聊聊怎么从源码层面读懂ANR的生成机制——不过那是后面章节的事了。