如何通过减少临时对象创建来降低 GC 频率?请列举具体编码实践
解读
国内 Android 面试中,性能题几乎必问,而“GC 抖动导致掉帧”是高频扣分点。面试官想确认两点:
- 你能否把“16 ms 帧预算”与“临时对象→GC→Stop-The-World→掉帧”这条因果链讲清楚;
- 你能否给出在 Kotlin/Java 真实业务代码里“看得见、摸得着”的优化套路,而不是背官方文档。
回答时先抛结论:减少临时对象的核心思路是“复用、延迟、压缩、消除”,再按“数据结构→算法→语法糖→框架”四层展开,每个点都要给出可直接落地的代码片段或改造案例,最后主动补一句“线上用 Perfetto 验证 GC 次数下降 30 %以上”,面试官基本就过了。
知识点
- 内存分配路径:TLAB → Eden → GC Root 标记 → Copying/Compact → Stop-The-World 耗时。
- 临时对象判定:方法栈内创建、生命周期小于 1 帧(16 ms)、无逃逸可被 JIT 栈上分配但 ART 不一定触发。
- 高频场景:
- onDraw、onBindViewHolder、RecyclerView 滚动、动画更新
- StringBuilder、Boxing、Iterator、Lambda 闭包、协程 Continuation
- 工具链:
- 本地 Memory Profiler 看 Allocation 数量
- 线上 Tinker+Perfetto 抓 system_server 的 GC 事件
- 国内厂商机型需关注 Huawei Ark、OPPO HyperBoost 对 GC 策略的魔改
- Kotlin 特有:
- inline + reified 可减少反射对象
- crossinline 避免匿名类
- suspendCoroutine 创建 SafeContinuation 对象每挂起一次
答案
一、数据结构层:把“短命对象”变“长命池”
- 对象池:对 RecyclerView 的 onBindViewHolder 里高频创建的 Paint、Path、Rect 使用 Pools.SimplePool 或自己写的 SynchronizedPool,池大小按“屏幕最多可见 item 数 + 2”设置,避免同步开销。
private val paintPool = Pools.SimplePool<Paint>(8) fun obtainPaint(): Paint = paintPool.acquire() ?: Paint().apply { isAntiAlias = true } fun recyclePaint(paint: Paint) { paint.reset(); paintPool.release(paint) } - 数组复用:对 Kotlin 的 IntArray 拼接场景,预分配 capacity,用 System.arraycopy 代替 slice/+.
- 字符串压缩:日志埋点用 StringInterner 把重复 URI 常量 intern 到常量池,减少 HashMap 的 Key 对象。
二、算法层:把“隐式装箱”变“显式原生”
- 避免 Int→Integer:SparseArray 代替 HashMap<Integer, T>;Kotlin 侧用 Int2ObjectArrayMap(DK 内部库)或 androidx.collection.IntList。
- 避免 Iterator:for (i in 0 until size) 代替 for (item in list),减少 Iterator 实例;对 Map 用 entrySet 缓存。
- 避免 vararg 装箱:方法内部如果确定参数长度 <= 5,写 5 个重载方法(Int, Int, Int…)代替 vararg Int,抖音 apk 体积换 GC 收益实测可接受。
三、语法糖层:把“语法糖对象”变“内联字节码”
- Lambda 复用:把 lambda 提为 private val 成员,避免每次 setOnClickListener { } 创建新实例;对需要捕获变量的,用 View.setTag+id 存弱引用,减少闭包对象。
- 字符串拼接:在 onDraw 里用 ThreadLocal<StringBuilder>,先 setLength(0) 再 append,杜绝 “+” 创建 StringBuilder+String 两个对象。
- Kotlin 协程:对高频触发的 UI 事件流(如滚动监听)使用 channelFlow + buffer(Channel.RENDEZVOUS) 把 Continuation 对象复用,避免默认 Channel.UNCAPED 创建大量 DispatchedContinuation。
四、框架层:把“框架临时对象”变“官方优化开关”
- Jetpack Compose:开启 compiler 参数 -Pandroidx.compose.compiler.plugins.kotlin:metricsDestination=build/compose-metrics,检查 restartable 函数是否标记为 @NonRestartable,减少 RecomposeScopeImpl 创建。
- Room:查询返回的 Cursor 用 copyToArray 一次性转成 Array<T>,避免 Kotlin 的 Sequence 每次迭代创建 Iterator。
- WorkManager:对批量任务用 OneTimeWorkRequest.Builder.setInputMerger(ArrayCreatingInputMerger::class) 代替默认,减少 Data.Builder 内部 HashMap 拷贝。
五、验证与灰度
- 本地:Memory Profiler 打开“Record Java/Kotlin Allocations”,滚动 RecyclerView 10 s,过滤包名后看 Allocation 数量,目标 < 500 次/秒。
- 线上:Tinker 灰度 5 %,Perfetto 抓 30 min,对比 GC 次数与掉帧率,国内低端机(红米 9A、荣耀畅玩 20)GC 次数下降 30 %以上即可全量。
拓展思考
- 在 Kotlin 1.9 的 K2 编译器中,逃逸分析栈上分配能力增强,可尝试开启 -Xvalue-classes 把包装类 inline 成原生,进一步把 GC 压力降到 native 层。
- 国内厂商对 ART 的修改(如华为 Ark Compiler)会把热点方法 AOT 成机器码并做栈上分配,但低概率触发 deoptimize 回解释器,此时临时对象仍会回退到堆分配,需用 systrace 看“deoptimize”标签,针对性加 @JvmStatic 避免回退。
- 对大型直播礼物动画这种“每帧 200+ Path”场景,对象池可能因锁竞争成为新瓶颈,可改用 ThreadLocal+Striped 池,或直接把动画下沉到 RenderThread 用 OpenGL 纹理缓存,彻底绕过 Java 堆。