0%

深入理解 Android 内存管理(二):从虚拟机到实践

五年前写《深入理解 Java 和 Android 中的内存管理(一):虚拟机》时,故事停在”ART 在安装时把字节码全量 AOT 编译成机器码,换来运行快但安装慢、占用大”。那是一个还把 Dalvik 当现实的年代。这五年,虚拟机自己被翻了个底朝天:编译策略从”一次全编”改成”按需热编”,GC 从全停顿的标记整理换成并发复制,Android 15 还把内存页从 4 KB 挪到了 16 KB——本文开篇第一节就来算这笔账。

但更值得写的是开发者侧的变化。Kotlin 成了 Android 的第一语言,协程把异步写成同步的样子,生命周期被 lifecycleScope 包起来——GC 判定对象可达性的规则((一)讲过的 GC Roots 与引用链)一个字没变,可”谁在攥着谁”的写法全变了。老一批泄漏模式(Handler 延时消息、内部类隐式持有外部类、静态变量持有 Context)退场了,新的一批穿着协程和 Flow 的外衣回来了。

所以(二)的定位和(一)正好互补:(一)讲”虚拟机怎么管内存”,这篇讲”你怎么知道自己的应用在漏内存、漏在哪、怎么修”。全文四条线:ART 十年演进、Kotlin 时代的泄漏新模式、Perfetto/LeakCanary 现代排查流、大图与大内存。所有版本特性都标注来源与版本号,代码示例可以直接对着改。

Key Takeaways

  • ART 演进的主线是"把一次性、全停顿的工作换成语义感知、并发的按需工作":JIT+AOT 混合编译削安装与存储成本,并发复制 GC 削 GC 停顿,Baseline Profiles 削冷启动与首帧卡顿。
  • 协程没让泄漏消失,只是换了形态:生命周期错位的 Job、GlobalScope、不受生命周期约束的 collect、忘 awaitClose 的 callbackFlow,是四类最高频的新模式。
  • 排查要分层:LeakCanary 回答"谁还攥着这个对象"(引用链),Perfetto 回答"系统水位和压力在哪"(RSS/PSS、rss_high_water_mark、heapprofd native 堆)——两个工具不是替代关系。
  • Bitmap 的痛点在 Android 8.0 后已整体移出 Java 堆:今天的战场是硬件位图的取舍与解码策略(inSampleSize、inBitmap),不是 GC。
  • 16 KB page size(Android 15 起支持;Google Play 兼容性要求 targetSdk 35+ 的应用必须支持,2027-02-01 起不兼容的更新无法发布)是近年最值得跟的一次底层变更,native 库没适配会在新设备上直接装不上或内存膨胀。

本文目录:一、ART 十年:每一步都在偿还内存的债 · 二、Kotlin 时代:泄漏没消失,只是换了形态 · 三、现代排查流:先引用链,后系统水位 · 四、大图与大内存:Bitmap 的战场转移 · 五、一次真实的排查复盘 · 六、五个决定 · FAQ

一、ART 十年:每一步都在偿还内存的债

先给一张时间线,后文挑对”内存”影响最大的三处展开。

