【HarmonyOS开发小实践】HarmonyOS GC 引用计数 vs 对象追踪,三种回收算法
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 释放引用对象 Acount0对象 Acount1对象 Acount2对象 Acount1对象 Acount0✅ 回收优点及时回收计数一降到 0 立刻回收不用等专门的 GC 暂停。无 STW没有 Stop The World 阶段回收动作分散在每次引用变更时。实现简单计数器加减逻辑直白。致命缺陷循环引用classParent{child:Child|nullnull;}classChild{parent:Parent|nullnull;}functionmain(){constparent:ParentnewParent();constchild:ChildnewChild();parent.childchild;// child 计数 1 → 2child.parentparent;// 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且执行时机不确定。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →