ANR 常见原因总览:你可能踩过的六个坑
ANR 的原因说来说去就那几类,但每次排查时它们常以组合拳的形式出现。我这些年见过的线上 ANR,90% 都能归到下面这六个大类里。咱们一个个拆开聊。
1. 主线程耗时操作
这是最常见、也最好理解的一种。主线程顾名思义只处理 UI 刷新和事件分发。你在它上面做个网络请求、解析一个几百 KB 的 JSON、或者执行一次数据库插入,只要超过 5 秒,系统就会弹出「应用无响应」。
我个人习惯把这类 ANR 称为「自找的 ANR」。因为它 100% 可以通过代码规范避免。你想想看,一个 SharedPreferences 的 apply() 本来就被设计成异步写,为什么非要用 commit() 在主线程同步写呢?
JSONObject.toString() 上。一看代码,产品经理要求列表每个 item 动态拼接 JSON 上报,开发直接在 Adapter 的 onBindViewHolder 里调了 new JSONObject(...).toString()。一个 item 20ms,列表滑一屏 40 个 item,主线程直接卡了 800ms。虽然是累积时间,但再滑动几下就超 5 秒了。
- 常见操作: 网络请求(HttpURLConnection / OkHttp 同步)、Bitmap 缩放、DB 查询、正则匹配、文件读写、大对象序列化
- 避坑指南: 凡是耗时超过 16ms 的操作,一律扔到子线程。用
HandlerThread、AsyncTask(已废弃但仍有旧代码在用)、Coroutine、RxJava都行。
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 而卡住几十秒。
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 就来了。
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。
- 排查技巧:
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 | 主线程写日志 | 使用独立线程写文件 |
| 文件锁 | 多进程写入同一文件 | 避免多进程共享文件 |
File.delete() 本应很快,但用户的存储空间快满了,系统 I/O 调度变慢,删除操作花了 3 秒。加上其他初始化耗时,刚好超过 5 秒。
给新手的一个小总结
遇到 ANR,先拿 trace 看主线程停在哪个方法上:
- 停在应用自己的代码里 → 看是不是耗时操作或锁
- 停在 Binder 调用上 → 看系统服务是否异常
- 停在
JNI或native调用 → 关注内存和 GC - 无明确停点但 CPU 高 → CPU 抢占或 GC
学会这六类原因,你就能在拿到 traces.txt 后快速缩小范围。后面几章我会带着你一步步看 trace 里的具体字段,嗯,先啃下这个基础再说。