第十五章:GC策略与调优——触发条件、暂停时间优化、GC日志分析

说到ART的GC,我其实挺感慨的。早年做Dalvik优化时,GC一触发,用户界面就跟幻灯片似的。后来ART引入了并发标记和紧凑策略,情况好了很多。但说实话,GC依然是Android性能优化的核心战场。

这一章,咱们就聊聊GC的触发条件、怎么优化暂停时间,以及如何通过日志定位问题。嗯,都是实战中摸爬滚打出来的经验。

15.1 GC触发条件:什么时候开始回收?

ART的GC触发,不是随机的。它有一套明确的策略。我个人习惯把触发条件分为三类:

  • 分配失败触发:最常见的情况。当新对象分配时,如果当前Region(或TLAB)空间不足,就会触发GC。说白了就是“内存不够了,赶紧腾地方”。
  • 显式调用触发:代码里调了System.gc()。嗯,这个我建议少用。我曾经在一个图片加载库中看到频繁调用System.gc(),结果GC把用户界面卡得不要不要的。
  • 内存压力触发:系统整体内存紧张时,AMS(ActivityManagerService)会向ART发送“内存压力”信号,触发GC。这种情况在低端机上尤其常见。

核心知识点:ART的GC触发,本质上是“分配速率”与“回收速率”的博弈。分配太快,GC就频繁;回收太慢,内存就吃紧。

你想想看,如果应用在短时间内创建了大量短生命周期对象(比如在onDraw()里new对象),GC就会被反复触发。这就是为什么我总强调“避免在UI线程分配对象”。

15.2 暂停时间优化:让GC“静悄悄”地干活

暂停时间(Pause Time)是GC优化的核心指标。ART的GC分为并发阶段和暂停阶段。并发阶段不暂停应用线程,但暂停阶段会。暂停时间越短,用户感知到的卡顿就越少。

我记得有一次,一个视频播放应用在播放过程中每隔几秒就卡一下。查了GC日志,发现暂停时间达到了80ms。嗯,这已经很明显了。

15.2.1 暂停时间从哪里来?

ART的暂停阶段主要做三件事:

  1. 根集扫描:遍历所有线程的栈、JNI全局引用等,标记存活对象。线程越多,暂停时间越长。
  2. 并发标记的最终处理:处理并发标记阶段遗漏的引用更新。
  3. 紧凑(Compaction):移动对象,减少内存碎片。这个最耗时。

优化技巧:减少线程数量、使用--background-thread-priority调整GC线程优先级、开启--concurrent-marking,都能有效降低暂停时间。

15.2.2 实战优化策略

我总结了几条实用的优化策略:

  • 减少分配速率:这是最根本的。用对象池、避免在循环中创建对象、使用SparseArray替代HashMap。我曾经把一个频繁创建对象的模块改成对象池,GC频率直接降了60%。
  • 调整堆大小:通过dalvik.vm.heapstartsizedalvik.vm.heapsize调整初始堆和最大堆。堆太小,GC频繁;堆太大,单次GC时间长。需要找到平衡点。
  • 使用--gc-policy参数:ART支持多种GC策略,比如kGcCollectorTypeConcurrentMarkSweepkGcCollectorTypeAllocSticky。Sticky GC只回收最近分配的对象,暂停时间极短。

避坑指南:我曾经在某个项目里把堆设得特别大,以为能减少GC。结果GC暂停时间反而变长了,因为每次GC要扫描的内存区域更大。嗯,堆大小不是越大越好。

15.3 GC日志分析:从日志中读懂GC

GC日志是定位问题的第一手资料。ART的GC日志格式很清晰,但需要知道怎么看。

15.3.1 日志格式解读

典型的GC日志长这样:

I/art: GC_ALLOC_FOR_ALLOC freed 1024K, 5% free 20480K/21504K, paused 3ms total 15ms

我来拆解一下:

字段 含义 我的经验
GC_ALLOC_FOR_ALLOC GC类型,这里是分配失败触发的并发标记 看到这个,说明分配速率可能过高
freed 1024K 本次回收的内存大小 如果回收量很小,说明内存泄漏风险高
5% free 堆空闲比例 低于10%就要警惕了
paused 3ms 暂停时间 超过16ms就会掉帧
total 15ms GC总耗时 包含并发阶段

15.3.2 如何通过日志定位问题?

我一般会关注几个关键指标:

  • 暂停时间是否超过16ms:超过16ms,意味着用户界面可能掉帧。如果频繁出现,就得优化了。
  • GC频率是否过高:如果每秒触发多次GC,说明分配速率太快。这时候需要检查代码中是否有频繁创建对象的场景。
  • 堆空闲比例是否持续偏低:如果每次GC后空闲比例都低于10%,说明堆太小或存在内存泄漏。

举个例子,我曾经遇到一个应用,GC日志里频繁出现GC_FOR_ALLOC,暂停时间在10ms左右。但堆空闲比例一直只有3%。嗯,这明显是堆太小了。调整dalvik.vm.heapstartsize后,GC频率降下来了,暂停时间也稳定在5ms以内。

15.4 知识体系:GC策略与调优全景图

下面这张图,是我梳理的GC策略与调优的核心逻辑。你可以把它当作一个检查清单。

GC策略与调优 触发条件 • 分配失败触发 • 显式调用触发 • 内存压力触发 暂停时间优化 • 减少分配速率 • 调整堆大小 • 使用Sticky GC • 减少线程数量 GC日志分析 • 暂停时间 > 16ms • GC频率过高 • 堆空闲比例低 • 回收量异常 核心目标:降低暂停时间,减少GC频率 分配速率 ↔ 回收速率 的平衡

这张图把GC调优的三个核心维度串起来了。触发条件是“因”,暂停时间是“果”,日志分析是“诊断工具”。三者缺一不可。

15.5 实战案例:一次GC调优的全过程

最后,分享一个我实际做过的调优案例。

某社交应用,在滑动Feed流时明显卡顿。我抓了GC日志,发现:

  • GC类型:GC_FOR_ALLOC,每秒触发3-4次
  • 暂停时间:平均12ms,最高到25ms
  • 堆空闲比例:每次GC后只有4%-6%

嗯,问题很明显:堆太小,分配太快。

我的优化步骤:

  1. 调整堆参数:将dalvik.vm.heapstartsize从8MB调到16MB,dalvik.vm.heapsize从256MB调到384MB。
  2. 优化代码:在Feed流Adapter中,把onBindViewHolder()里的对象创建移到onCreateViewHolder()中,用对象池复用。
  3. 启用Sticky GC:在AndroidManifest中设置android:largeHeap="true",并调整GC策略参数。

优化后,GC频率降到每秒不到1次,暂停时间稳定在3ms以内。用户反馈卡顿问题基本消失。

我的建议:GC调优没有银弹。每个应用的内存分配模式不同,需要结合GC日志和实际场景来调整。我习惯先抓日志,再分析,最后动手改。嗯,顺序很重要。


公众号:蓝海资料掘金营,微信deep3321