第 10 章

10.3内存抖动与频繁 GC

适用 Android 8.0 (API 26) - Android 17 (API 37) · 内容验证 2026-07-31

内存抖动(allocation churn)描述高频分配与回收,不等同于内存泄漏。泄漏对象长期可达,GC 后的堆基线不断上升;抖动对象通常很快失去引用,堆曲线反复上升和回落。两种现象也可能同时存在,例如一个页面既在每帧创建临时对象,又把少量对象留在错误的生命周期里。

一次 GC 事件也不能直接判定卡顿。诊断要证明三件事:目标场景的分配速率异常、GC 或分配等待与慢帧时间重叠、减少热点分配后同场景指标改善。这里的热点分配是指在目标时间窗中反复出现或累计字节数较高的分配调用栈。

从分配到慢帧发生了什么

分配快,不代表分配免费

ART 为小对象提供线程本地分配快路径。TLAB(Thread-Local Allocation Buffer,线程本地分配缓冲区)让线程在自己的缓冲区内推进指针,避免每次分配都争用堆的全局锁。Android 8 以来的 Concurrent Copying(CC,并发复制)可使用 RegionTLAB;Android 17 的 Concurrent Mark-Compact(CMC,并发标记压缩)在启用 TLAB 时使用 TLAB allocator。

对象仍有后续成本:

  • TLAB 或 RegionTLAB(按堆区域划分的 TLAB)用尽后需要补充空间。
  • 分配会增加标记、扫描和回收工作量。
  • 活对象越多,收集器需要处理的引用越多。
  • 大对象或突发分配更容易离开最轻的分配路径。
  • 回收线程会消耗 CPU 和内存带宽,与 UI 线程、RenderThread(渲染线程)竞争。
  • 可分配空间不足时,发起分配的线程可能等待 GC 完成。

120 Hz 屏幕的理论帧间隔约为 8.33 ms。GC 暂停、分配等待或 CPU 竞争只要与已经接近预算的帧重叠,就可能产生 missed frame(未按时呈现的帧)。暂停时间没有跨设备通用常量,不能用“某类 GC 固定耗时几毫秒”代替 trace 证据。

Android 17 的 CMC 源码边界

Android 17 collector_type.hkCollectorTypeCMC 定义为 Concurrent mark-compactheap.ccgUseUserfaultfd 为真时校验前台收集器为 CMC、后台收集器为 CMCBackground;启用 generational GC(分代垃圾回收)时,还会创建负责年轻代的 YoungMarkCompactruntime.cc 对 generational GC 的选择同时检查 collector 能力、-Xgc 选项和 ShouldUseGenerationalGC()

这几处源码说明 Android 17 具备 CMC 与 generational CMC 路径。源码中存在 YoungMarkCompact,不代表每台 Android 17 设备、每个进程都使用同一运行参数。OEM 配置、ART Mainline 模块和进程选项仍可能影响选择;取证时可从 ART 启动日志中的 collector 描述核对当前进程。

CMC 的 mark_compact.cc 检查 UFFD_FEATURE_SIGBUSMREMAP_DONTUNMAP,并实现 SigbusHandler() 处理压缩期间的页面访问。kernel 侧对应实现按 android17-6.18-2026-06_r6fs/userfaultfd.cmm/mremap.c 核对。该机制用于缩短并发压缩对 mutator(执行应用代码的线程)的阻塞范围,但不会消除 GC 工作量。应用持续制造短命对象时,年轻代回收、CPU 占用和 allocation stall(分配等待)仍然需要测量。

识别抖动、泄漏与正常波动

现象 常见曲线 优先证据 主要处理方向
短命对象抖动 分配量快速增长,GC 后大幅回落 allocation call stack(分配调用栈)、GC 时间、慢帧重叠 降低分配次数或单次大小
引用泄漏 多轮 GC 后存活基线持续上升 heap dump、Retained Size、GC Root 修正引用所有权
缓存或对象池增长 上升后停在容量上限 容量、命中率、逐出记录 校准容量和失效策略
大对象突发 单次或少数分配拉高曲线 分配大小、调用栈、业务事件 复用 buffer、分块或移出关键路径
正常预热 启动阶段增长,随后稳定 类加载、资源初始化、后续稳态 通常不改,保留基线

“锯齿形”只是线索。若每次 GC 后基线也在上升,需要同时排查存活对象;若分配很高但帧时间、CPU 和 GC 等待没有退化,优化优先级可以后移。

常见分配热点

onDraw()、布局和动画回调

每帧回调会让同一处分配随帧数重复执行。PaintPathRect、数组、Lambda 和临时集合都应通过 allocation profile(分配记录)验证,不能只按代码外观猜测。

下面的示例用于说明如何把可复用的绘制状态移出 onDraw()

