阻塞根源:耗时操作、锁竞争、Binder调用阻塞、死循环
好,咱们直接切入正题。主线程到底是怎么被拖垮的?我排查过上百次ANR,总结下来就四个大类。你只要记住这四类,以后遇到卡顿问题心里就能画出一张“嫌疑犯地图”。
1. 耗时操作:IO、网络、数据库、位图解码
这是ANR的头号嫌疑犯。说白了,就是主线程里干了不该它干的体力活。我个人习惯,任何超过10ms的操作都会让我警觉。
关键指标:ANR阈值是5秒(前台)或10秒(后台),但主线程只要阻塞1秒以上,用户就能感知到卡顿。所以我们要把“毫秒级”的阻塞习惯扼杀在摇篮里。
IO操作:SharedPreferences的commit()是同步写磁盘,我见过太多团队在主线程commit导致ANR。建议用apply()异步提交,或者迁移到DataStore/MMKV。
网络请求:这个基本是常识了,但总有人为了“省事”在回调里直接访问网络。记住:所有网络库(OkHttp/Retrofit)的回调默认还是在主线程,需要手动切线程。
数据库查询:SQLite的查询快则几毫秒,慢则几百毫秒——尤其是全表扫描或复杂JOIN。我在一个项目中遇到过,一张表20万条记录,在主线程做DELETE FROM,直接卡死了3秒多。
位图解码:BitmapFactory.decodeFile()如果不采样压缩,一张几MB的图片解码出来可能占几十MB内存,耗时50~200ms。更坑的是,它不走CPU而是走GPU管线,主线程根本没法分时处理。
// 错误示范:主线程直接解码
Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/big_image.jpg");
// 正确做法:子线程解码,主线程只负责刷新UI
new Thread(() -> {
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = 4; // 采样压缩
Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/big_image.jpg", opts);
runOnUiThread(() -> imageView.setImageBitmap(bitmap));
}).start();
💡 我通常会用一个“主线程时间橡皮擦”原则:任何不属于UI展示、动画计算、触摸反馈的代码,都不应该跑到主线程上。画个圈圈:只留onDraw、onClick、onTouch、onLayout、invalidate。其他?一律扔后台。
2. 锁竞争:主线程等锁等到天荒地老
你想想看,两个线程抢一把锁,本来很快就能结束。但如果子线程占着锁去做网络请求,主线程调用同一个同步方法时,就得一直等——直到ANR。
| 锁类型 | 常见坑点 | 建议 |
|---|---|---|
| synchronized方法 | 子线程长时间持有锁 | 缩小同步块,只锁关键代码 |
| ReentrantLock | 忘记finally中unlock | 用try-finally保证释放 |
| 锁内做IO | 同步块内调用网络/磁盘 | 禁止!锁外准备好数据 |
我曾经排查过一个案例:主线程偶尔卡死3秒,定位到是某个单例对象的getInstance()方法加了synchronized,而子线程在初始化时做了数据库建表操作。主线程一调用getInstance(),直接block住等子线程建表完成。解决办法:让单例提前初始化,或者用双重检查锁定+volatile。
⚠️ 特别警惕:锁内嵌套锁(死锁风险)。主线程锁A,子线程锁B,然后互相等待——这种死锁通常不会触发ANR提示,而是直接冻屏,用户只能强制杀进程。
3. Binder调用阻塞:SurfaceFlinger、ActivityManager太忙了
Binder是Android进程间通信的命脉,但主线程发起Binder调用时,如果目标进程(比如system_server)卡住,主线程就会同步阻塞。我印象最深的是某次使用startActivityForResult时,ActivityManagerService里有个锁竞争,导致启动耗时超过5秒,直接弹ANR对话框。
常见的Binder阻塞场景:
- startActivity():AMS需要遍历所有已注册的Activity,如果栈深度大或系统卡顿,可能超过100ms
- bindService():等待Service创建或返回IBinder
- ContentResolver.query():跨进程访问ContentProvider,如果Provider在子线程还未初始化完毕
- 系统服务调用:如TelephonyManager.getDeviceId()需要RIL层响应,可能卡在主线程
我建议:任何与系统服务交互的调用,如果不确定耗时,先扔到子线程试试。如果无法避免(比如onResume里需要读取系统状态),至少用StrictMode打标记,提前暴露问题。
4. 死循环:永远跑不出来的循环
死循环是所有阻塞中最“不讲理”的一种——它不依赖外部资源,完全是自己把自己锁死的。常见的死循环模式:
- while(true) 没有break条件:通常是因为忘记写计数自增,或者条件永远为true
- for循环索引不更新:for (int i=0; i<n; ) 这种低级错误
- 递归无终止条件:比如计算斐波那契数列时没写base case
- Handler不断post自身:在循环里调用handler.post(this)且没有移除条件
// 一个隐蔽的死循环案例
Handler handler = new Handler(Looper.getMainLooper());
handler.post(new Runnable() {
@Override
public void run() {
// 处理一些事...
// 忘记移除自身?那就无限触发
handler.post(this); // 每次都post,形成一个无限链
}
});
死循环诊断技巧:如果看到log持续输出同一行,或者CPU占用率飙到100%但没ANR(因为一直在计算,没触发超时?),基本就是死循环。用Systrace抓取主线程任务,会看到无限长的“Handler: ... ”片段。
好了,四个阻塞根源已经讲完。下次你看到ANR日志,先看看主线程在干嘛:是在执行SQLite查询(耗时操作)?是在等一个synchronized方法(锁竞争)?是在等系统服务返回(Binder阻塞)?还是压根没停过(死循环)?心里有了归类,排查方向就明确了。记住,主线程很脆弱,咱们得保护它——少干活、别吵架、不求人、别死机。就这么简单。