ART 十年:编译与 GC 的演进时间线 主线:一次性全量工作 → 语义感知、并发的按需工作 Android 5.0(2014):ART 替代 Dalvik,安装时全量 AOT 运行快,代价是安装慢、存储占用涨 Android 7.0(2016):JIT + AOT 混合,profile 引导 只对热点做 AOT,安装与存储成本大降 Android 8.0(2017):并发复制 GC(CC GC) 全堆长停顿退场;Bitmap 像素数据同时移入 native heap Android 9–11:Baseline Profiles 云端下发;CC 分代化(10+) 官方下发热点清单,冷启动可测量改善 Android 12+:Baseline Profiles 随 APK 本地分发 应用自管热点文件;ART 走 Mainline 可独立更新 Android 15(2024):支持 16 KB page size 大页降低页表与 TLB 压力;native 库需重新对齐编译 2027.02 起:不兼容 16 KB 的应用更新无法发布 大页适配从可选项变成门槛 三步演进:按需编译(7.0)→ 并发 GC(8.0,10 起分代)→ 可更新虚拟机(12+ Mainline) 每一代都在把"内存相关的停顿与占用"换给更聪明的调度
版本 年份 变化 对内存意味着什么
Android 5.0 2014 ART 替代 Dalvik,安装时全量 AOT 运行快,但安装慢、存储占用涨((一)文尾提过 10–20% 的空间开销)
Android 7.0 2016 JIT + AOT 混合,profile-guided 编译 不再全编:热点代码运行时 JIT,冷却后按 profile AOT,安装与存储成本大降
Android 8.0 2017 并发复制 GC(CC GC);Bitmap 像素数据移入 native heap GC 停顿从”全堆标记整理的 STW”变成极短的并发暂停;Java 堆瘦身
Android 9–11 2018–2020 Baseline Profiles 云端下发(Cloud Profiles);CC GC 自 10 起扩展出分代回收 官方下发热点清单,冷启动与首帧可测量改善;年轻代对象可低成本单独回收
Android 12+ 2021 起 Baseline Profiles 随 APK 本地分发;ART 走 Mainline(ART Modules) 应用自管热点文件;虚拟机本身可以不跟 OTA 更新
Android 15 2024 支持 16 KB page size 大页降低页表与 TLB 压力,native 库需重新编译适配
2026 — 16 KB 兼容为 Play 要求,2027-02-01 起不兼容的更新无法发布 适配不再是可选项

(版本与特性出处:Android 8.0 行为变更与 ART 文档、Baseline Profiles 官方文档、16 KB page size 官方指南,检索于 2026-11-04。)

变化一:编译策略,从”一次全编”到”按需热编”。 Android 5.0 的全量 AOT 是拿”安装时间 + 存储空间”换”运行速度”——应用装一次,全部字节码变机器码。Android 7.0 换成 JIT + AOT 混合:刚安装的应用先以解释 + JIT 跑,跑的过程中记录”哪些方法被频繁执行”(profile),设备空闲时 dex2oat 只对热点做 AOT 编译,产物比全量 AOT 小得多。这是典型的把”不区分冷热的全量工作”换成”语义感知的按需工作”。沿这条线走到 Android 12,Baseline Profiles 把编译时机进一步前移:应用在构建期自己产出热点清单(用户首次启动前最常走的代码路径),随 APK 一起安装,首帧之前就把关键路径编译好——官方给出的数字是:含入的代码路径自首次启动起执行速度提升约 30%。

变化二:GC,从”全堆停顿”到”并发复制”。 (一)里讲的分代假设(大多数对象朝生夕死)在 ART 上一直成立,但老版本 ART 对老年代的回收是接近全停顿的标记整理。Android 8.0 起的并发复制 GC(CC GC)把重活搬进并发阶段:后台线程边跑应用边做可达性标记,只有极短的暂停用来切换状态与复制根集附近的对象;分代化也在同一时期补上——ART 官方文档写明 CC 自 Android 10 起扩展为分代 GC,年轻对象(往往很快不可达)以极低成本单独回收,明显推迟全堆 GC 的到来,把(一)讲的分代假设落到了 CC 的实现里。对开发者,这意味着”GC 停顿导致掉帧”的叙事发生了变化:全堆 Full GC 式的长停顿基本退场,真正扎手的是分配速率过高带来的”GC 太频繁”和之后第三节要讲的 native 侧水位。

变化三:虚拟机自己变得可更新了。 Android 12 起 ART 走 Mainline(模块化系统文档),虚拟机的修复与新特性可以随 Play 系统更新下发,不必等整个系统 OTA。这直接改变了”Android 版本 = ART 能力”的旧等式:同一台 Android 12 设备,一年后的 ART 可能已经加了新的 GC 调优或 dexopt 策略。对内存管理这个话题,它意味着 ART 行为差异排查要带上”ART 模块版本”这个新维度。

二、Kotlin 时代:泄漏没消失,只是换了形态

