第2章 主线程阻塞的典型根因

2.1 现象识别:主线程阻塞的“六种信号”

主线程阻塞不会凭空报警,而是通过一系列可观察的“信号”暴露出来。开发团队应建立统一的阻塞识别清单,避免将ANR归因于“随机事件”。以下表格列出了最常见的阻塞现象及其与根因的关联度:

现象用户感知数据指标关联根因
应用启动白屏 > 3s点击图标后长时间无响应Displayed时间 > 5s耗时操作(磁盘/网络)
列表滑动掉帧、卡顿手指滑动时界面跳跃帧率 < 30fps,掉帧率 > 5%耗时操作、内存抖动
点击事件隔数秒才生效按键无反馈,然后一次性执行Touch事件分发延迟 > 200msBinder调用等待、锁竞争
通知栏拉出后无动画下拉通知栏空白或卡死SystemServer通信延迟Binder调用、锁竞争
“应用无响应”弹窗系统弹出ANR对话框ANR日志中"main" tid 阻塞任意一种根因累积
冷启动时闪一下黑屏白屏后短暂黑屏onCreate执行时间 > 1s内存抖动(频繁GC)
判断规则:如果出现“应用无响应”弹窗,首先检查/data/anr/traces.txt中主线程状态是否为BLOCKED / WAITING;若主线程TID长时间处于MONITOR或WAITING on,则优先排查锁竞争和Binder调用。

2.2 数据采集:定位根因的“三大情报源”

精准定位根因需要同时依赖三种数据源,不可偏废:

  1. 系统ANR Trace(/data/anr/traces.txt或dumpsys dropbox --print data_app_anr)—— 抓取主线程堆栈、锁持有状态。
  2. Systrace / Perfetto —— 观察主线程CPU耗时、SurfaceFlinger帧渲染耗时、Binder跨进程次数。
  3. StrictMode 日志 —— 运行时自动标记主线程上的磁盘I/O、网络调用、未关闭资源。
# 快速采样 Trace 中主线程状态(Linux Shell)
grep "main" /data/anr/traces.txt | head -10
# 输出示例:
"main" prio=5 tid=1 Blocked
  at com.example.MainActivity.onCreate(MainActivity.java:45)
  - waiting to lock <0x0f3c4d28> (a java.lang.Object)
  at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:...
避坑提醒:不要只依赖Blocked关键字。有时候主线程是Runnable但GC频繁导致类似阻塞效果,此时需要配合mmap或mallinfo判断内存抖动。

2.3 原因定位:主线程阻塞根因故障树

以下SVG故障树将四大根因及其常见子因结构化展示,帮助你从现象快速定位到具体代码域:

主线程阻塞根因故障树 主线程阻塞 耗时操作 锁竞争 Binder调用 内存抖动 磁盘读写 网络请求 复杂计算 类锁/实例锁 线程间死锁 锁粒度过大 服务端同步 IPC频率过高 Binder池耗尽 循环创建对象 String拼接 AutoBoxing 排查顺序:先看耗时操作(最简单),再看锁竞争和Binder调用,最后考虑内存抖动(隐蔽性最高)

2.4 四大根因详解与修复动作

2.4.1 耗时操作

子类典型代码示例定位手段修复动作
磁盘I/O
FileInputStream fis = new FileInputStream(path);
byte[] buf = new byte[8192];
while (fis.read(buf) != -1) { ... }
StrictMode detectDiskRead/Write异步Executor + 缓存策略(如LruDiskCache)
网络请求
URLConnection conn = new URL(url).openConnection();
conn.setConnectTimeout(5000);
InputStream is = conn.getInputStream();
网络库回调非主线程Retrofit/OkHttp 强制异步,或使用协程/HandlerThread
复杂计算
for (int i = 0; i < 100000; i++) { 
    result += Math.sin(Math.sqrt(i));
}
Systrace 显示CPU占用连续>16ms分片计算/WorkManager/RenderScript
判断规则:在StrictMode日志中搜索diskRead或diskWrite,若发现主线程上单次I/O耗时超过10ms,则判定为阻塞风险。配合Log.d打印耗时差异点。

2.4.2 锁竞争

现象:Trace中主线程显示 waiting to lock 或 blocked on monitor。典型场景:主线程与后台线程共享同一把锁,或者系统锁(如WindowManager锁)被长时间持有。

