HarmonyOS GC 引用计数 vs 对象追踪,三种回收算法

手动管内存的年代,C 程序员一半的 bug 都来自忘记 free 或者 free 了又用。GC 把这件事自动化了,但自动化的方式不止一种。ArkTS 运行时选了对象追踪这条路,原因是什么,得先把两大类算法的优缺点摆出来看。这篇就把引用计数、对象追踪,以及对象追踪下的三种回收算法讲透,给后面看 HPP GC 打底。

为什么需要 GC

程序运行时在堆上分配对象,用完了得回收,否则内存越占越多最终 OOM。手动管理(C/C++ 的 malloc/free、new/delete)要求开发者精确知道每个对象什么时候不再被使用,这在有复杂引用关系的程序里极难做到:

  • 释放早了:还有别的地方持有引用,访问到野指针,crash。
  • 释放晚了:内存一直占着,泄漏。
  • 释放两次:堆被破坏,行为未定义。

GC 的思路是让运行时自动判断哪些对象不再被使用,自动回收。判断"不再被使用"的方法,就是 GC 算法的核心。两大流派:引用计数和对象追踪。

引用计数法

原理很直白:每个对象带一个计数器,有多少个引用指向它计数就是多少。引用建立时 +1,断开时 -1,降到 0 就回收。

被 B 引用

又被 C 引用

C 释放引用

B 释放引用

对象 A
count=0

对象 A
count=1

对象 A
count=2

对象 A
count=1

对象 A
count=0
✅ 回收

优点

  • 及时回收:计数一降到 0 立刻回收,不用等专门的 GC 暂停。
  • 无 STW:没有 Stop The World 阶段,回收动作分散在每次引用变更时。
  • 实现简单:计数器加减,逻辑直白。

致命缺陷:循环引用

class Parent {
  child: Child | null = null;
}

class Child {
  parent: Parent | null = null;
}

function main() {
  const parent: Parent = new Parent();
  const child: Child = new Child();
  parent.child = child;   // child 计数 +1 → 2
  child.parent = parent;  // parent 计数 +1 → 2
  // main 返回后,parent 和 child 的局部变量引用断开
  // 但它们互相引用,计数都是 1,永远降不到 0
}

parent 和 child 互相持有,函数结束后两个对象的计数都是 1,谁也释放不了。这就是循环引用导致的内存泄漏,引用计数法从原理上解决不了。

其他缺点

  • 性能开销:每次赋值都要改计数器,频繁的引用变更场景下开销累积。
  • 计数溢出:理论上计数器有上限,虽然实际很少遇到。
  • 线程安全:多线程下计数器加减要原子操作,有竞争开销。

对象追踪法

对象追踪(Tracing GC)的思路反过来:不跟踪引用的建立和断开,而是从一组肯定存活的对象出发,顺着引用链遍历,能遍历到的就是活的,遍历不到的就是垃圾。

根对象

遍历的起点叫 GC Root,包括:

  • 栈上的局部变量
  • 全局对象
  • 活跃的 ArkTS 函数闭包变量
  • Native 引用持有的对象

从 Root 出发能到达的对象都是存活的,到达不了的就是垃圾。

GC Root

对象 A

对象 B

对象 C

对象 D

对象 E

对象 F

蓝色(A/B/C/D)从 Root 可达,存活;黄色(E/F)不可达,是垃圾。

优点

  • 解决循环引用:循环引用的对象只要从 Root 不可达,就会被识别为垃圾。parent 和 child 互相引用,但 main 返回后从 Root 已经到不了它们,一起回收。
  • 赋值无开销:建立和断开引用不触发任何 GC 动作,赋值就是写个指针。

缺点

  • 有 STW:遍历对象图期间要保证引用关系不变,得暂停业务线程。这个暂停就是 GC 调优的主要目标。
  • 回收延迟:垃圾要等到下一次 GC 才被回收,期间占着内存,叫"浮动垃圾"。
  • 实现复杂:遍历对象图、维护标记位、处理并发标记,比引用计数复杂得多。

ArkTS 为什么选对象追踪

引用计数的循环引用问题在面向对象编程里太常见了——父子节点、双向链表、观察者模式都容易写出循环引用。ArkTS 是面向对象的语言,选引用计数等于把泄漏问题甩给开发者。对象追踪虽然实现复杂、有 STW,但 STW 可以通过并发标记、分代回收等手段压到毫秒级,开发者无感。两害相权,ArkTS 运行时选了对象追踪。

对象追踪的三种回收类型

标记阶段找出存活对象后,怎么回收垃圾有三种基本做法。每种在内存碎片、空间利用率、性能之间做不同取舍。

标记-清扫(Mark-Sweep)

标记完,直接把垃圾对象占的内存放回空闲队列。对象不动,只改空闲链表。

标记前:[A活][B垃圾][C活][D垃圾][E活]
标记后:[A活][B空闲][C活][D空闲][E活]
清扫:  把 B、D 加入空闲链表,下次分配从这里取

优点:对象不移动,效率高;对有外部引用的对象友好(地址不变)。

