前言
"为什么我的 App 冷启动慢"——这个问题如果只回答"用 Baseline Profiles",那等于没回答。真正要理解,得钻进 ART(Android Runtime)内部看看它到底怎么跑你的字节码。
这一篇讲三件事:ART 的编译策略(AOT/JIT/PGO 怎么配合)、垃圾回收器怎么选(为什么不用 G1)、以及实际开发中怎么利用这些知识做性能优化。
一、ART 的三层编译
ART 不是简单的"解释器"或"AOT 编译器",它是三者的混合体:
+-------------------+
| dex 字节码 |
+---------+---------+
|
+------+------+
| 解释器执行 | <-- 启动时所有方法走这里
+------+------+
|
profile 采集
(热点方法)
|
+-------------+-------------+
| |
+-------+-------+ +------+------+
| JIT 编译 | | AOT 编译 |
| (运行中) | | (安装/空闲) |
+-------+-------+ +------+------+
| |
+-------------+-------------+
|
Profile 持久化
下次安装 -> AOT 预编译
解释器(Interpreter)
App 启动那一刻,所有方法都走解释器逐条执行字节码。慢,但不需要编译时间。这保证冷启动不会有额外的编译开销。
JIT(Just-In-Time)
解释器执行过程中,ART 会记录哪些方法被调得频繁(默认阈值 100 次)。达到阈值后,JIT 编译器在后台线程把这个方法编译成机器码,后续调用直接走机器码。
JIT 编译结果不持久化,App 重启后清空。
AOT(Ahead-Of-Time)
安装 App 时(或空闲时),ART 会用 dex2oat 工具把字节码预编译成机器码(OAT 文件)。启动时直接加载,不走解释器。
但全量 AOT 编译太慢(安装时间过长),所以 Android 7.0+ 改成了"混合编译":只 AOT 编译 Profile 里标记的热点方法。
PGO(Profile-Guided Optimization)
PGO 是 AOT 和 JIT 的桥梁。运行时 JIT 采集热点方法信息,存成 profile 文件。下次安装/更新时,dex2oat 拿 profile 做输入,只 AOT 编译热点方法。
第 1 次运行: 解释器 -> JIT 编译热点 -> 存 profile
第 2 次启动: AOT 加载 profile 标记的方法 -> 启动快
Baseline Profiles 就是人工提供这份 profile,跳过"运行时采集"这一步。
二、三种策略的配合关系
Android 各版本的策略变化:
三、GC:为什么 ART 不用 G1
ART 在 Android 10 之前用的是 Concurrent Copying Collector(CC),10 之后做了增强,叫 CC(Generational)。不是 G1,不是 ZGC,是自研的。
为什么不用现成的 G1/ZGC?
ART 的堆是 Zygote 预分配的大块连续内存,不需要 G1 的分区设计
移动设备对暂停时间极度敏感(16ms 内必须完成),CC 用 read barrier 方式做并发标记,暂停可以做到 5ms 以下
G1 的 Region 设计对大堆(4GB+)更有意义,移动端堆一般 256MB 以下
CC 的工作方式:
1. 标记阶段(并发): GC 线程遍历对象图,标记存活对象
2. 复制阶段(STW < 5ms): 把存活对象复制到新空间
3. 修正阶段(并发): 修正指向旧地址的引用
关键设计:read barrier。每次读对象引用时,ART 检查这个引用是否指向已经复制过的旧地址。如果是,修正为新地址。这让 GC 不需要 STW 整个复制阶段。
实际影响:你几乎不会看到 ART 的 GC 日志里出现"Full GC"或 >10ms 的暂停。但如果你的代码大量分配临时对象(比如循环里 new),GC 频率上升,暂停次数多了也会累积影响。
四、实战:利用 ART 知识做优化
场景 1:冷启动慢
// 坏例子:启动时创建大量 lambda
class App : Application() {
override fun onCreate() {
super.onCreate()
// 每个 lambda 都是一个新对象
repeat(50) { i ->
workerScheduler.execute {
loadFeature(i)
}
}
}
}
// 好例子:延迟初始化 + 减少 lambda
class App : Application() {
override fun onCreate() {
super.onCreate()
// 用 App Startup 框架管理
initializerProvider.deferInit {
loadCriticalFeatures()
}
// 非关键路径延迟到空闲
mainHandler.postDelayed({
loadNonCriticalFeatures()
}, 1000)
}
}
原理:启动阶段所有方法走解释器,JIT 还没生效。减少方法调用次数和对象分配,直接减少解释器执行时间。
场景 2:运行中卡顿
// 检测 GC 频率
// adb shell dumpsys meminfo <package> | grep "GC"
// 输出示例:
// GC in 1s: 23 times, total pause: 89ms
// 如果 1 秒内 GC >10 次,说明对象分配太频繁
// 优化:复用对象
val buffer = ByteArray(4096) // 复用
fun processData(input: InputStream) {
while (input.read(buffer) > 0) {
// 不在循环里 new ByteArray
handleChunk(buffer)
}
}
场景 3:Baseline Profile 不生效
// 确认 profile 是否加载
// adb shell cmd package compile -m speed-profile -f <package>
// 检查 dex2oat 日志
// adb logcat | grep "dex2oat"
// 看是否有 "Compiled methods: XXX" 输出
常见原因:
Profile 文件没打进 APK(检查
assets/dexopt/baseline-prof路径)Profile 里的类名和方法签名跟实际不匹配(混淆后 R8 改了名)
设备存储空间不足,dex2oat 跳过编译
五、编译模式对照
# 查看当前 App 的编译模式
adb shell cmd package list packages -f | grep <package>
# 编译模式选项
# -m verify: 只验证,不编译(最慢)
# -m quicken: 快速编译,不做优化
# -m speed: 全量 AOT 编译
# -m speed-profile: 按 profile 编译(推荐)
Google Play 安装 App 时默认用 speed-profile(如果你提供了 Baseline Profile)。国内应用市场很多用 quicken 或 verify,这是国内 App 启动普遍慢的原因之一。
小结
ART 的三层编译(解释器 -> JIT -> AOT)配合 PGO,兼顾了启动速度和运行性能。GC 用 CC + read barrier,暂停控制在 5ms 以下。
理解这些机制的实际价值:你能知道"为什么慢"和"怎么验证是否优化生效",而不是盲目加 Profile 和改代码。
下篇讲 Compose 类型安全导航,从字符串路由迁移到编译时检查。