Android 启动优化(五):线程、GC 与 IO 为什么会拖慢启动?

序、众所周知,每一次开始写这种框架系类的文章的时候,就是我在找工作了,有闲暇时间可以写点东西。哈哈哈哈,26年找工作,简直就是。。。。。

重要的事情说三遍

有岗位的请 CALL 我,谢谢大家了。。。。

有岗位的请 CALL 我,谢谢大家了。。。。

有岗位的请 CALL 我,谢谢大家了。。。。

上一篇我们讲了:

Application 与第三方 SDK 初始化优化。

核心原则是:

启动阶段只做真正必须做的事情。

但即使我们已经把很多 SDK 做了延迟初始化,启动依然可能不够快。

这时候通常还要继续看几个非常常见的问题:

  • 线程太多

  • CPU 竞争

  • 主线程等待

  • GC

  • 文件 IO

  • SharedPreferences

  • 数据库

  • Binder

这些问题有一个共同点:

它们不一定表现为"某个方法特别慢",但却可能让整个启动链路被拖长。

这也是为什么启动优化不能只看方法耗时。


一、线程越多,启动一定越快吗?

很多人做启动优化时,会想到一个很自然的方案:

主线程慢,那就把任务全部放到子线程。

例如原来 Application 中顺序执行:

任务 A

任务 B

任务 C

任务 D

于是优化成:

线程 1 执行 A

线程 2 执行 B

线程 3 执行 C

线程 4 执行 D

看起来好像所有任务都并行了。

但真实情况不一定更快。

因为:

CPU 核心数量是有限的。

假设设备只有几个核心,而启动瞬间创建了十几个线程。

这些线程都会争抢 CPU。

包括:

Main Thread。

最终就可能出现:

后台线程很多,

但主线程反而拿不到 CPU。


二、Perfetto 中的 Runnable 为什么重要?

在 Perfetto 中,我们前面已经认识了几个状态:

  • Running

  • Runnable

  • Waiting

  • Sleeping

其中:

Runnable

在启动优化里非常值得关注。

Runnable 表示:

线程已经准备执行,但是暂时没有获得 CPU。

也就是说:

它不是没工作。

而是:

想干活,但是抢不到 CPU。

如果启动阶段看到 Main Thread 长时间处于 Runnable,

那就要警惕:

当前是不是存在严重的 CPU 竞争?


三、Running 和 Runnable 有什么区别?

可以这样理解。

Running

表示:

当前线程正在 CPU 上真正执行代码。

例如:

Main Thread 连续 Running 50ms。

说明:

主线程这 50ms 基本一直在计算。

可能是:

  • JSON 解析

  • View Inflate

  • 类加载

  • 业务计算

  • 大量对象创建


Runnable

表示:

线程已经准备好了,但是还在排队等 CPU。

比如:

主线程想执行首帧绘制。

但当前其他几个后台线程正在占用 CPU。

主线程就可能:

Runnable

等待调度

Running

所以:

Runnable 时间长,不代表主线程代码本身复杂。

它可能只是:

CPU 竞争太严重。


四、为什么"全部异步"可能适得其反?

假设 Application 中有:

10 个初始化任务。

全部放线程池。

启动瞬间:

10 个任务一起执行。

这些任务可能同时进行:

  • JSON 解析

  • 数据计算

  • Class Loading

  • 数据库操作

  • SDK 初始化

CPU 很快被占满。

这时候首页 Activity 需要:

  • Inflate

  • Measure

  • Layout

  • Draw

但 Main Thread 也需要 CPU。

最终:

后台任务把前台关键任务挤慢了。

所以更合理的启动优化思路不是:

尽可能多并发。

而是:

保证启动关键路径优先。


五、线程优化应该怎么做?

一般可以从几个方向考虑。

1. 控制启动阶段线程数量

不要每个 SDK 自己创建一个线程。

尽量统一使用线程池。


2. 控制并发度

不是所有任务都需要一起执行。

例如:

高优先级任务先执行。

低优先级任务首帧之后再执行。


3. 减少线程创建

线程本身也有创建和调度成本。

大量创建短生命周期线程通常没有必要。


4. 避免主线程等待子线程

这是一个很常见的问题。

例如:

主线程:

提交一个后台任务。

然后马上:

等待后台任务完成。

这种情况虽然用了线程池,

本质上仍然是:

同步执行。

甚至还增加了线程切换成本。


六、主线程 Waiting 到底意味着什么?