// 引发ANR的锁竞争示例
public class DataHolder {
    private static final Object mLock = new Object();
    private List<String> data = new ArrayList<>();

    public void updateOnMainThread() {
        synchronized (mLock) {
            // 模拟耗时
            Thread.sleep(200); // 实际可能是数据库操作
        }
    }

    public void backgroundWrite() {
        new Thread(() -> {
            synchronized (mLock) {
                // 长时间持有锁
                writeToDisk(data);
            }
        }).start();
    }
}
锁类型识别特征修复策略
类锁(static synchronized)Trace中目标方法前有waiting to lock避免主线程调用static同步方法;改用ReentrantLock+超时
实例锁主线程堆栈显示waiting for <0x...> (a java.lang.Object)将锁对象替换为final Object mLock,并确保锁内代码不超过10ms
系统服务锁主线程等待android.app.ActivityManagerService或android.view.WindowManager检查是否有过多AMS调用,合并请求,使用Handler.postDelayed批量回调

2.4.3 Binder调用

Binder IPC 在 64 位设备上单次调用约 30-100μs,但如果频率过高(例如每秒1000次)或服务端响应慢,就会累积阻塞。典型的案例:getContentResolver().query()、sendBroadcast()、PackageManager.queryIntentActivities()。

判断规则:使用adb shell dumpsys binder_dump或Perfetto的Binder Track统计主线程发起IPC的次数。如果每秒超过50次,即视为高频Binder调用,需要缓存或批量。
// 错误:主线程高频调用
for (String pkg : packageList) {
    PackageInfo info = getPackageManager().getPackageInfo(pkg, 0);
}

// 修复:将包信息缓存到Map中,或使用PackageManager.queryInstalledPackages()一次获得
Map<String, PackageInfo> cache = new HashMap<>();
PackageManager pm = getPackageManager();
List<PackageInfo> installed = pm.getInstalledPackages(0);
for (PackageInfo info : installed) {
    cache.put(info.packageName, info);
}

2.4.4 内存抖动

内存抖动指短时间内大量分配对象,触发CMS/Concurrent GC,导致主线程被暂停(STW或并发暂停)。虽然GC默认在后台线程执行,但并发标记和引用处理会短暂挂起所有线程,造成卡顿甚至ANR。

常见代码模式GC影响修复建议
循环内创建String
for (int i=0; i<1000; i++) str += i;
每次拼接产生StringBuilder临时对象,年轻代快速填满使用StringBuilder或StringBuffer,预分配大小
AutoBoxing
List<Integer> list = new ArrayList<>(); list.add(t);
每次add产生Integer对象(堆外分配)使用原始类型数组int[]或SparseIntArray
onDraw中new对象
Paint paint = new Paint(); canvas.drawCircle(x,y,10,paint);
每帧创建对象,导致频繁young GC将paint声明为成员变量复用
避坑提醒:Android 12+引入了targetHeapSize和分代GC优化,但内存抖动仍会导致后台GC线程占用CPU,影响主线程响应。使用hprof或Allocation Tracker观察对象分配频率。

2.5 复测:验证修复是否有效

  1. 压力测试:使用Monkey+事件注入,模拟高强度点击;运行adb shell monkey -p com.yourapp --throttle 100 -v 5000,观察是否出现ANR。
  2. Trace验证:修复后重新抓取systrace,确认主线程连续执行区间不超过16ms,无等待锁或IPC突发。
  3. 性能指标对比:使用dumpsys gfxinfo观察修复前后帧率平滑度,确保掉帧率 < 2%。
# 比对修复前后ANR日志中主线程阻塞点
diff -u /data/anr/traces_old.txt /data/anr/traces_new.txt | grep "main"
# 如果不再出现Blocked或Waiting,则修复生效

2.6 预防:构建工程师“阻塞防火墙”

  • 编码规范:主线程禁止直接执行网络、磁盘I/O、复杂计算;使用Lint规则标记@WorkerThread注解。
  • 自动化检测:CI/CD流水线中集成StrictMode + ADB Shell dumpsys,每次打release包前运行./gradlew testStrictModeDebug。
  • 监控报警:线上埋点捕获“应用无响应”事件,上报主线程堆栈以及近期内存分配曲线。常用框架:火萤、Bugly的ANR监控。