kotlin
class MeterView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : View(context, attrs) {
    private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.RED
    }
    private val barBounds = RectF()

    override fun onDraw(canvas: Canvas) {
        barBounds.set(0f, 0f, width.toFloat(), height.toFloat())
        canvas.drawRect(barBounds, barPaint)
    }
}

PaintRectF 只在 View 创建时分配,帧回调只更新字段。若绘制状态会跨线程使用,复用对象还要满足线程所有权要求。

循环、列表绑定与流式转换

循环内部创建集合、正则、格式化器或中间 DTO(数据传输对象),会让分配次数随元素数增加。Kotlin collection pipeline(集合操作链)也可能创建中间集合;Sequence 能减少部分中间集合,但它有迭代器和间接调用成本,短小集合未必受益。

修复时先问三个问题:

  • 中间结果是否必须保存?
  • 容器能否移到循环外并通过 clear() 复用?
  • 能否提供预估容量,减少数组扩容与复制?

复用容器前要确认调用不会把容器引用保存到异步任务或返回值中。误用同一实例会引入数据覆盖和并发错误。

字符串与日志

循环中的 result += item 会反复生成新的 String 内容。日志参数也会在调用前求值,日志方法内部再判断开关已经来不及避免参数构造。

下面的示例把多次不可变字符串拼接改成使用同一个 builder(可变构建器)完成。

kotlin
fun joinNames(items: List<String>): String = buildString {
    items.forEachIndexed { index, item ->
        if (index > 0) append(", ")
        append(item)
    }
}

返回 String 本身仍会分配;这段代码减少的是循环内重复复制。高频日志可在调用点检查日志级别,或使用接收 Lambda 的延迟日志 API,确保禁用日志时不构造文本。

装箱

泛型集合、可空原始类型和接口调用可能把 IntLong 等原始值包装成对象,这一过程称为装箱。JVM 对部分数值有 wrapper cache(包装对象缓存),编译器也可能消除一些临时对象,因此不能断言每次装箱都会产生新对象。

在已确认的热点中可评估以下替代:

当前结构 候选结构
HashMap SparseArray
HashMap SparseIntArray
HashMap SparseLongArray
HashMap LongSparseArray
List 的密集数值计算 IntArray

SparseArray 使用排序后的原始类型 key 数组,能省去 key 装箱;它与哈希表的查找、插入特征不同。数据规模和更新模式必须通过 benchmark(基准测试)决定,不能按“元素超过某个数量”设置统一切换点。

大型 buffer 与图像

相机、编解码、Bitmap 和序列化常产生大数组或 native buffer。此类问题的 Java 对象数可能不高,allocation bytes(累计分配字节数)却很大。优化方向包括固定数量的 buffer 轮转、按目标尺寸解码、分块处理和遵守库的 buffer pool(缓冲池)所有权。

Android 8.0(API 26)起 Bitmap 像素位于 native heap。图片库管理的 Bitmap 应归还给库自己的池;业务代码不要在未知调用方仍可能使用 Bitmap 时提前 recycle()

用证据定位

Android Studio:记录 Java/Kotlin 分配

Android Studio 的 “Track Memory Consumption (Java/Kotlin Allocations)” 可以显示分配类型、大小、线程、调用栈和释放时间。完整记录本身会减慢高分配应用;官方工具提供 sampled(抽样)模式降低影响。

一次有效的录制应满足:

  1. 在可重复的用户动作前开始录制。
  2. 只保留足以覆盖该动作的时间窗。
  3. 分别按 Allocations、Allocation Size 与 Total Count 排序。
  4. 回到调用栈,确认对象来自业务路径、profiler(分析器)自身或框架预热。
  5. 修复后在同设备、同构建类型、同数据集下复测。

分析 churn 时关注时间窗内累计分配次数与字节数;分析泄漏时关注结束时仍存活的数量、大小和引用关系。两个问题不应使用同一排序口径。

Perfetto heapprofd:调用栈采样

heapprofd 从 Android 10 起支持 native malloc/free 分配分析。Android 12 起可把 heap 指定为 com.android.art,记录 Java allocation call stack。ART allocation profile 只记录对象创建位置,不提供对象删除时间或完整引用图;分析存活对象的引用关系需要 heap dump。

下面两条命令展示当前 Perfetto 脚本的 native 与 ART 入口。

bash
# Native malloc/free allocation profile
tools/heap_profile android -n com.example.app

# Java allocation profile(Android 12+)
tools/heap_profile android -n com.example.app --heaps com.android.art

user build(日常发布版本的系统镜像)只允许分析 manifest 标记为 debuggableprofileable 的 App。Java profile 里的 Total allocation sizeTotal allocation count 包含采样期间的累计分配,即使对象稍后已经回收;不能把它们当作当前存活堆大小。分配突发还可能让共享缓冲区溢出,Perfetto 会提前结束 profile,此时需要提高 sampling interval(采样间隔)或按官方建议调整缓冲区。