Perfetto 中如果发现:

Main Thread:

Waiting

时间很长。

说明:

主线程当前没有在执行,而是在等待某个条件。

常见原因包括:

  • 等锁

  • 等其他线程

  • 等 Binder

  • 等 Condition

  • 等 IO

  • 等某个同步任务完成

这里最重要的问题不是:

Waiting 本身是不是慢。

而是:

主线程在等谁?


七、锁竞争为什么会拖慢启动?

假设启动时两个线程都访问一个共享对象。

线程 A:

持有锁。

Main Thread:

需要这个锁。

Main Thread 被阻塞。

如果线程 A 又执行了比较重的任务,

那么主线程就会一直等。

例如一些全局组件:

  • 单例初始化

  • 数据库

  • 配置中心

  • 日志组件

  • 缓存系统

内部都可能存在同步锁。

所以启动阶段如果看到:

主线程长时间 Waiting,

就要检查是否存在:

锁竞争。


八、Binder 等待也是启动耗时的一部分

Android 很多系统能力不是直接在应用进程内部完成的。

而是需要通过:

Binder

调用系统服务。

例如:

  • PackageManager

  • ActivityManager

  • WindowManager

  • Location

  • 设备信息

  • 系统设置

如果启动阶段频繁请求这些系统服务,

可能出现:

App Main Thread

Binder 请求

系统进程处理

返回结果

Main Thread 继续执行。

如果系统服务繁忙,

或者 Binder 调用本身较多,

主线程就会等待。

因此做启动优化时,可以问一句:

这些系统服务调用真的需要在首屏之前完成吗?

如果不需要,

就应该考虑延迟。


九、GC 为什么会影响启动?

App 启动过程中通常会创建大量对象。

比如:

  • Application

  • Activity

  • Fragment

  • View

  • Drawable

  • Bitmap

  • JSON 对象

  • SDK 对象

如果启动阶段短时间创建大量对象,

堆内存压力就会上升。

当系统认为需要回收内存时,

就可能触发:

GC。

GC 会占用 CPU,

也可能让应用线程暂停一段时间。

如果 GC 恰好发生在首屏之前,

就可能增加 TTID。


十、启动阶段为什么容易产生大量对象?

常见原因包括:

大量 View Inflate

每一个 View 都是对象。

首页 View 越多,

创建对象越多。


JSON 解析

大 JSON 往往会产生大量临时对象。


大量集合

例如:

List、Map、临时 Bean。


SDK 集中初始化

每个 SDK 初始化都可能创建自己的对象体系。


Bitmap

图片解码会产生较大的内存压力。

所以启动优化不只是:

代码执行了多久。

还要考虑:

启动过程中制造了多少垃圾对象。


十一、看到 GC 就一定要优化吗?

不一定。

GC 是 Android 正常内存管理机制。

偶尔出现一次 GC:

不代表一定存在严重问题。

真正值得关注的是:

  • 首屏之前频繁 GC

  • 单次 GC 时间较长

  • GC 与启动卡顿时间高度重合

  • 优化对象创建后启动明显改善

所以还是要结合:

Perfetto 时间线

分析。

不要简单:

Trace 里有 GC,所以启动慢就是 GC。


十二、怎么减少启动阶段 GC?

可以从几个方向考虑。

1. 减少不必要对象创建

尤其是首屏之前。


2. 避免启动时解析大数据

如果不是首屏必须的数据,

可以延迟。


3. 减少复杂布局

View 越多,

对象越多。


4. 避免大量临时集合

例如只为了初始化使用一次的大 List、Map。


5. 图片按需加载

不要 App 一启动就解码大量 Bitmap。

目标仍然是:

让启动关键路径尽可能轻。


十三、IO 为什么是启动优化重点?

磁盘 IO 和 CPU 计算不一样。

CPU 计算一般比较稳定。

而磁盘 IO 可能受到:

  • 文件大小

  • 文件系统

  • 缓存

  • 设备存储性能

  • 系统负载

影响。

因此 IO 不仅可能慢,

而且:

波动也比较大。

这也是为什么同一个 App 启动多次,

某些 Iteration 会明显慢一些。


十四、启动阶段常见的 IO 有哪些?

最常见的包括:

  • 文件读取

  • SharedPreferences

  • SQLite

  • 数据库初始化

  • 配置文件

  • 缓存文件

  • 日志文件

  • JSON 文件

  • 资源加载