缺点:内存碎片严重。空闲的 B 和 D 不连续,下次要分配一个大对象,虽然总空闲空间够,但没有连续的大块,分配失败。极端情况下明明还有一半内存空闲,却 OOM。

标记-复制(Mark-Compact,又叫 Copying GC)

把堆对半分:From 和 To。GC 时把存活对象从 From 复制到 To,复制完整个 From 一次性回收,下次分配从 To 开始。From 和 To 角色互换。

From: [A活][B垃圾][C活][D垃圾][E活]
复制到 To: [A][C][E]   (紧凑排列)
回收 From 整个空间

优点:无碎片,分配快(指针碰撞即可);存活对象少时复制开销小。

缺点:空间利用率 50%,永远有一半堆是空的等下次用。存活对象多时复制开销大。对象地址变了,有外部引用的话要更新所有引用。

适合存活率低的区域——年轻代。新生对象大多朝生夕死,复制开销小,无碎片的收益大。

标记-整理(Mark-Compact)

标记完,把存活对象往一端挪,挤掉中间的垃圾,最后尾部一大块空闲。

标记前:[A活][B垃圾][C活][D垃圾][E活]
整理后:[A][C][E][          空闲          ]

优点:无碎片,空间利用率 100%(不像复制算法浪费一半)。

缺点:整理要移动对象、更新所有引用,开销最大。对象多时慢。

适合存活率高、对碎片敏感的区域——老年代。老年代对象大多长期存活,复制开销大,整理虽然慢但频率低,分摊下来可接受。

三种对比

算法内存碎片空间利用率性能适合区域
标记-清扫严重高快存活率高、对碎片不敏感
标记-复制无50%存活少时快年轻代(存活率低)
标记-整理无高慢老年代(存活率高)

没有一种算法在所有维度上都最优,这就是为什么现代 GC 都走分代+混合算法的路子——不同区域用不同算法。HPP GC 也是这个思路,下一篇细讲。

举个栗子:看三种算法在不同存活率下的表现

假设一个 100MB 的区域,GC 后存活对象占 20MB。

标记-清扫:清扫开销≈0(改空闲链表),总耗时≈标记时间。但留下 80MB 碎片空间,下次分配大对象可能失败。

标记-复制:复制 20MB 存活对象到 To 区,耗时≈复制 20MB。From 区 100MB 整个回收。To 区剩 80MB 连续空闲,分配爽快。代价是平时只有 50MB 可用。

标记-整理:移动 20MB 存活对象到一端,耗时≈移动 20MB + 更新引用。剩 80MB 连续空闲。比复制慢一点(要更新引用),但平时 100MB 都能用。

存活率 80% 时(80MB 存活):

  • 标记-清扫:还是改链表,快。
  • 标记-复制:复制 80MB,慢;而且 To 区只有 50MB,放不下,根本不能用。
  • 标记-整理:移动 80MB,慢,但能跑完。

这就能看出为什么年轻代用复制(存活少、复制快、无碎片),老年代用整理(存活多、复制放不下、整理能扛)。

实践中要注意的

  • 别把 GC 当魔法:GC 能回收不可达对象,但"不可达"不等于"你不用了"。如果你把对象塞进一个全局 Map 当缓存忘了删,它就一直从 Root 可达,GC 永远不回收,这就是内存泄漏。GC 解决的是"找不到引用的对象",不是"逻辑上不再需要的对象"。
  • 循环引用在 ArkTS 里不是问题:对象追踪法天然处理循环引用,parent 互相引用 child,只要从 Root 不可达就一起回收。从 C++/Python(引用计数)转过来的开发者老担心循环引用,在 ArkTS 里这个担心可以放下。
  • STW 暂停能感知到:虽然 HPP GC 把 STW 压到毫秒级,但敏感场景(滑动、动画)里偶发的几毫秒卡顿可能就是 GC。看到卡顿先抓 GC 日志,别急着怀疑业务代码。
  • 手动触发 GC 通常没必要:ArkTS 运行时的 GC 触发策略已经调得比较保守,手动 ArkTools.hintGC() 多数时候是干扰。只在明确知道一大堆对象刚变成垃圾、且接下来有性能敏感操作时才考虑手动触发。

总结一下下

  • 短生命周期对象尽量在函数内创建,函数返回后从 Root 不可达,下次 Young GC 就回收了。Young GC 频率高、开销小,这种对象几乎无感。
  • 长生命周期对象(缓存、单例)会被晋升到老年代,Old GC 频率低、开销大。老年代对象多不是问题,问题是老年代对象频繁增删会导致碎片和频繁 Old GC。
  • 大对象(几 MB 以上的 ArrayBuffer)会直接进 HugeObjectSpace,每个独占一个 Region。大对象频繁创建回收会让 HugeObjectSpace 的 Region 反复分配释放,关注这块的 GC 日志。
  • 别在 finalize 回调里做复杂逻辑。finalize 是 GC 回收对象时调用的,在里面分配新对象、持有引用都会干扰 GC,且执行时机不确定。
Logo

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

更多推荐