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 就回收。
#mermaid-svg-9m9chlr8gbDGsYDR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9m9chlr8gbDGsYDR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9m9chlr8gbDGsYDR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9m9chlr8gbDGsYDR .error-icon{fill:#552222;}#mermaid-svg-9m9chlr8gbDGsYDR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9m9chlr8gbDGsYDR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9m9chlr8gbDGsYDR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9m9chlr8gbDGsYDR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9m9chlr8gbDGsYDR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9m9chlr8gbDGsYDR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9m9chlr8gbDGsYDR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9m9chlr8gbDGsYDR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9m9chlr8gbDGsYDR .marker.cross{stroke:#333333;}#mermaid-svg-9m9chlr8gbDGsYDR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9m9chlr8gbDGsYDR p{margin:0;}#mermaid-svg-9m9chlr8gbDGsYDR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9m9chlr8gbDGsYDR .cluster-label text{fill:#333;}#mermaid-svg-9m9chlr8gbDGsYDR .cluster-label span{color:#333;}#mermaid-svg-9m9chlr8gbDGsYDR .cluster-label span p{background-color:transparent;}#mermaid-svg-9m9chlr8gbDGsYDR .label text,#mermaid-svg-9m9chlr8gbDGsYDR span{fill:#333;color:#333;}#mermaid-svg-9m9chlr8gbDGsYDR .node rect,#mermaid-svg-9m9chlr8gbDGsYDR .node circle,#mermaid-svg-9m9chlr8gbDGsYDR .node ellipse,#mermaid-svg-9m9chlr8gbDGsYDR .node polygon,#mermaid-svg-9m9chlr8gbDGsYDR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9m9chlr8gbDGsYDR .rough-node .label text,#mermaid-svg-9m9chlr8gbDGsYDR .node .label text,#mermaid-svg-9m9chlr8gbDGsYDR .image-shape .label,#mermaid-svg-9m9chlr8gbDGsYDR .icon-shape .label{text-anchor:middle;}#mermaid-svg-9m9chlr8gbDGsYDR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9m9chlr8gbDGsYDR .rough-node .label,#mermaid-svg-9m9chlr8gbDGsYDR .node .label,#mermaid-svg-9m9chlr8gbDGsYDR .image-shape .label,#mermaid-svg-9m9chlr8gbDGsYDR .icon-shape .label{text-align:center;}#mermaid-svg-9m9chlr8gbDGsYDR .node.clickable{cursor:pointer;}#mermaid-svg-9m9chlr8gbDGsYDR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9m9chlr8gbDGsYDR .arrowheadPath{fill:#333333;}#mermaid-svg-9m9chlr8gbDGsYDR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9m9chlr8gbDGsYDR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9m9chlr8gbDGsYDR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9m9chlr8gbDGsYDR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9m9chlr8gbDGsYDR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9m9chlr8gbDGsYDR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9m9chlr8gbDGsYDR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9m9chlr8gbDGsYDR .cluster text{fill:#333;}#mermaid-svg-9m9chlr8gbDGsYDR .cluster span{color:#333;}#mermaid-svg-9m9chlr8gbDGsYDR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9m9chlr8gbDGsYDR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9m9chlr8gbDGsYDR rect.text{fill:none;stroke-width:0;}#mermaid-svg-9m9chlr8gbDGsYDR .icon-shape,#mermaid-svg-9m9chlr8gbDGsYDR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9m9chlr8gbDGsYDR .icon-shape p,#mermaid-svg-9m9chlr8gbDGsYDR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9m9chlr8gbDGsYDR .icon-shape .label rect,#mermaid-svg-9m9chlr8gbDGsYDR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9m9chlr8gbDGsYDR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9m9chlr8gbDGsYDR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9m9chlr8gbDGsYDR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 被 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 阶段,回收动作分散在每次引用变更时。
- 实现简单:计数器加减,逻辑直白。
致命缺陷:循环引用
typescript
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 出发能到达的对象都是存活的,到达不了的就是垃圾。
#mermaid-svg-EapmWMEZXFagBsFz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EapmWMEZXFagBsFz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EapmWMEZXFagBsFz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EapmWMEZXFagBsFz .error-icon{fill:#552222;}#mermaid-svg-EapmWMEZXFagBsFz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EapmWMEZXFagBsFz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EapmWMEZXFagBsFz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EapmWMEZXFagBsFz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EapmWMEZXFagBsFz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EapmWMEZXFagBsFz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EapmWMEZXFagBsFz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EapmWMEZXFagBsFz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EapmWMEZXFagBsFz .marker.cross{stroke:#333333;}#mermaid-svg-EapmWMEZXFagBsFz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EapmWMEZXFagBsFz p{margin:0;}#mermaid-svg-EapmWMEZXFagBsFz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EapmWMEZXFagBsFz .cluster-label text{fill:#333;}#mermaid-svg-EapmWMEZXFagBsFz .cluster-label span{color:#333;}#mermaid-svg-EapmWMEZXFagBsFz .cluster-label span p{background-color:transparent;}#mermaid-svg-EapmWMEZXFagBsFz .label text,#mermaid-svg-EapmWMEZXFagBsFz span{fill:#333;color:#333;}#mermaid-svg-EapmWMEZXFagBsFz .node rect,#mermaid-svg-EapmWMEZXFagBsFz .node circle,#mermaid-svg-EapmWMEZXFagBsFz .node ellipse,#mermaid-svg-EapmWMEZXFagBsFz .node polygon,#mermaid-svg-EapmWMEZXFagBsFz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EapmWMEZXFagBsFz .rough-node .label text,#mermaid-svg-EapmWMEZXFagBsFz .node .label text,#mermaid-svg-EapmWMEZXFagBsFz .image-shape .label,#mermaid-svg-EapmWMEZXFagBsFz .icon-shape .label{text-anchor:middle;}#mermaid-svg-EapmWMEZXFagBsFz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EapmWMEZXFagBsFz .rough-node .label,#mermaid-svg-EapmWMEZXFagBsFz .node .label,#mermaid-svg-EapmWMEZXFagBsFz .image-shape .label,#mermaid-svg-EapmWMEZXFagBsFz .icon-shape .label{text-align:center;}#mermaid-svg-EapmWMEZXFagBsFz .node.clickable{cursor:pointer;}#mermaid-svg-EapmWMEZXFagBsFz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EapmWMEZXFagBsFz .arrowheadPath{fill:#333333;}#mermaid-svg-EapmWMEZXFagBsFz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EapmWMEZXFagBsFz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EapmWMEZXFagBsFz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EapmWMEZXFagBsFz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EapmWMEZXFagBsFz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EapmWMEZXFagBsFz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EapmWMEZXFagBsFz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EapmWMEZXFagBsFz .cluster text{fill:#333;}#mermaid-svg-EapmWMEZXFagBsFz .cluster span{color:#333;}#mermaid-svg-EapmWMEZXFagBsFz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EapmWMEZXFagBsFz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EapmWMEZXFagBsFz rect.text{fill:none;stroke-width:0;}#mermaid-svg-EapmWMEZXFagBsFz .icon-shape,#mermaid-svg-EapmWMEZXFagBsFz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EapmWMEZXFagBsFz .icon-shape p,#mermaid-svg-EapmWMEZXFagBsFz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EapmWMEZXFagBsFz .icon-shape .label rect,#mermaid-svg-EapmWMEZXFagBsFz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EapmWMEZXFagBsFz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EapmWMEZXFagBsFz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EapmWMEZXFagBsFz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 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,且执行时机不确定。