【HarmonyOS开发小实践】HPP GC 深入:分代模型、混合算法与 Heap 结构
HPP GC 深入:分代模型、混合算法与 Heap 结构
知道了对象追踪和三种回收算法,再看 HPP GC 就是在看一个工程化方案:怎么把这些算法组合起来,既压低 STW 时间,又控制碎片,还保证吞吐。HPP GC(High Performance Partial Garbage Collection)是 ArkTS 运行时的 GC 实现,名字里的 High Performance 来自三个设计——分代模型、混合算法、流程并发化。这篇把这三块和 Heap 结构、触发策略、执行策略都拆开看。
设计目标
HPP GC 要同时优化三个互相打架的指标:
- 低延迟:单次 GC 暂停时间短,不卡 UI。Young GC 目标毫秒级,Old GC 几毫秒到十几毫秒。
- 高吞吐:GC 占总 CPU 时间比例低,应用跑得快。
- 低内存占用:堆别浪费,浮动垃圾别太多。
三个指标互相制约。低延迟要求 GC 切碎成小段并发跑,但并发本身有协调开销,伤吞吐;高吞吐要求 GC 别太频繁,但间隔长浮动垃圾多,伤内存占用。HPP GC 的做法是分代+混合算法+并发标记,针对不同区域用不同策略,在三个指标间找平衡。
分代模型
经验观察:大多数对象朝生夕死,分配后很快变垃圾;少数对象活得久,一旦熬过几轮 GC 就一直活着。这就是分代的依据——把短命对象和长寿对象分开存放,分别回收。
ArkTS 运行时的分代:
- YoungSpace(年轻代,又叫 SemiSpace):新对象在这分配。分成 From 和 To 两个半区,GC 时用复制算法把存活对象从 From 复制到 To,然后交换。存活率低,复制开销小。
- OldSpace(老年代):年轻代熬过两轮 GC 的对象晋升到这里。存活率高,用混合算法(整理+清扫)。
- HugeObjectSpace(大对象空间):超过阈值的大对象直接放这,每个对象独占一个 Region。
分代的好处是回收频率差异化:Young GC 频繁但快,Old GC 不频繁但慢,Full GC 很少触发。大部分 GC 是 Young GC,开销小到应用无感。
混合算法
HPP GC 不是单一算法,是"部分复制 + 部分整理 + 部分清扫"的混合,按区域选算法。
年轻代:标记-复制
年轻代对象生命周期短、回收频繁、大小有限,用复制算法最合适。From/To 两个半区,GC 时存活对象从 From 复制到 To,From 整个清空。无碎片,复制量小(存活少)。
老年代:部分整理 + 部分清扫
老年代用启发式 CSet(Collection Set)选择算法。思路是:不一次性整理整个老年代(太慢),而是挑存活率低的几个 Region 做整理(复制存活对象到别的 Region,整个 Region 回收),剩下的 Region 做清扫(标记垃圾放回空闲链表)。
具体策略:
- 标记阶段统计每个 Region 的存活对象大小。
- 把存活率(存活对象大小 / Region 大小)低于阈值的 Region 纳入候选 CSet,按存活率从低到高排序。
- 根据释放 Region 数量阈值,从候选里选出最终 CSet,做整理回收。
- 没选进 CSet 的 Region 做清扫回收。
这个策略结合了整理(无碎片)和清扫(快)的优点。存活少的 Region 整理代价小(要复制的存活对象少),整理完整个 Region 释放,无碎片;存活多的 Region 清扫,不移动对象,快。
流程并发化
HPP GC 把 GC 流程拆成多个阶段,尽量让标记和清扫并发跑,减少主线程暂停:
- 并发+并行标记:标记对象图时,大部分工作在 GC 线程池并发跑,主线程只在标记开始和结束短暂暂停。
- 并发+并行清扫:清扫空闲 Region 在 GC 线程池跑。
- 并行复制/整理:移动存活对象时多线程并行。
- 并行回改:更新被移动对象的引用。
- 并发清理:最后清理工作并发跑。
并发标记是降延迟的关键。对象图遍历是最耗时的部分,让它和应用线程并发跑,主线程暂停就只剩标记开始时的根扫描和标记结束时的再标记,都是毫秒级。
GC 流程
一次完整的 GC(以 Old GC 为例)大致流程:
主线程只在"初始标记"和"再标记"两个短暂时刻暂停,其余阶段并发跑。这就是 HPP GC 能把 STW 压到毫秒级的原因。
HPP GC 的三种类型
| 类型 | 触发 | 耗时 | 场景 | 日志关键词 |
|---|---|---|---|---|
| Young GC | YoungSpace 阈值 2-16MB | 毫秒级 | 前台 | [ HPP YoungGC ] |
| Old GC | OldSpace 阈值 20-300MB | 5-10ms | 前台 | [ HPP OldGC ] |
| Full GC | 后台切换、手动触发 | 较长 | 后台 | [ CompressGC ] |
Young GC:只回收 YoungSpace,复制算法,最快最频繁。绝大多数 GC 都是 Young GC。
Old GC:回收年轻代 + 部分老年代,整理+清扫混合。频率低,单次 5-10ms。会做全量标记,所以比 Young GC 慢。
Full GC:年轻代+老年代全量压缩,最大限度回收内存。不在前台触发(太慢),主要在切后台、DumpHeapSnapshot、手动 ArkTools.hintGC() 时跑。
Smart GC 和 Idle GC 不是新类型,是在这三种里选——敏感场景用 Smart GC 抑制触发,空闲时用 Idle GC 主动回收。
Heap 结构
Heap 分两种:LocalHeap 和 SharedHeap。
LocalHeap
每个 ArkTS 线程独有一个 LocalHeap,存这个线程的对象。
各 Space 的职责:
| Space | 存什么 | 回收方式 |
|---|---|---|
| YoungSpace | 新对象 | 复制算法,频繁 |
| OldSpace | 晋升的老对象 | 整理+清扫混合 |
| HugeObjectSpace | 大对象,每个独占 Region | 整个 Region 回收 |
| ReadOnlySpace | 运行期只读数据 | 不回收 |
| NonMovableSpace | 不可移动对象(有外部引用) | 清扫 |
| SnapshotSpace | 堆快照转储用 | 按需 |
| MachineCodeSpace | JIT 机器码 | 按需 |
每个 Space 由一个或多个 Region 组成,Region 是向内存分配器申请的单位。Region 大小固定,分配时找空闲 Region,释放时整块归还。
SharedHeap
所有 ArkTS 线程共享一个 SharedHeap,存跨线程共享的对象(如 Sendable 对象)。
SharedHeap 没有 SemiSpace——共享对象通常活得久,不需要年轻代复制算法。共享对象直接进 SharedOldSpace,用整理+清扫。
SharedHeap 的意义:多 Worker 线程共享同一份只读数据(如配置表、常量数组)时,不用每个线程复制一份,省内存,访问也快。
Heap 参数
LocalHeap 关键参数(手机设备默认值):
| 参数 | 值 | 说明 |
|---|---|---|
| HeapSize | 448MB | 主线程堆总大小 |
| SemiSpaceSize | 2-16MB | 年轻代半区大小 |
| NonmovableSpaceSize | 2-64MB | 不可移动空间 |
| gcThreadNum | 7 | GC 线程数 |
| longPauseTime | 40ms | 超长 GC 阈值,超过会打完整日志 |
Worker 线程堆上限 768MB,比主线程大,因为 Worker 通常干重活。
SharedHeap 参数:
| 参数 | 值 |
|---|---|
| SharedHeapSize | 778MB |
| SharedOldSpaceSize | 约 350MB |
| SharedNonMovableSpaceSize | 64MB |
这些参数多数由系统设定,开发者不能直接改。能查的接口是 @ohos.hidebug 和 @ohos.util.getAllVMHeapMemoryInfo,运行时拿到实际值。
触发策略
GC 不是定时跑的,是按需触发。几种触发条件:
空间阈值触发:对象分配到 Space 阈值就触发对应 GC。YoungSpace 满了触发 Young GC,OldSpace 满了触发 Old GC。日志里是 GCReason::ALLOCATION_LIMIT。
Native 绑定大小触发:堆内对象绑定的 Native 内存(如 ArrayBuffer 的 Native buffer)达到阈值,触发 GC 顺便回收 Native 内存。
切后台触发:应用切到后台,主动触发一次 Full GC——前台时为了不卡用户忍着不回收,到后台没性能压力了就清干净。日志里是 GCReason::SWITCH_BACKGROUND。
空闲触发:应用空闲时(没在交互),根据内存使用情况选合适的 GC 类型主动跑。日志里是 GCReason::IDLE。这是 Idle GC,趁空闲把浮动垃圾清掉,避免下次忙的时候被迫 GC。
执行策略
触发之后怎么执行,也有几条策略:
ConcurrentMark:尽量把标记阶段并发化。标记是最耗时的部分,并发跑能大幅减少主线程暂停。日志关键词 trigger full mark、Trigger semi mark。
动态阈值调整:GC 后根据存活率调整下次触发阈值。Young GC 后调 YoungSpace 阈值,Old GC 后调 OldSpace 和 GlobalSpace 阈值。日志里 AdjustOldSpaceLimit、RecomputeLimits。这样 GC 能自适应——对象分配快时阈值调小,频繁 GC;分配慢时阈值调大,减少 GC。
CSet 选择:Partial Old GC 时按存活率选 Region,优先回收存活少的 Region。日志里 Select CSet success: number is ...。
前台 vs 后台:前台场景优先低延迟,用 Young GC 和 Old GC,并发标记;后台场景优先回收彻底,用 Full GC,全量压缩。
案例:分代模型下对象的生命周期
写段代码,看对象怎么在分代之间流转:
class ShortLived {
data: number[] = new Array(100);
}
class LongLived {
cache: Map<string, number> = new Map();
}
// 全局持有,会进老年代
const longLived = new LongLived();
function businessLogic() {
// 临时对象,函数返回后从 Root 不可达
for (let i = 0; i < 10000; i++) {
const tmp = new ShortLived(); // 进 YoungSpace From
// 用完 tmp,函数继续
}
// 函数返回,tmp 们不可达,下次 Young GC 回收
}
// 主循环
for (let i = 0; i < 100; i++) {
businessLogic(); // 每轮产生 1 万个短命对象
longLived.cache.set(`k${i}`, i); // longLived 持续增长,留在老年代
}
GC 行为:
businessLogic里的 1 万个 ShortLived 进 YoungSpace From,函数返回后不可达。下次 Young GC 时 From 区几乎全是垃圾,复制到 To 的存活对象极少,开销小。longLived是全局变量,从 Root 一直可达,熬过多次 Young GC 后晋升到 OldSpace。它的 cache Map 持续增长,待在老年代。- Young GC 频繁但快,主要回收 ShortLived。Old GC 偶尔触发,整理老年代里 cache 增长留下的碎片。
如果反过来,把 ShortLived 也存到全局:
const leak: ShortLived[] = [];
for (let i = 0; i < 100000; i++) {
leak.push(new ShortLived()); // 全部晋升到老年代
}
这些对象从 Root 可达(通过 leak 数组),不会被回收,全晋升到 OldSpace。老年代迅速膨胀,触发 Old GC,但 GC 发现存活率 100%,整理完没释放多少,下次很快又触发——这就是 GC 频繁的一个典型原因:本该短命的对象被长期引用了。
实践中要注意的
- 全局容器是泄漏高发区:Map、Set、Array 存到全局变量,往里塞对象容易,忘了删就一直占着。用 WeakMap、WeakSet 持有非强引用,或者定期清理。
- 大对象直接进 HugeObjectSpace:几 MB 的 ArrayBuffer 不走年轻代,直接进大对象空间,每个独占 Region。大对象频繁分配回收会让 HugeObjectSpace 的 Region 反复申请释放,关注这块日志。
- Sendable 对象进 SharedHeap:跨 Worker 传递的 Sendable 对象在 SharedHeap,不在 LocalHeap。SharedHeap 没 SemiSpace,这些对象不会走 Young GC,回收靠 SharedOldSpace 的整理+清扫。
- Worker 线程有自己的 LocalHeap:每个 Worker 独立堆,独立 GC。主线程 GC 不会暂停 Worker,反过来也是。但 SharedHeap 的 GC 会影响所有线程。
几条经验哦
- Young GC 频繁是正常的,别看到日志里一串 Young GC 就紧张。Young GC 设计目标就是频繁但快,单次 1-2ms 不影响体验。
- Old GC 频繁才是问题。如果 Old GC 几秒一次甚至更密,说明老年代膨胀太快,要么是短命对象被长期引用,要么是缓存无界增长,回业务代码查。
- Full GC 在前台触发是异常。正常 Full GC 只在切后台时跑。前台跑 Full GC 说明 Old GC 顶不住了,老年代碎片严重或内存压力极大,要查内存使用。
- GC 日志里
Heap alive rate(存活率)是关键指标。Young GC 后存活率应该很低(个位数 %),如果很高说明年轻代对象都在晋升,分代失效,得查为什么这些对象活得久。
更多推荐


所有评论(0)