根与环:为什么你的 ArkTS 内存泄漏,和“循环引用“无关
关于 ArkTS 异步任务泄漏的一次机制溯源
——从"闭包持有 this"这个流行解释,追溯到协程栈帧锚定
引言:一个被传抄了太多次的解释
打开任何一篇讲 ArkTS 内存泄漏的中文技术文章,你几乎都会看到同一个句式:
“闭包形成了循环引用,导致 GC 无法回收,于是内存泄漏。”
然后配一张 A 持有 B、B 持有 A 的图,再加一句"要把引用置为 null"。
这个解释听起来自洽,传播得也足够广,但它在 ArkTS 的运行时模型里是错的——不是细节不准,而是因果搞反了。
本文要做的事只有一件:把"为什么错"和"错的背后真正发生了什么"讲清楚。因为一旦这个前提是错的,后面所有的修复手段都会失焦——你会花时间去"断引用",而真正需要处理的东西根本没有被触碰。
第一章 一个被忽略的前提:循环引用在 ArkTS 里不会泄漏
1.1 两种 GC 的路线分歧
垃圾回收有两条经典路线,它们对"循环引用"的态度完全相反。
引用计数(Reference Counting):给每个对象记一个"被引用次数",归零即回收。它的致命缺陷正是循环引用——parent.child = child; child.parent = parent; 的两个对象,引用计数各为 1,永远不会归零,即使外部已经完全不用它们了。
对象追踪(Tracing GC):从一组"根对象"出发遍历引用图,能到达的标记为存活,其余全部回收。它天然免疫循环引用——因为判断标准是"能否从根到达",而不是"自己有没有人指着"。
官方在讲 GC 算法选型时,写得非常直接:
由于引用计数存在内存泄漏问题,ArkTS 运行时选择基于**对象追踪(即 Tracing GC)**算法设计 GC。
而《开发态快速定位 ArkTS 泄漏》开篇也把机制交代得很清楚:
ArkTS 运行时采用 HPP GC(即高性能部分垃圾回收),将对象按生命周期划分为新生代和老年代。使用标记-清除(mark-and-sweep)算法回收内存,可达对象被标记为存活,其余对象将被回收。
结论:ArkTS 用的是对象追踪。所以纯粹的循环引用,在 ArkTS 里根本不是泄漏。
// 这段代码不会泄漏
class A { b: B | null = null }
class B { a: A | null = null }
function createCycle(): void {
const a = new A();
const b = new B();
a.b = b;
b.a = a;
// 函数退栈 → a、b 从任何 Root 都不可达
// 下一轮 GC 时,a 和 b 会被**一起**回收
}
从 C++、Python(引用计数派系)转过来的开发者,会本能地认为"这个环是隐患"。在 ArkTS 里不是。 环的两端同时不可达时,GC 会把它们作为一个整体回收掉。
1.2 那么,为什么大家都说有环就泄漏?
因为漏掉了一个词:根。
回看官方文档里那句被反复引用、却很少被真正读懂的话:
GC 仅回收那些从 GC Root 不可达的对象。 换言之,只要一个对象仍处于引用树的路径之上,即便它已被程序逻辑遗忘、不再被实际需要,GC 也无力将其回收。内存泄漏的本质不是 GC 失效,而是开发者留下了不该留的引用链。
注意最后这半句——“不该留的引用链”。它说的不是"环",而是"链"。链必须有起点,那个起点就是根。
于是可以给出一个精确得多的判据:
| 结构 | 是否泄漏 | 原因 |
|---|---|---|
| 纯环(A↔B,无外部引用) | ❌ 不泄漏 | 环整体从 Root 不可达,一起回收 |
| 有根的环(Root → A → B → 闭包 → A) | ✅ 泄漏 | 从 Root 可达,永不被回收 |
| 无根的长链 | ❌ 不泄漏 | 链头不可达,整条链一起回收 |
泄漏的充分条件是"根可达",不是"存在环"。 环只是一个让引用关系变得"看起来甩不掉"的形态,真正把对象钉在内存里的是那个根。
那些文章里画的图,其实画的是"有根的环"——只是他们省略了根,读者便误以为问题出在环上。于是产生了一个流传极广的错误动作:去断环。
而断环在 ArkTS 里往往无效,因为——
只要根还在,断掉环的一端,GC 依然能从根走到底。
打个比方:环像是一根拴住船的缆绳打成的结。你解不开的是船和岸之间的缆绳,不是绳上的那个结。 把结解开(断环),缆绳依然连着,船依然走不了。
1.3 这个纠正带来什么实际差别
差别很大,因为它改变了你的排查起点。
| 错误前提下的排查 | 正确前提下的排查 |
|---|---|
| “哪里形成了循环引用?” | “谁在引用它?” |
逐个 xxx = null 去断环 | 顺着引用链向上找根 |
| 关注对象之间的相互指向 | 关注从根出发的路径 |
所以正确的第一个动作不是"断引用",而是把引用链打印出来,看它的顶端是什么。
而这一步,官方在文档里已经给出了标准动作:
在 Snapshot 快照的对象引用链中找到异常存活对象(如本该销毁的 Component 实例),通过 “Shortest Paths” 分析引用链情况。
看顶端是什么——这正是下一章要讲的坐标系。
第二章 真正的坐标系:三类 GC Root
2.1 为什么必须按"根的类型"分类
既然泄漏的判据是"从某类根可达",那么根的类型就直接决定了修复手段。
不同类型的根,需要完全不同的动作去切断:
- 如果根是全局单例 → 你需要清理那个单例
- 如果根是 Native 句柄 → 你需要补一个释放调用
- 如果根是栈帧 → 断引用完全没用,你需要让函数退栈
这就是官方给出 GC Root 分类的真正价值——它不是知识点的罗列,而是一张"按根类型选修复手段"的决策表。
2.2 三类根,三种病,三种药
官方文档把 ArkTS 的泄漏场景明确归为三类:
| # | 根的类型 | 典型成因 | 修复动作 | 引用链特征 |
|---|---|---|---|---|
| ① | VMRoot | 模块导出对象、globalThis.xxx、修改内置原型链 | 清理静态属性 / 全局变量 | 顶端为 VMRoot / SourceTextModule |
| ② | Local / Global Handle | NAPI 的 napi_value / napi_ref 未成对释放 | 补 napi_close_handle_scope / napi_delete_reference | Distance = 1(被根直接持有) |
| ③ | FrameRoot | 函数不退栈、闭包捕获栈帧变量被外部长期持有 | 让函数退栈 / 切断外部持有 | 既非 VMRoot,也非 Handle |
这张表最有价值的一栏是"引用链特征" —— 它把"看起来都一样"的内存泄漏,变成了一次看图判断:
看引用链顶端是 VMRoot ? → ① 全局/模块问题
看泄漏对象 Distance 是否为 1 ? → ② Native 句柄问题
两者都不是 ? → ③ 栈帧问题
2.3 为什么 ③ 是异步泄漏的主战场
前面两类(全局单例、NAPI 句柄)都是"对象被某个长期存在的东西指着",符合大家对"引用"的直觉。
但第三类不是。官方对 FrameRoot 的定义是:
FrameRoot 是函数调用栈帧在 GC 遍历过程中的根节点。 当函数被调用时,其局部变量和入参对象会被当前栈帧引用,从而成为 GC 的"可达"起点。
关键在这句:
然而,若函数长期不退栈,局部变量 / 参数所引用的对象将持续被 FrameRoot 锚定,即便业务逻辑已不再需要它们,GC 也无法回收。
也就是说——这些对象不是"被谁引用了",而是"所在的那个函数还没结束"。
这一句话,把异步泄漏的性质彻底改变了:
| 常见理解 | 实际机制 |
|---|---|
回调闭包持有 this,形成引用 | 函数没退栈,栈帧里的所有局部变量被整体锚定 |
| 修复 = 断开引用 | 修复 = 让函数尽快结束 |
请注意这个差别带来的后果:
栈帧锚定是"整体"的,不是"逐条"的。 一个挂着不结束的函数里,所有局部变量、所有入参、以及它们能间接到达的整个对象子图,都活着。你手工去 xxx = null 断掉其中一个,等于在一间被焊死的房子里挪走一把椅子——房子还在,人还是出不去。
这就是为什么"断引用"在异步泄漏上经常无效。
而官方列出的四种"函数不退栈"的情形里,第四条正是我们关心的:
常见导致栈帧滞留的情形包括:
- 死循环或无限递归,函数永不返回;
- 在函数内启动了一个长期运行的同步阻塞操作(如同步网络请求、大文件同步读写);
- 函数内部创建了闭包并被外部长期持有,且闭包捕获了该函数栈帧中的变量(导致整个栈帧无法释放);
- 使用了生成器(Generator)或 async/await 但未正确消费,导致协程挂起,栈帧保留。
最后一条是本文的核心。它需要我们重新理解一件事——
在 ArkTS 里,async 函数不是一个"回调注册",它是一个协程。
第三章 async 不是回调,是协程
3.1 一个被简化掉的模型
绝大多数教程把 async/await 解释成"Promise 的语法糖"——从语法层面看没错,但从运行时层面看,这个说法丢掉了一个关键事实:
await 会让函数"挂起",而挂起的函数,它的栈帧是"保留"的,不是"销毁"的。
普通函数调用是这样的:
调用 f() → 建立栈帧 → 执行 → 返回 → 栈帧销毁 → 局部变量失去 FrameRoot 锚定 → 可回收
而带 await 的异步函数是这样的:
调用 f() → 建立栈帧 → 执行 → 遇到 await → 挂起(栈帧保留!)→ ... → 恢复 → 执行完 → 栈帧销毁
↑
这中间的时间,栈帧里的所有东西都被 FrameRoot 锚定
挂起期间,栈帧没有销毁。 官方原文用的词是"栈帧保留"。
这解释了一个很多人碰到过的现象:一个 await 了慢接口的函数,即使你早就"不需要"它了,它捕获的那些大对象在接口返回前一直活着。
3.2 官方那条"反直觉建议"的真正原因
现在可以解释那条被无数人困惑的建议了。
官方在讲自定义组件生命周期时明确说:
如果在生命周期的
aboutToDisappear使用异步操作(Promise 或者回调方法),自定义组件将被保留在 Promise 的闭包中,直到回调方法被执行完,这个行为阻止了自定义组件的垃圾回收。
流行的解读是:“因为闭包捕获了 this,形成了引用。”
这个解读方向对了,但层次浅了。它的表述方式会让你以为这是个"引用问题",于是你会去"断引用"。但真正的机制在更下面一层:
异步函数挂起时,栈帧保留。而那个栈帧里的 this(指向组件实例)是被 FrameRoot 直接锚定的。
换句话说,泄漏的不是"this 被闭包指着",而是"this 待在一个不肯结束的函数栈帧里"。
这个区别很关键,因为它决定了修复动作:
| 如果病因是"闭包引用" | 如果病因是"栈帧锚定" |
|---|---|
| 断掉引用即可 | 必须让函数结束 |
可以在别处补置 null | 无法从外部干预,只能不写(或让它尽快返回) |
而"不写"恰恰就是官方的建议——不要在销毁回调里写 async。官方给的替代动作是"只做同步清理;需要异步收尾的,把数据传给独立服务,不要传 this"。
现在你明白这句话为什么这么说了:把数据传出去,等于把清理逻辑搬到一个不锚定组件的栈帧里执行。 那个独立的服务结束时干净退栈,你的组件本来就已经自由了。
3.3 更硬的一层证据:协程的生命周期规范
如果你觉得"栈帧保留"还只是个形象说法,那么 ArkTS 并发规范的原文会把它钉死:
For async functions lifetime is limited to the lifetime of root coroutine for coroutine from which this async function created. When root coroutine is finished - it is no guarantee that any code will be executed in async function.
(async 函数的生命周期受限于创建它的根协程的生命周期。当根协程结束时,不保证 async 函数中任何代码会被执行。)
这句话透露了两件事:
async函数挂在协程树上的——每个协程都有父协程,最终追溯到根协程。这不是"语法糖",这是有管辖关系的运行时对象。- 根协程一结束,内部代码就不保证执行了——这等于官方承认了:协程的存活范围由外部界定,语言本身不给你精细的控制权。
顺带一提,规范里还定义了域的划分(Main / EA / General),以及不同协程类型(Jcoroutine / Acoroutine / AJcoroutine)的调度亲和性。这套体系的存在本身就在说明:ArkTS 的异步不是"回调",而是一套和线程、域绑定的调度模型。
3.4 和主流平台比,ArkTS 缺了哪一环
把三个平台放在一起看,问题会变得非常清楚。
| 平台 | 异步并发模型 | 取消机制 | 生命周期绑定 |
|---|---|---|---|
| Kotlin | 协程 | CoroutineScope.cancel() | 语言机制(CoroutineScope) |
| Swift | 结构化并发 | Task.cancel() | 语言机制(Task 树) |
| ArkTS | 协程 + TaskPool | 无内建取消(async/await 层面) | 无 |
Kotlin 里你写 scope.launch { ... },scope 被销毁时子协程自动取消——这是结构化并发的定义性特征:父作用域负责回收子任务。
ArkTS 呢?官方文档里老老实实列出了一堆"不会随页面销毁而自动停止"的东西:
HTTP 请求、
Promise.then/catch、setTimeout/setInterval、Worker、TaskPool、事件监听、WebSocket。
语言层没有给你 CoroutineScope。 async/await 发起后,它是一个"自由"的协程,挂在协程树上,但没有任何人负责在页面销毁时把它摘下来。
而且,唯一的显式取消手段是有边界的。官方对 taskpool.cancel 的说明:
When the task is in the taskpool waiting queue, after the task is canceled, it will no longer be executed… When the task is already being executed in a taskpool worker thread, canceling the task does not affect the continued execution of the task.
翻译过来:
- 任务在排队中 → 取消立刻生效
- 任务已经在跑 → 取消不影响它继续执行
原因也不难理解:TaskPool 是内存隔离的(Actor 模型),强杀一个正在操作自己内存的线程,会留下不可预测的破损状态。系统宁可让它跑完,也不肯冒险中断。
于是我们在 ArkTS 里面对的局面是:
发起:随便发 ← 没有作用域约束
取消:语言层没有 ← 只有 TaskPool 的有限取消
兜底:没有 ← 只能自己建
这就是"三层防线"存在的根本原因——它不是一套最佳实践锦囊,它是在给一个缺失的能力做补丁。
第四章 三层防线的本质:重建责任归属
4.1 换个角度看那三层
通常"三层防线"被介绍成三个技术手段。但如果把它们放在"ArkTS 缺失责任模型"这个背景里,你会发现它们各自在补一个具体的缺失位:
| 层 | 技术手段 | 它在补什么 |
|---|---|---|
| ① 主动取消 | AbortController / clearTimeout / task.cancel() / terminate() | 补"终止权"——让任务有机会被提前叫停 |
| ② 标志位兜底 | isActive 校验 | 补"接受权"——决定结果还要不要采纳 |
| ③ 契约约束 | 统一封装基类 | 补"归属权"——让每个任务都有明确的责任人 |
三层不是"三重保险"(重复做同一件事),而是三个不同的权能。少任何一层,都会留下一个具体的能力空洞。
4.2 为什么"标志位"在 ArkTS 里不是可选项
对照第 3.4 节的结论:既然 cancel 对"已在执行"的任务无效,那么——
“取消"本质上是"尽力而为”,不是"保证成功"。
一个 cancel() 返回了,不代表你的回调不会被执行。它只是"可能不会被执行"。
于是必须有一个与取消机制正交的、绝对可靠的判断点。这就是标志位:
async load(): Promise<void> {
const data = await fetchData();
// 回来第一件事:问"我还该管这个结果吗"
if (!this.isActive) { return; }
this.data = data;
}
这个 if 看起来朴素,但它承担了一个重要职责:它是唯一不依赖"取消是否成功"的保证。
从"栈帧锚定"的角度看,它还有第二重意义——它让函数尽快走到 return,从而尽快退栈。 这不只是"不写脏数据",它同时缩短了栈帧的存活时间。
两层收益:一层是数据正确性,一层是内存。
这也解释了为什么标志位要放在回来的第一行,而不是放在赋值之前随便某个位置:越早 return,栈帧越早销毁。
4.3 "任务归属"才是那个真正的缺口
把三层合起来看,它们其实在回答同一个问题:
这个任务,归谁管?什么时候算结束?
- 取消:谁有权叫停它
- 标志位:谁有权接受它的结果
- 契约:谁负责给它建立上述两件事
而这正是 ArkTS 语言层没有回答的问题。Kotlin 用 CoroutineScope 回答了,Swift 用 Task 树回答了,ArkTS 把这个问题留给了开发者。
所以,抽象到方法论层面——
在 ArkTS 里写异步代码,实质工作不是"写异步逻辑",而是"给每个异步任务指定一个负责人"。
一个可操作的自检清单:
| 检查项 | 问题 |
|---|---|
| 发起点 | 这个任务由哪个对象发起?它的生命周期是多长? |
| 终止权 | 发起者销毁时,谁去叫停这个任务?靠什么机制? |
| 接受权 | 任务返回时,用什么判断"结果是否还该生效"? |
| 退栈 | 最坏情况下,这个函数多久能走到 return? |
第四条最容易被忽略,但它是 FrameRoot 类泄漏的唯一出口。
第五章 两个必须纠正的流行说法
5.1 “用 WeakRef 打破循环引用防泄漏”——在 ArkTS 里是误诊
大量文章推荐用 WeakRef / WeakMap 来"打破循环引用,防止内存泄漏"。
这个建议的前提(环会泄漏)在 ArkTS 里不成立——第 1.1 节已经论证过。既然纯环本身不泄漏,那"打破环"就没有解决任何问题。
⚠️ 顺带澄清一处矛盾:网上有文章称"ArkTS 严格模式不支持 WeakRef/WeakMap/WeakSet",这与官方白皮书不符。官方在讲强弱引用时明确列出:
JS 实现:WeakMap / WeakSet(ES6);WeakRef(ES2021) 直接创建对单个对象的弱引用。使用
.deref()方法获取原对象。也就是说,这三者都是官方承认的能力。以本地 SDK 的
.d.ts为准即可。
那 WeakRef 在 ArkTS 里还有用吗?有,但用途不同。
它的真正价值在于第 1.2 节的那个判据——它防止的是"建立新的根可达路径",而不是"打破环"。
典型场景是"长期存在的容器索引短生命周期对象":
// ❌ 全局缓存表成了一个"根"
const cache = new Map<string, Component>();
// ✅ 弱引用:容器不会阻止对象被回收
const cache = new WeakMap<object, Component>();
Map 强引用键值,于是这个全局 cache 就成了一个根——凡是被它存过的组件,都再也不会被回收。这才是泄漏。而 WeakMap 的键是弱引用,“键被回收后,整条记录自动消失”。
结论:WeakRef 的正确定位是"避免让容器变成根",而不是"打破环"。 前者是真实需求,后者在 ArkTS 里是个伪命题。
5.2 "循环引用"这个说法本身该被淘汰
更彻底一点:在 ArkTS 语境下,"循环引用导致内存泄漏"这个说法应该停止使用。
它把两个不同的事实压缩成了一个错误的因果:
| 说法 | 实际 |
|---|---|
| “循环引用导致泄漏” | 循环引用只是形态,不构成泄漏 |
| —— | 真正的病因是"从根可达" |
替换成一个准确的表述:
一个不可达的环,是垃圾;一个可达的环,是泄漏。
差别只在那个"根"。
用这个表述去检查你的代码,排查动作会立刻变得有方向:不是"哪有环",而是"谁在引用它,那个人的生命周期合理吗"。
结语:当"断引用"无效时,说明你找错了层次
回到最初那个流行解释。它错不在"提到了闭包和引用"——那些确实存在;它错在把两个层次混成了一个。
现象层: 对象无法回收
↓
引用层: 闭包持有 this ← 流行解释停在这里,于是建议"断引用"
↓
栈帧层: 挂起的协程不销毁栈帧 ← 真正的机制,修复动作是"让函数结束"
↓
模型层: ArkTS 没有作用域约束 ← 根本缺口,需要自建责任模型
每往下一层,修复手段就完全不同:
- 停在引用层 → 你会去
xxx = null,在异步泄漏上常常无效 - 看清栈帧层 → 你会去让函数尽早
return,这才对得上病 - 理解模型层 → 你会去建封装基类、统一治理,这才是治本
而三层防线的意义也在这里浮现出来:它不是三个技巧的堆叠,它是把缺失的第四层——“任务责任模型”——手工补上。
所以,下次面对"越用越卡"时,与其继续在代码里找环,不如先做这两件事:
- 打开 Snapshot,看那个异常存活对象的最短引用链,顶端是什么 —— 是
VMRoot、Handle,还是都不是? - 如果不是前两类,就别再断引用了 —— 去看那个函数为什么还没结束。
内存泄漏的本质,从来不是"引用没断",而是"该结束的还没结束"。
附:核心结论速查
| # | 结论 | 依据 |
|---|---|---|
| 1 | ArkTS 用 Tracing GC,纯循环引用不会泄漏 | 官方:「引用计数存在内存泄漏问题,ArkTS 运行时选择基于对象追踪算法」 |
| 2 | 泄漏的充分条件是根可达,不是存在环 | 官方:「GC 仅回收那些从 GC Root 不可达的对象」 |
| 3 | GC Root 分三类:VMRoot / Handle / FrameRoot | 官方《开发态快速定位 ArkTS 泄漏》 |
| 4 | 看引用链顶端即可判断病型,对应三种修复手段 | 同上:VMRoot / Distance=1 / 其余 |
| 5 | FrameRoot 是异步泄漏主战场,栈帧锚定是整体的 | 官方:「若函数长期不退栈…GC 也无法回收」 |
| 6 | await 使函数挂起,栈帧保留而非销毁 | 官方:「async/await 但未正确消费,导致协程挂起,栈帧保留」 |
| 7 | async 函数是协程,生命周期绑根协程 | ArkTS 并发规范 §1.2.5 |
| 8 | ArkTS 缺 CoroutineScope,取消机制有边界 | 官方列出"不会自动停止"的清单;cancel 对已执行任务无效 |
| 9 | 三层防线的本质是补终止权 / 接受权 / 归属权 | 本文推导 |
| 10 | WeakRef 的用途是"避免容器成为根",不是"打破环" | 官方强弱引用说明 |
参考文档
| 文档 | 用途 |
|---|---|
| 开发态快速定位 ArkTS 泄漏 | 本文核心依据:GC Root 三分类、FrameRoot 定义、栈帧滞留四情形、标准化排查流程 |
| GC 垃圾回收(HPP GC) | 分代模型、混合算法、GC 触发阈值 |
| 鸿蒙编程语言白皮书 · GC 机制 | 强引用 / 弱引用、WeakMap / WeakSet / WeakRef 能力 |
| ArkTS Concurrency Specification | async 函数生命周期、协程域与类型、根协程 |
| @ohos.taskpool API 参考 | cancel 的能力边界、isCanceled 检查点 |
| 自定义组件生命周期 | aboutToDisappear 中不建议使用 async 的原文依据 |
本文为 ArkTS 内存泄漏系列的机制溯源篇。配套实现见同目录 LifecycleSafeAsync.ets 与 README.md。
更多推荐


所有评论(0)