Perfetto:把分配、GC 和帧放进同一窗口

系统 trace 至少应包含调度、频率、Frame Timeline 与 ART/应用 trace。分析顺序如下:

  1. 从 missed frame 或业务事件确定时间窗。
  2. 检查 UI 线程是否暂停、处于 Runnable(可运行但在等待 CPU)状态,或正在等待分配。
  3. 对齐 GC slice、HeapTaskDaemon 活动和 CPU 频率。
  4. 用 allocation profile 找到同一场景的高频调用栈。
  5. 排除锁竞争、I/O、Binder、thermal throttling(温控限频)等同时发生的原因。

HeapTaskDaemon(ART 堆任务线程)活跃且附近出现慢帧,只能说明两者时间相近。还要结合线程状态、GC slice(GC 轨迹片段)和修复前后对照,才能判断因果关系。

不要在 App 中复制 GcWatcher

Android 17 的 BinderInternal.GcWatcher 仍通过 WeakReferencefinalize() 感知 GC,并向 framework 内部 watcher(观察者)分发回调。BinderInternal 属于隐藏 API,finalization(终结处理)的执行时间也没有确定性。应用不应复制这种模式做线上 GC 计数,更不应把 watcher 的 finalize() 当作每次 GC 的精确通知。

开发期使用 Android Studio、Perfetto 和 ART 日志;线上只保留经过开销评估的场景指标。Android 17 的 ProfilingManager 可提供受系统控制的 profile artifact(分析产物),但同样不构成实时 GC 回调 API。

优化策略

优先删除分配

收益最高且风险较低的改动通常是:

  • 把逐帧创建的绘制对象变为 View 或 renderer(渲染器)的成员。
  • 移除不必要的中间集合和 DTO。
  • 给已知规模的数组、集合、builder(构建器)设置合理初始容量。
  • 让相机、音视频与网络 buffer 按清晰所有权复用。
  • 关闭的日志在调用点跳过字符串与参数构造。
  • 密集数值路径使用原始类型数组,减少泛型装箱。

预分配会把成本移到更早的时间,并增加对象存活期。启动路径已经紧张或对象很少使用时,预分配可能让结果变差;应在目标场景基准中评估。

对象池是受约束的优化

Android framework 的 Message.obtain()MotionEvent.obtain() 等 API 使用池化,因为对象创建频繁、状态可重置且所有权协议明确。业务对象只有满足相似条件时才适合放入对象池:

  • profiler 已确认分配点占据显著成本;
  • 对象构造或 backing storage(底层存储)成本较高;
  • 同时在用的实例数有稳定上限;
  • acquire/release(获取/归还)所有权能被审查;
  • 每个字段都能可靠重置;
  • 池命中率、容量与内存占用可观测。

池会延长对象存活期,也可能增加同步、清理和泄漏风险。小型不可变对象或构造很便宜的对象通常交给 ART 更合适。池大小不能照搬 16、32 等经验数字,应由并发峰值和命中率决定。

不调用 System.gc() 治理抖动

System.gc() 只是向虚拟机提出 GC 请求,既不保证立即执行,也不消除分配源。主动请求可能把回收安排到用户操作期间。测试工具可在明确目的下触发 GC;产品代码应修正分配速率、生命周期和容量。

Kotlin value class 的边界

@JvmInline value class 在 JVM 上可用底层值表示,适合给原始类型增加类型安全,同时避免部分 wrapper(包装对象)。下面的类型在直接参数和局部变量路径上通常可用 long 表示。

kotlin
@JvmInline
value class UserId(val raw: Long)

Kotlin 官方文档明确列出需要装箱的场景:作为泛型类型、接口类型或 nullable value class 使用。ListComparable 参数和 UserId? 都可能产生 wrapper。value class 是减少特定路径装箱的工具,不会自动让泛型集合变成原始类型存储;高频数值处理仍可评估 LongArray

Jetpack Compose 中的分配

Compose Runtime 通过 SlotTable/LinkTable(记录组合树结构的数据表)、RecomposeScope(重组作用域)和 snapshot state(快照状态)保存组合结构。结构本身会占用内存;业务更常见的 churn 来自重组时重复执行的构造代码、不断变化的参数身份和不必要的状态派生。

remember 解决的是重组期重建

在 composable(可组合函数)中直接创建集合、格式化器或状态对象,会在该代码重新执行时创建新实例。需要跨重组保存的对象可用 remember(keys) 缓存,并让 key 精确描述对象何时失效。

下面的示例让 formatter 只在 locale 改变时重建。

kotlin
@Composable
fun PriceText(
    price: BigDecimal,
    locale: Locale,
) {
    val formatter = remember(locale) {
        NumberFormat.getCurrencyInstance(locale)
    }
    Text(formatter.format(price))
}

