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 运行时的分代:

直接进

一次 GC 后存活

再次 GC 后存活

长期存活

新分配对象

YoungSpace
From 区

YoungSpace
To 区

OldSpace
老年代

  • 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 做清扫(标记垃圾放回空闲链表)。

具体策略:

  1. 标记阶段统计每个 Region 的存活对象大小。
  2. 把存活率(存活对象大小 / Region 大小)低于阈值的 Region 纳入候选 CSet,按存活率从低到高排序。
  3. 根据释放 Region 数量阈值,从候选里选出最终 CSet,做整理回收。
  4. 没选进 CSet 的 Region 做清扫回收。

标记阶段
统计各 Region 存活大小

存活率低于阈值
纳入候选 CSet

按存活率排序
选最终 CSet

CSet 内 Region
整理回收

CSet 外 Region
清扫回收

完成

这个策略结合了整理(无碎片)和清扫(快)的优点。存活少的 Region 整理代价小(要复制的存活对象少),整理完整个 Region 释放,无碎片;存活多的 Region 清扫,不移动对象,快。

流程并发化

HPP GC 把 GC 流程拆成多个阶段,尽量让标记和清扫并发跑,减少主线程暂停:

  • 并发+并行标记:标记对象图时,大部分工作在 GC 线程池并发跑,主线程只在标记开始和结束短暂暂停。
  • 并发+并行清扫:清扫空闲 Region 在 GC 线程池跑。
  • 并行复制/整理:移动存活对象时多线程并行。
  • 并行回改:更新被移动对象的引用。
  • 并发清理:最后清理工作并发跑。

并发标记是降延迟的关键。对象图遍历是最耗时的部分,让它和应用线程并发跑,主线程暂停就只剩标记开始时的根扫描和标记结束时的再标记,都是毫秒级。

GC 流程

一次完整的 GC(以 Old GC 为例)大致流程:

HeapGC 线程池应用线程HeapGC 线程池应用线程短暂 STW初始标记应用继续跑短暂 STW再标记分配对象触发阈值触发 GC释放主线程并发标记对象图标记完成并行整理 CSet并行清扫其余 Region并发清理GC 完成

主线程只在"初始标记"和"再标记"两个短暂时刻暂停,其余阶段并发跑。这就是 HPP GC 能把 STW 压到毫秒级的原因。

HPP GC 的三种类型

类型触发耗时场景日志关键词
Young GCYoungSpace 阈值 2-16MB毫秒级前台[ HPP YoungGC ]
Old GCOldSpace 阈值 20-300MB5-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,存这个线程的对象。

LocalHeap

YoungSpace
SemiSpace From/To

OldSpace

HugeObjectSpace

ReadOnlySpace

NonMovableSpace

SnapshotSpace

MachineCodeSpace

各 Space 的职责:

Space存什么回收方式
YoungSpace新对象复制算法,频繁
OldSpace晋升的老对象整理+清扫混合
HugeObjectSpace大对象,每个独占 Region整个 Region 回收
ReadOnlySpace运行期只读数据不回收
NonMovableSpace不可移动对象(有外部引用)清扫
SnapshotSpace堆快照转储用按需
MachineCodeSpaceJIT 机器码按需

每个 Space 由一个或多个 Region 组成,Region 是向内存分配器申请的单位。Region 大小固定,分配时找空闲 Region,释放时整块归还。

SharedHeap

所有 ArkTS 线程共享一个 SharedHeap,存跨线程共享的对象(如 Sendable 对象)。

SharedHeap

SharedOldSpace

SharedHugeObjectSpace

SharedReadOnlySpace

SharedNonMovableSpace

SharedHeap 没有 SemiSpace——共享对象通常活得久,不需要年轻代复制算法。共享对象直接进 SharedOldSpace,用整理+清扫。

SharedHeap 的意义:多 Worker 线程共享同一份只读数据(如配置表、常量数组)时,不用每个线程复制一份,省内存,访问也快。

Heap 参数

LocalHeap 关键参数(手机设备默认值):

参数值说明
HeapSize448MB主线程堆总大小
SemiSpaceSize2-16MB年轻代半区大小
NonmovableSpaceSize2-64MB不可移动空间
gcThreadNum7GC 线程数
longPauseTime40ms超长 GC 阈值,超过会打完整日志

Worker 线程堆上限 768MB,比主线程大,因为 Worker 通常干重活。

SharedHeap 参数:

参数值
SharedHeapSize778MB
SharedOldSpaceSize约 350MB
SharedNonMovableSpaceSize64MB

这些参数多数由系统设定,开发者不能直接改。能查的接口是 @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。

分配达阈值

Young/Old GC

Native 绑定达阈值

触发 GC

切后台

Full GC

应用空闲

Idle GC
选 Young/Old/Full

执行策略

触发之后怎么执行,也有几条策略:

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 后存活率应该很低(个位数 %),如果很高说明年轻代对象都在晋升,分代失效,得查为什么这些对象活得久。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