GC 判定”对象能不能回收”的规则没变:从 GC Roots 出发画引用链,不可达即回收((一)讲过)。泄漏的本质永远是”该断的引用没断”。变的只是写法——下面四类模式,覆盖了我近几年 review 和排查里见到的绝大多数 Kotlin 层泄漏。

旧模式(Java 时代) 新形态(Kotlin 协程时代)
Handler 发延时消息,Activity 销毁后消息仍持有 Activity 自建 CoroutineScope 的 Job 在页面销毁后不取消,协程体持有 Activity/Fragment
静态变量持有 Context / 单例缓存 View GlobalScope.launch 把短生命周期对象挂到进程级 scope
匿名内部类隐式持有外部类 lifecycleScope.launch { flow.collect } 不受生命周期状态约束,STOP 后仍在收集
广播/监听器忘记注销 callbackFlow 忘记 awaitClose,注册的回调永不解除

模式一:生命周期错位的 Job。 lifecycleScope 的好处是随 Lifecycle 销毁自动取消,但它救不了”协程内部把工作交给了别的 scope”或者”协程把引用存进了长生命周期对象”的场景。反例:

1
2
3
4
5
6
7
8
9
10
11
12
class Repo {
val cache = mutableListOf<Bitmap>()
fun prefetch(urls: List<String>) = scope.launch { // scope 是 Repo 自己的单例 scope
urls.forEach { cache += load(it) } // 闭包引用 Activity 传进来的 ctx
}
}

class DetailActivity : AppCompatActivity() {
override fun onCreate(...) {
repo.prefetch(urls) // Activity 销毁后 Job 仍在跑
}
}

修正的关键是让 Job 的生命周期跟着引用的生命周期走:谁被引用,就把 launch 挂到谁的 scope 上,或者把取消的责任显式交回调用方(takeIf { !isFinishing } 这类补丁不算数)。

模式二:GlobalScope。 官方文档对它的定位写得很直白:创建的协程不受任何 Job 管理,”activity-scoped 的操作放进去就是泄漏”(kotlinx.coroutines 文档)。GlobalScope.launch { updateUi() } 在 Activity 销毁后照样执行,协程体连同捕获的 this 一起活到进程死。正解要么 lifecycleScope,要么自建 scope 并在 onDestroy 里 cancel()。

模式三:collect 不受生命周期约束。 这是 2020 年前后最集中的一类线上问题:

1
2
3
4
5
6
7
8
9
10
11
// 反例:STOP 状态下仍在收集,UI 更新照常发生,Activity 引用长期持有
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}

// 正解:官方推荐的 repeatOnLifecycle,进入 STARTED 才收集,STOPPED 自动挂起
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}

区别不在”会不会崩”,而在”STOP 到 RESUME 之间白白做了多少事、攥了多少过期状态”。(官方推荐写法明确将 repeatOnLifecycle 列为 UI 层收集的标准答案。)

模式四:callbackFlow 忘 awaitClose 与 SharedFlow 的重放缓存。 callbackFlow 把回调 API 转成 Flow,桥的两端是”注册回调”和”解除回调”——忘了 awaitClose { ... },Flow 结束时回调永远挂着,注册进系统或 SDK 的监听器就永远拿着你的对象。SharedFlow(replay = N) 则是另一种方向:缓存的最近 N 个事件里如果有带 Context/View 引用的,新订阅者一来就全部重放——缓存策略要当成内存问题来设计,不是当成 UX 问题。

一句话小结:结构化并发是把旧泄漏模式批量终结的关键设计——scope 的取消随结构传播,所以”scope 绑定生命周期 + repeatOnLifecycle + awaitClose”三件事做对,Kotlin 层泄漏基本就没了。

三、现代排查流:先引用链,后系统水位

工具必须按”回答什么问题”来分层,否则很容易拿着放大镜找错东西。

