ANR 常见原因总览:你可能踩过的六个坑

ANR 的原因说来说去就那几类,但每次排查时它们常以组合拳的形式出现。我这些年见过的线上 ANR,90% 都能归到下面这六个大类里。咱们一个个拆开聊。

ANR 六大根因全景图 ANR 主线程耗时操作 网络/JSON解析/DB 锁等待 · 死锁 线程间竞争导致 Binder 调用阻塞 对端无响应 CPU 抢占 线程过多/死循环 内存不足 GC 抖动/频繁分配 I/O 阻塞 磁盘慢/文件锁 💡 实际场景中往往多个原因叠加:比如内存不足引起频繁 GC → 主线程卡住 → 发起 Binder 调用被阻塞 定位时需要结合 trace + log 分析根因,不要只盯着表面现象。

1. 主线程耗时操作

这是最常见、也最好理解的一种。主线程顾名思义只处理 UI 刷新和事件分发。你在它上面做个网络请求、解析一个几百 KB 的 JSON、或者执行一次数据库插入,只要超过 5 秒,系统就会弹出「应用无响应」。

我个人习惯把这类 ANR 称为「自找的 ANR」。因为它 100% 可以通过代码规范避免。你想想看,一个 SharedPreferences 的 apply() 本来就被设计成异步写,为什么非要用 commit() 在主线程同步写呢?

典型案例(我踩过的坑): 某次上线前灰度包报 ANR,trace 停在 JSONObject.toString() 上。一看代码,产品经理要求列表每个 item 动态拼接 JSON 上报,开发直接在 Adapter 的 onBindViewHolder 里调了 new JSONObject(...).toString()。一个 item 20ms,列表滑一屏 40 个 item,主线程直接卡了 800ms。虽然是累积时间,但再滑动几下就超 5 秒了。
  • 常见操作: 网络请求(HttpURLConnection / OkHttp 同步)、Bitmap 缩放、DB 查询、正则匹配、文件读写、大对象序列化
  • 避坑指南: 凡是耗时超过 16ms 的操作,一律扔到子线程。用 HandlerThread、AsyncTask(已废弃但仍有旧代码在用)、Coroutine、RxJava 都行。
检查技巧: 用 Profiler 抓 Method Trace,看主线程上有没有颜色深得发红的调用。颜色越深耗时越长。

2. 锁等待与死锁

多个线程争抢同一把锁,其中一个线程持有锁不释放,其他线程(包括主线程)就只能干等着。这种情况在 trace 里很容易看出:Waiting on ... held by thread <tid>。如果你看到主线程在 LockSupport.parkNanos 或 Object.wait() 上卡住,基本就是锁的问题。

我记得有一次线上 ANR,trace 显示主线程在 HashMap.get() 里等锁。我们当时一脸懵:HashMap 不是线程不安全吗?谁给它加了锁?后来发现是同事在子线程里用了 Collections.synchronizedMap() 包裹了 HashMap,主线程遍历时子线程正在扩容,两个操作互相锁住了。

特别注意: 非线程安全的容器 + 不恰当的同步包装 = 隐藏的锁等待。能用 ConcurrentHashMap 就别自己加锁。
  • 常见场景: 主线程调用 View.post(Runnable) 等待执行时被死锁、线程池满了之后主线程等待子线程结果、Handler 消息队列阻塞
  • 排查思路: 看 adb shell dumpsys dropbox data_app_anr --print 输出的线程状态。重点看主线程是 BLOCKED、WAITING 还是 TIMED_WAITING

3. Binder 调用阻塞

Android 的跨进程通信全靠 Binder。主线程发一个 Binder 请求出去(比如调用系统服务 ActivityManagerService 或 PackageManagerService),如果对方返回慢,主线程就会一直等在 BinderProxy.transact() 上。这就是典型的「进程间依赖导致的 ANR」。

说白了,你调用系统 API 时,系统服务可能正在忙。比如 getPackageManager().getInstalledApplications() 可能因为包管理服务在扫描新安装的 App 而卡住几十秒。