如果这些操作直接发生在 Main Thread,

就可能阻塞:

首帧绘制。


十五、SharedPreferences 为什么也要注意?

很多项目启动时会读取:

  • 登录状态

  • 用户配置

  • AB 实验

  • App 设置

  • 上次启动信息

这些数据往往放在:

SharedPreferences。

SharedPreferences 用起来很方便,

但它底层仍然涉及:

  • 文件读取

  • XML 解析

  • 内存对象创建

如果配置文件越来越大,

启动阶段大量读取,

也可能增加耗时。

所以一个原则是:

不要因为 SharedPreferences API 很简单,就认为它没有性能成本。


十六、数据库为什么容易拖慢启动?

数据库操作也是启动阶段常见问题。

例如:

App 一启动就:

  • 打开数据库

  • 创建连接

  • 查询用户数据

  • 查询首页缓存

  • 数据迁移

  • 检查表结构

如果这些操作发生在首屏之前,

就可能影响 TTID。

尤其是:

数据库升级 / Migration

更要小心。

因为某个版本首次启动时,

可能出现一次明显的启动变慢。


十七、数据库优化的核心思路

还是先问:

这条数据是不是首屏必须?

如果不是:

延迟查询。

如果必须:

考虑:

  • 减少查询量

  • 优化 SQL

  • 使用索引

  • 减少主线程数据库访问

  • 首屏只读取必要字段

  • 避免启动阶段做复杂 Migration

不要一上来就把整个数据库加载到内存。


十八、文件读取也要注意"大文件"

例如启动时读取:

一个 10KB 配置文件。

和:

一个 10MB JSON 文件。

完全不是一个概念。

如果启动阶段加载:

  • 大 JSON

  • 大文本

  • 大图片

  • 大模型文件

  • 大缓存

一定要非常谨慎。

通常更合理的方案是:

首屏先读取最小必要数据。

其他内容:

首帧之后再加载。


十九、IO 放后台线程就一定没问题吗?

也不一定。

假设你把 5 个大文件读取任务全部放到后台线程。

虽然 Main Thread 不直接执行 IO,

但这些任务仍然可能:

  • 占用 IO 带宽

  • 消耗 CPU 解析数据

  • 创建大量对象

  • 与主线程竞争资源

所以真正合理的方式仍然是:

减少启动阶段不必要的 IO。

而不是单纯:

把 IO 换到后台线程。


二十、CPU 问题和 IO 问题怎么区分?

Perfetto 中可以通过一些现象辅助判断。

假设一个任务:

Wall Duration:

100ms

CPU Duration:

90ms

通常说明:

主要是 CPU 计算。

可以检查:

  • JSON

  • Inflate

  • 类加载

  • 计算逻辑


如果:

Wall Duration:

100ms

CPU Duration:

10ms

说明:

大部分时间没有占用 CPU。

可能存在:

  • Waiting

  • IO

  • Binder

  • 调度等待

这时候分析方向就完全不同。

所以:

Wall Duration + CPU Duration 一定要结合看。


二十一、启动优化不能只看主线程代码

这是这一篇很重要的一点。

有时候你检查 Main Thread 代码:

发现并没有一个特别慢的方法。

但 TTID 还是高。

原因可能是:

后台线程太多。

CPU 竞争。

或者:

主线程频繁等待 Binder。

或者:

首屏前发生 GC。

或者:

磁盘 IO 抖动。

也就是说:

启动性能是整个系统调度结果,而不仅仅是某一个 Java/Kotlin 方法的执行时间。

这也是 Perfetto 的价值。


二十二、一个典型的线程问题

假设 App 启动时初始化:

  • 图片库

  • 推送

  • 埋点

  • 广告

  • 数据库

  • 配置中心

为了优化启动,

全部放到线程池。

结果 Perfetto 中看到:

大量后台线程:

Running。

Main Thread:

Runnable。

此时:

主线程并不是代码慢,

而是:

一直在等 CPU。

这时候真正的优化方案应该是:

  • 降低并发度

  • 延迟低优先级任务

  • 首帧之后再执行部分任务

而不是继续:

再多开几个线程。


二十三、一个典型的 IO 问题

假设 Perfetto 中看到:

Application 阶段 Wall Duration 很长。

但 CPU Duration 很低。

继续分析发现:

启动时读取了:

  • 用户配置

  • 首页缓存

  • 历史记录

  • 日志文件

这些操作都在 Main Thread。

那么优化方向就很明确:

