主线程阻塞的“七宗罪”:耗时计算、同步锁、IO与网络请求
说实话,我最早排查ANR的时候,十个里面有八个是主线程干了不该干的事。你想想看,Android的主线程就像一条单车道,所有UI更新、事件分发、触摸响应都挤在这条路上。一旦有车抛锚——也就是耗时操作——整条路就堵死了。系统很直接:5秒没反应,ANR弹窗就出来了。
我曾经接手过一个播放器项目,用户反馈频繁ANR。一看日志,主线程里直接写了文件下载,还加了同步锁。嗯,那场面简直灾难。下面我就把最常见的几种阻塞原因掰开揉碎讲清楚。
一、耗时计算——CPU密集型任务
大数据量的排序、复杂的数学运算、图片的像素级处理,这些如果直接扔到主线程,就是典型的CPU饥饿。主线程忙不过来,UI线程就得不到执行时间片。
// 主线程中做百万次运算
public void onClick(View v) {
long result = 0;
for (int i = 0; i < 1000000; i++) {
result += expensiveCalculation(i);
}
textView.setText(String.valueOf(result));
}
为什么要避免?因为主线程每多花1ms,用户就能感知到卡顿。超过5s,直接ANR。我个人的习惯是:任何超过16ms的任务,都考虑移到后台线程。你可以用AsyncTask、HandlerThread或者现代一点的协程。
BitmapFactory.decodeStream配合ExecutorService,问题解决。别忘了,子线程不能直接更新UI,记得切回主线程。
二、同步锁——死锁与线程竞争
同步锁本身不是坏事,但如果你在主线程拿了一把锁,而另一个线程也等着这把锁,那就坏了。更糟糕的是死锁:线程A持有锁1等锁2,线程B持有锁2等锁1,主线程在其中任何一个里都会永远卡住。
// 主线程持锁等待子线程释放的场景
public class MyActivity extends Activity {
private final Object lock = new Object();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
new Thread(() -> {
synchronized (lock) {
try { Thread.sleep(6000); } catch (Exception e) {}
}
}).start();
// 主线程尝试获取同一把锁
synchronized (lock) {
// 这里至少要等6秒,如果恰好卡在UI刷新前,ANR就来了
}
}
}
我个人习惯是:主线程里不要加锁等待。如果必须同步,考虑用ReentrantLock.tryLock(timeout)加超时,或者用Handler把同步逻辑转移到后台。
ViewRootImpl内部也有全局锁,如果你自己用锁不小心,很容易触发死锁。我在项目中遇到过一次:自定义View的onDraw里调用了Lock,而另一个线程持锁后又调用了invalidate()——完美死锁,ANR频发。解法是把所有UI更新都放到主线程消息队列里串行执行。
三、IO操作——文件读写与SharedPreferences
磁盘IO的速度比内存慢几个数量级。主线程读一个几十MB的文件,或者频繁调用SharedPreferences.commit()(同步写入),都会造成长时间阻塞。
| 操作 | 典型耗时 | ANR风险 |
|---|---|---|
| 读取100KB文件 | 10~50ms | 低(但多次累积危险) |
| SharedPreferences.commit() | 50~200ms | 中 |
| 写10MB数据库文件 | 200ms~2s | 高 |
你想想看,用户手指滑动列表,主线程却在等磁盘写完成,界面当然会掉帧。更严重的是,如果磁盘io被其他进程占用(比如系统自动备份),等待时间还可能更长。
我的建议是:使用SharedPreferences.edit().apply()替代commit()(异步写),文件读写通过AsyncTask或RxJava在后台完成。对于数据库,可以使用Room的异步查询@Query配合LiveData或Flow。
四、网络请求——堪称ANR头号凶手
我在早期做开发时,踩过最大的坑就是直接在Activity的onCreate里执行网络请求。那时候还没有OkHttp,用的是HttpURLConnection,默认就是同步的。一个慢接口耗时8秒,ANR率直接飙升到30%。
// 主线程做网络请求——禁止
URL url = new URL("https://slow-api.example.com/data");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000); // 10秒超时
// 实际阻塞主线程至少10秒
InputStream in = conn.getInputStream();
现在虽然网络库都有异步回调,但如果你不小心在Callback里用了CountDownLatch.await(),或者通过Thread.join()等待网络结果,主线程照样被拖死。
Retrofit+Kotlin协程的suspend函数,或者用OkHttp的enqueue()异步。
五、数据库操作——Room的隐式陷阱
Room默认在主线程执行读写吗?不,Room是禁止在主线程进行数据库操作的(默认会抛IllegalStateException)。但很多人为了“图省事”,调用allowMainThreadQueries()——这简直就是开启ANR的作弊码。
即使没有调用这个方法,如果你在回调或Listener里做了复杂的联表查询,一旦数据量大,查询时间很容易超过1秒。而@Transaction注解的事务如果包含多个@Query,也可能因为锁竞争导致阻塞。
RecyclerView的onBindViewHolder里调用了一个dao.getCount(),当时没注意这个查询是同步的(因为用的@Query直接返回int),结果每次滑动列表都要等待几十毫秒。改成LiveData异步后,流畅多了。
一张图看懂主线程阻塞的全貌
下面这张图梳理了所有常见阻塞原因,以及它们如何一步步走向ANR。你可以拿来当排查清单用。
六、实际排查步骤与工具链
当你拿到一个ANR日志时,别慌。我个人习惯按下面几步来做:
- 看主线程堆栈:从
/data/anr/traces.txt里找到主线程的状态,是BLOCKED、WAITING还是RUNNING。如果看到java.net.SocketInputStream.read,那就是网络阻塞。 - 检查锁持有:如果看到
synchronized或LockSupport.park,看是哪个对象的monitor持有者。 - 定位耗时操作:用CPU Profiler或
SimplePerf捕捉主线程执行时间最长的函数。 - 分析Room查询:在
Room.databaseBuilder().enableQueryCallback()里打印查询耗时,或者用Database Inspector查看慢SQL。
Looper.loop()里等待——这可能误导你以为主线程没阻塞。实际上它只是刚好空闲,真正阻塞的点可能在几分钟前,而堆栈只记录了当前状态。所以一定要结合CPU和IO的历史数据来诊断。
七、总结一个快速决策表
| 阻塞类型 | 判断特征 | 最佳实践 |
|---|---|---|
| 耗时计算 | CPU占用100%,主线程堆栈为run或compute | 使用AsyncTask或协程 |
| 同步锁 | 主线程BLOCKED,显示- waiting to lock <0x...> | 避免主线程加锁;使用tryLock(timeout) |
| IO操作 | 主线程RUNNING但长时间卡在FileInputStream.read | 使用ReadWriteLock+后台线程;或用NIO |
| 网络请求 | 主线程卡在SocketInputStream.read,超时时间长 | 使用OkHttp异步或协程 |
| Room数据库 | 主线程堆栈有RoomDatabase.query或allowMainThreadQueries | 使用@Query+LiveData/Flow;禁止allowMainThreadQueries |
最后说一句,排查ANR其实就是在做减法——把主线程上不该有的东西一件一件搬走。越早发现,越容易修复。如果你在项目里碰到类似的情况,不妨从这张图的几个分支开始查起,十有八九能找到真凶。