我踩过的例子: 某个 App 启动时读取已安装应用列表,用了 PackageManager.queryIntentActivities()。用户装了 500+ 个应用,这个调用耗时 3-5 秒。主线程等待期间如果来个点击事件,ANR 就触发了。
  • 典型 Binder 调用: startActivity()、bindService()、ContentResolver.query()、LocationManager.requestLocationUpdates()
  • 预防方法: 将 Binder 调用放到子线程,或者使用异步版本的 API(例如 ContentResolver.query() 用 AsyncQueryHandler)

4. CPU 抢占

当设备的 CPU 被其他线程或进程吃满时,主线程即使没做什么耗时操作,也得不到调度时间片。这种情况在低端机上尤为常见。

你想想看,一个 App 里起了十几个线程同时在计算、压缩图片、或者跑死循环,CPU 使用率冲到 90%+,主线程每次只能跑几毫秒就被抢走,5 秒内完不成一个消息处理,ANR 就来了。

我怎么发现这种 ANR 的? 看 trace 文件头部有 CPU usage 信息。如果系统 CPU 使用率超过 80%,且主线程的状态是 RUNNABLE 但长时间没推进,那基本就是 CPU 被抢了。
  • 常见原因: 第三方 SDK 后台循环拉取、WebView 多进程渲染、动画帧率过高、垃圾回收(GC)频繁
  • 解决方案: 控制线程池大小(建议最多 2*CPU 核数)、使用 Thread.setPriority(THREAD_PRIORITY_BACKGROUND) 降低后台线程优先级

5. 内存不足

内存不足会导致系统频繁触发 GC 甚至 LMK(Low Memory Killer)。每一次 GC 都会暂停所有线程(包括主线程),尤其是 Full GC 可能停顿几十到几百毫秒。如果短时间内反复 GC,累积起来就超过了 ANR 阈值。

我记得有一次处理线上 ANR,trace 里主线程没有在任何操作上卡住,但反复出现 GC_FOR_ALLOC 和 GC_CONCURRENT。一看堆转储,Bitmap 对象占了 80% 内存,循环加载大图导致每秒触发 3-4 次 GC。

警告: 别以为 OOM 才是问题。GC 导致的 ANR 比 OOM 更隐蔽,它不一定会 crash,但会让用户体验极差。
  • 排查技巧: adb shell dumpsys meminfo 你的包名 看 Pss 大小;adb shell dumpsys ginfo(需 root)查看 GC 频率
  • 预防: 使用 Glide 而非直接加载 Bitmap、避免在 onDraw 中创建对象、使用对象池复用临时对象

6. I/O 阻塞

主线程读写文件、访问数据库、或者调用 SharedPreferences.getBoolean() 时,如果底层磁盘 I/O 很慢(比如 UFS 老化、存储空间满、文件被加锁),就会卡住主线程。

很多开发者觉得 SharedPreferences 是轻量级的,没什么问题。但实际上一旦 SP 文件过大(比如超过 100KB),它的 load() 过程是在主线程同步加载的。我记得有次用户反馈「进入设置页就 ANR」,排查发现 SP 文件被写入了 2MB 的 JSON 字符串。

I/O 类型潜在风险建议
SharedPreferences 读文件大时同步 I/O改用 DataStore 或 MMKV
SQLite 查询复杂 JOIN 或未加索引子线程查询 + Room 配合
FileOutputStream主线程写日志使用独立线程写文件
文件锁多进程写入同一文件避免多进程共享文件
曾经的血泪教训: 某 SDK 内置了日志收集功能,每次启动时在主线程删除过期日志文件。一个 File.delete() 本应很快,但用户的存储空间快满了,系统 I/O 调度变慢,删除操作花了 3 秒。加上其他初始化耗时,刚好超过 5 秒。

给新手的一个小总结

遇到 ANR,先拿 trace 看主线程停在哪个方法上:

  1. 停在应用自己的代码里 → 看是不是耗时操作或锁
  2. 停在 Binder 调用上 → 看系统服务是否异常
  3. 停在 JNI 或 native 调用 → 关注内存和 GC
  4. 无明确停点但 CPU 高 → CPU 抢占或 GC

学会这六类原因,你就能在拿到 traces.txt 后快速缩小范围。后面几章我会带着你一步步看 trace 里的具体字段,嗯,先啃下这个基础再说。