首屏真正需要:

用户登录状态。

其他数据:

都可以延迟。

最终把启动路径从:

读取 10 个文件

变成:

只读取 1 个必要配置。

这种优化通常比:

微调某个方法的 2ms

更有价值。


二十四、启动阶段的问题可以分成三类

以后分析 Perfetto 时,可以先把问题分成:

第一类:CPU 重

表现:

CPU Duration 很高。

常见原因:

  • 计算

  • Inflate

  • JSON

  • Class Loading

  • 大量对象创建


第二类:等待重

表现:

Wall Duration 高,

CPU Duration 低。

常见原因:

  • Binder

  • Lock

  • 等线程

  • IO


第三类:调度竞争

表现:

Runnable 时间高。

常见原因:

  • 线程太多

  • CPU 被后台任务占用

这样一分类,

问题会清晰很多。


二十五、优化之后一定要重新测

假设我们做了这些修改:

减少启动线程。

延迟数据库查询。

减少 SharedPreferences 读取。

降低对象创建。

那么下一步不是:

看代码感觉不错。

而是重新运行:

Macrobenchmark。

例如原来:

TTID median = 340.2ms

优化以后:

TTID median = 300ms

再结合 Perfetto 看到:

Main Thread Runnable 减少。

GC 消失。

IO 延迟到首帧之后。

这时候才可以说:

这次优化有效。


二十六、到这里我们解决的还是"业务层启动问题"

目前几篇主要优化的是:

  • Application

  • SDK

  • 线程

  • IO

  • GC

  • Binder

  • UI

这些都属于比较常见的:

业务层启动优化。

但是 Android 冷启动还有另外一类成本:

代码本身怎么被加载和执行。

例如:

启动需要加载哪些 Class?

这些 Class 分布在哪些 DEX?

是否发生大量 Page Fault?

启动代码是不是集中存放?

这就进入更深一层的:

Class Loading 与 DEX Layout。


二十七、小结

这一篇重点讲了三个很容易被忽略的启动性能问题:

线程

不是越多越好。

如果线程过多:

会产生 CPU 竞争,

导致 Main Thread 出现大量 Runnable。


GC

启动阶段大量对象创建,

可能触发 GC,

增加首帧之前的额外工作。


IO

文件、SharedPreferences、数据库等磁盘操作,

如果进入启动关键路径,

很容易造成启动耗时和波动。

所以 Perfetto 分析时可以重点观察:

Running

CPU 是否真的很忙?

Runnable

是不是在抢 CPU?

Waiting

到底在等谁?

再结合:

  • GC

  • Binder

  • IO

  • Lock

定位真正的问题。

启动优化最终还是回到一个原则:

让首屏关键路径尽量少做事情、少等待、少竞争。

下一篇我们就进入启动优化里更深入的一部分:

Android 启动优化(六):Class Loading、DEX Layout 与启动类重排

这一篇会开始讲:

  • Android 启动为什么会涉及大量类加载

  • DEX 分布为什么会影响冷启动

  • Page Fault 是什么

  • 为什么启动类集中可能更快

  • Startup Profile 的 DEX Layout 思路

  • ReDex InterDex 的 coldstart_classes

  • 类重排到底优化的是启动性能还是包体积

这也是后面把 Startup Profile 和 ReDex InterDex 真正串起来的一篇。

相关推荐
counting money1 小时前
Java IO流详解:从InputStream到文件操作实战
java·开发语言·python
ltl1 小时前
向量检索引擎选型:决策树、RAG 回链与开放问题
数据库
坚持学习前端日记2 小时前
Python SQLAlchemy ORM 从0到1精通实战手册(基础到复杂高阶)
数据库·python·oracle
灯澜忆梦2 小时前
【MySQL17】进阶篇 | InnoDB引擎
数据库·mysql
程序员小八7772 小时前
上海百度B端java后端日常实习一面
java·开发语言
anxiao_m2 小时前
2026制造业云桌面选型攻略,不同生产场景适配方案汇总
大数据·网络·数据库
土星云SaturnCloud2 小时前
产线SOP动作级AI监管方案:土星云SE110S-WC8赋能合规识别、预警与效率分析
大数据·服务器·人工智能·ai·边缘计算
许彰午2 小时前
# 数据库配拦截器,不用改BPMN
数据库
躺不平的理查德3 小时前
嵌入式 Linux 系统移植与 SD 卡量产镜像制作
linux·运维·服务器