工具 所在层 回答的问题 典型成本
LeakCanary 应用内(debug 依赖) “这个对象为什么还没被回收?”——完整引用链 零配置接入,debug 包自动触发
Android Studio Memory Profiler 开发机 “堆里现在有什么?和上次比多了什么?” 手动快照 + 对比
Perfetto 系统 “RSS/PSS 水位怎么走的?native 堆谁在涨?有没有 kswapd 压力?” adb 抓 trace,需要熟悉面板
内存排查决策流:先水位,后引用链 顺序反了容易拿着放大镜找错层 现象:用久了变卡 / 变慢 第一步:Perfetto 看水位 meminfo 曲线 / rss_high_water_mark 峰值 / kswapd 压力 增长来自哪一层? 按曲线归因(Java 堆 / native / GPU) Java / Kotlin 层 Profiler 快照对比找可疑类 ↓ LeakCanary 要引用链 "谁还攥着这个对象" native / GPU 层 Bitmap 进 native 堆后的主战场 ↓ heapprofd 抓 malloc 栈 "哪条调用栈在分配" adb 抓 trace

第一层:LeakCanary 抓引用链。 接入只需要一个 debugImplementation 依赖:

1
2
3
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

stable 版本线是 2.14;3.0 系列已进入 alpha(截至 2026-06 最新为 3.0-alpha-9,GitHub releases),仓库 30,000+ star,生产集成仍建议走 2.14。

工作原理四步:ObjectWatcher watch 关键对象(Activity、Fragment、ViewModel、View、Service 的销毁事件自动注册)→ 期待的时间点过后对象仍在,触发一次 GC 复核 → 仍在就 dump hprof → Shark 在进程内解析出”从 GC Roots 到泄漏对象”的最短引用链,直接在通知栏展示。它回答的是点的问题:谁攥着谁。收益有官方背书:LeakCanary 文档记录,Square Point of Sale 首次启用 LeakCanary 后修复多处泄漏,OOM 崩溃率下降 94%。配合本文第二节,四类新模式在 LeakCanary 报告里的长相都很典型——mRepo 里的 scope$job、callbackFlow 的 channel、SharedFlow 的 replay cache。

第二层:Perfetto 看系统水位。 LeakCanary 说不出”应用整体内存是涨是跌、是 Java 还是 native、系统有没有压力”——这是 Perfetto 的地盘。抓一次带内存数据源的 trace:

1
2
3
4
5
6
7
adb shell perfetto \
-c - --time 30s -o /data/misc/perfetto-traces/mem.pftrace <<EOF
buffers: { size_kb: 65536 }
data_sources: { config { name: "linux.meminfo" } }
data_sources: { config { name: "android.memory.rss_high_water_mark" } }
data_sources: { config { name: "android.heapprofd" } }
EOF

三个面板对应三个问题:linux.meminfo 给出按进程的 RSS/PSS 曲线(趋势);rss_high_water_mark 记录进程 RSS 的历史峰值——这是判断”这个应用到底把系统推到过什么程度”的硬指标,回落不代表没顶过;heapprofd(native 堆采样)在 native 内存增长时给出 malloc 调用栈——Bitmap 移出 Java 堆(下一节)之后,这是图像问题的主要藏身处。

实战决策流:线上反馈”用久了变卡”→ Perfetto 看趋势与水位(确认增长来自哪一层)→ native 涨用 heapprofd 定位调用栈,Java/Kotlin 涨用 Profiler 快照对比 → 引用链疑问交给 LeakCanary。顺序反过来(先堆快照后水位)的常见翻车:snapshot 里看到一堆 Bitmap 就以为是图片问题,实际是 native 侧页面缓存(kswapd 压力)在背锅。

四、大图与大内存:Bitmap 的战场转移

(一)写作时,”Bitmap 占爆 Java 堆”还是高频线上问题;Android 8.0 之后,像素数据的存放位置从 Java heap 挪到了 native heap(官方说明),OOM 的形态也随之改变:不再是在 Java 堆里拼 OutOfMemoryError,而是 native 侧直接触顶。