remember 会延长对象在 composition(组合树)中的存活时间,不适合缓存无限增长的数据。key 过于频繁变化时,缓存会反复失效;key 缺失时,又可能读到与新输入不匹配的旧对象。

strong skipping 减少无效重组

Kotlin 2.0.20 起默认启用 Compose strong skipping(强跳过模式)。启用后,restartable composable(可重启的可组合函数)可被标记为 skippable(参数未变时可跳过),具有 unstable capture(捕获不稳定对象)的 Lambda 也会被编译器记忆。参数比较规则仍有差异:unstable 参数使用实例相等,stable 参数使用对象相等。

不要为了跳过重组随意添加 @Stable。它是一份行为契约:公开属性变化必须能被 Compose 感知,equals() 也要满足约定。错误标注可能让 UI 漏掉更新。可通过 Compose compiler reports(编译器报告)检查稳定性推断,再优化频繁重组的节点。

derivedStateOf 有适用条件

derivedStateOf 适合输入变化频率高、输出变化频率低的场景,例如滚动位置持续变化,而按钮只关心“是否离开列表顶部”。它本身有维护依赖和计算状态的成本;如果两个输入每次变化都要求更新拼接结果,直接计算通常更简单。

Compose 官方示例把 derivedStateOf 放在 remember 中。Compose Runtime 1.12.0-beta01 修复了一个潜在保留问题:未正确 remember 的 derived state(派生状态)在 forward write(前向写入)路径上可能被 composition 持有到销毁。该修复已包含在 2026-07-29 发布的 1.12.0-rc01;生产升级仍要遵循团队对 RC(候选发布版)与稳定版的依赖政策。

原始类型 state

计数器等状态可使用 mutableIntStateOf()mutableLongStateOf() 等原始类型 state API,并通过 intValuelongValue 访问,减少 MutableState 在 JVM 路径上的装箱机会。仍需用 allocation profile 验证热点;UI 状态读写频率和重组范围通常比单个 wrapper 更值得先看。

Compose 与 Android 17 的版本关系

Compose 通过 AndroidX 发布,不跟 platform API 一一绑定。Compose Runtime 1.12.0-alpha01 把库的 compileSdk 更新到 API 37,并要求至少使用 AGP(Android Gradle Plugin)9.2.0;这属于构建依赖边界,不能据此推断 Android 17 设备拥有专属的 SlotTable、LinkTable 或 Lazy list GC 行为。

验证优化

优化前后使用同一套脚本和数据:

维度 建议指标
分配 每次业务操作的 allocation count、allocation bytes、热点调用栈占比
GC 事件次数、暂停区间、blocking GC/allocation wait、GC 线程 CPU
Frame Timeline 的 missed frame、帧时长分位数
资源 Java heap、native heap、graphics、RSS/PSS 与 CPU 频率
正确性 对象池状态、并发、画面、数据一致性和生命周期

只比较峰值 heap 容易漏掉“总分配量下降但存活集上升”。只比较 GC 次数也会受 heap 上限、收集器和设备配置影响。目标场景的分配、GC、帧时间与正确性同时验收,结论才可复用。

排查清单

  • 高分配发生在启动、滚动、动画、相机、编解码还是后台任务?
  • 对象是短命、长期存活,还是被缓存/池持有?
  • 排序依据是 allocation count、bytes 还是 live objects?
  • 慢帧时间窗内是否存在 GC pause、allocation wait 或 GC CPU 竞争?
  • profiler 自身是否改变了应用速度或内存分类?
  • onDraw()、循环和日志是否仍有可移出的对象构造?
  • 装箱是否经过 allocation call stack 证实?
  • 对象池是否有容量、重置、所有权和并发规则?
  • Compose 的 remember key、参数身份和重组范围是否符合预期?
  • 修复后是否在相同构建、设备、数据和操作脚本下复测?

与其他章节的关系

  • 4.2 ART 虚拟机内存管理:CC、CMC、TLAB、RegionTLAB 与 GC 日志。
  • 7.1/7.2 卡顿分析:Frame Timeline、线程状态与 CPU 竞争。
  • 10.1 App 内存分析与案例:Java、native、graphics 与 mmap 的分类工具,缓存、pool、碎片和虚拟地址空间的增长诊断,以及 MemoryThrashing 的平台边界与差分采样。
  • 23.2 内存泄漏检测与治理:存活对象、GC Root 与引用所有权。
  • 10.2 低内存影响:进程内 GC 与整机回收压力的区别。

参考资料

正文《Android 技术内幕》作者 高建武(Gracker),原仓库 Gracker/android-internals-wiki,以 CC BY-NC-SA 4.0 授权。本站是个人非商业阅读版改编,非官方站点。正文字体 霞鹜文楷(SIL OFL 1.1,授权全文)。