硬件位图是这波迁移的延续。 Bitmap.Config.HARDWARE 把像素数据只存在 GPU 显存里,CPU 侧只剩一个引用句柄——绘制快、内存省,但有明确边界:不能在 CPU 侧读回像素(getPixels 会抛异常)、不能用于需要 CPU 访问的场合(Canvas 软件绘制、部分自绘特效)。Coil 与 Glide 在满足条件时默认启用硬件位图,这也是”升级图片库后一些效果突然报错”的最常见原因。

解码策略仍然是基本功。 展示 1080x1920 的图到 108x192 的 ImageView,按 4 字节/像素直接解码就是约 8 MB 起步的内存:

1
2
3
4
5
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, opts) // 先只读尺寸
opts.inSampleSize = calcInSampleSize(opts, reqW, reqH) // 2 的幂,按目标尺寸算
opts.inJustDecodeBounds = false
BitmapFactory.decodeFile(path, opts) // 再真解码

进阶的 inBitmap 复用(新 Bitmap 复用旧位图的内存块)在图片列表场景能把分配抖动压掉一截,但要求尺寸兼容,交给图片库处理比自己管省心。官方文档给过一组直观的量化:一张 2048x1536 的图以 ARGB_8888 全图解码约 12 MB,按目标尺寸取 inSampleSize=4 后解码到 512x384,只要约 0.75 MB(加载大图官方指南)——16 倍的差距全部来自”要不要按显示尺寸解码”这一件事。

16 KB page size 是图像与 native 库的新变量。 Android 15 起支持 16 KB 内存页设备;官方指南写明 Google Play 兼容性要求:targetSdk 35+(Android 15 及以上)的应用必须支持 16 KB 内存页,且自 2027 年 2 月 1 日起,不兼容 16 KB 的应用更新将无法发布。内存页变大后,页表项减少、TLB 命中率上升——官方实测给出的是一组不大的净收益:内存压力下应用启动时间平均降低 3.16%、启动功耗降低 4.56%、相机冷启动快 6.60%、系统启动时间提升约 8%。但所有含 native 代码的库必须按 16 KB 对齐重新编译,否则在启用大页的设备上无法安装或运行。

五、一次真实的排查复盘

把一个图片详情页从 Handler 回调迁移到协程,结果迁移完一压测就漏——这个复盘里第二节的四个模式占了两个。

现象:反复进出详情页十几次后,Profiler 里 DetailFragment 实例数稳步上涨,滑动开始掉帧。代码表面上看没有任何问题:collect 用了 lifecycleScope,页面销毁 scope 会取消——教科书式的”正确”写法。

LeakCanary 的报告指向了一条不短的引用链:DetailFragment → adapter → 协程闭包 → Repo 单例 scope 里的一个 Job。链的末段才是真相:loadFlow() 内部为了预加载,往 Repo 自己的单例 scope 里又 launch 了一个协程,闭包顺手捕获了 adapter(间接攥着整个 Fragment)。外层 lifecycleScope 取消时,取消只沿结构化并发树传播——内层那个 Job 的父级是 Repo 的 scope,根本不在这棵树上,取消令传不到。

1
2
3
4
5
6
7
8
9
10
// 迁移后的代码,表面正确
lifecycleScope.launch {
repo.loadFlow(urls).collect { adapter.add(it) } // ← collect 闭包捕获 adapter
}

// repo.loadFlow 内部:另起炉灶的预加载,闭包捕获了调用链上的对象
fun loadFlow(urls: List<String>) = flow {
scope.launch { prefetch(urls.map { it }) } // ← 不在取消树里,泄漏根源
emitAll(...)
}

修复只有一条正路:让所有协程进同一棵取消树。 把 loadFlow 改成 suspend 函数、预加载用外层传入的 scope(或者干脆放进 coroutineScope { } 块里成为外层 Job 的孩子)。修复后压测验证:进出页面 20 次,Perfetto 的 RSS 曲线回到起点,LeakCanary 报告清零。

复盘要点:协程迁移的 review 清单里必须有一条——“这个 launch 的父级是谁?页面销毁时取消能传到它吗”。能答上来,第二节四个模式里的三个(一、二、四)都自动免疫;答不上来,说明存在游离于取消树之外的协程,泄漏只是时间问题。把这类易错点固化成可执行的清单而不是靠记性,做法可以参考把 code review 流程固化为技能的实践。

六、五个决定

  1. 编译:新应用直接上 Baseline Profiles,冷启动与首帧的收益是官方实测级别的。
  2. 作用域:所有自建 CoroutineScope 的生命周期与所属对象一致;GlobalScope 进 code review 黑名单。
  3. 收集:UI 层 collect 一律 repeatOnLifecycle(STARTED),别用裸 lifecycleScope.launch。
  4. 排查:LeakCanary 常驻 debug 包;线上问题先用 Perfetto 看水位与 native 栈,再决定往哪层挖。
  5. 图像:图片库默认配置起步;硬件位图报错时先想”是不是 CPU 侧读了”,而不是绕回软位图;native 依赖在 2026 年的今天必须确认 16 KB 适配。此外,大内存场景正在被端侧 AI 继续推高——端侧大模型实测里 Gemma 3n 单模型就要吃掉 GB 级内存,内存预算的分配只会越来越紧张。

FAQ

RSS、PSS、USS 三个指标到底看哪个?

看你要回答什么问题。RSS(常驻内存)把共享库整份计入,多进程环境会虚高,只适合粗看趋势;PSS(按比例分摊)把共享内存按进程数分摊,是 Play Vitals 和大多数”应用内存大盘”的口径,日常监控用它;USS(私有内存)只算进程独占的部分,归因”这个进程自己多吃了多少”时最准。Perfetto 的 linux.meminfo 面板三者都有,建议固定口径看趋势、换口径做归因,别跨口径比较。

LeakCanary 报的泄漏都是真泄漏吗?

绝大多数是,但有需要判断的灰区。它的机制是”对象该回收了却还在 → 触发 GC 复核 → 还在才 dump”,误报的常见来源是缓存策略:LruCache 故意保留的最近条目、按设计常驻的单例,出现在报告里不代表错,只代表”这个对象的生命周期比 LeakCanary 预设的短”。处理原则:Activity、Fragment、View、ViewModel 的泄漏一律当真并必修;缓存类对象的报告结合保留策略评估;拿不准时看引用链里有没有”预期之外”的中间环节——有的话基本就是第二节里哪一类模式。

内存优化先做哪件事收益最大?

按投入产出排序:第一步接 LeakCanary,抓现成泄漏,零成本白捡的收益;第二步看图片——图像几乎永远是应用内存的最大单项,图片库默认配置 + 解码尺寸匹配就能解决大半;第三步才是分配速率层面的微优化(复用、避免热路径每帧分配)。GC 参数调优放在最后,因为应用层基本没有 ART GC 参数可调,你能做的是”让它少干活”。

16 KB page size 对已有应用意味着什么?

分两种情况。纯 Kotlin/Java 应用(不含自己编译的 native 代码)无需任何动作,系统自动适配。含 native 库(自研 so 或第三方 SDK)的应用必须让所有 so 按 16 KB 对齐重新编译——Google Play 兼容性要求 targetSdk 35+ 的应用必须支持 16 KB,且自 2027 年 2 月 1 日起不兼容的应用更新将无法发布,在启用大页的设备上也可能直接安装失败。官方提供了检查工具与迁移文档(developer.android.com/guide/practices/page-sizes),迁移本身通常只是改编译配置,难点在依赖链里第三方 SDK 的版本跟进。

GC 调优还有必要吗?

JVM 时代那套”调 GC 参数”的思路在 Android 上基本不适用:ART 的 GC 策略由系统与 ART 模块决定,应用层几乎拿不到调节旋钮。实践中的”GC 调优”只剩一件事——降低分配速率:对象池复用、避免 onDraw/高频回调里创建对象、大对象走专门路径(比如上一节的 inBitmap 复用)。分配速率下来了,无论是并发复制 GC 还是分代 Minor collection,触发频率都会自然下降。