前言
这是一个发生在鸿蒙上的真实案例,同时所有使用对象追踪gc的语言都有可能触发这个问题,比如java、go等语言。所以这篇文章的经验各个技术栈的同学都可以借鉴。
对鸿蒙gc有了解的同学一定会有这么一个疑问,鸿蒙的gc主要使用对象追踪类型的算法,怎么可能会存在 循环依赖 问题,循环依赖不都是OC、C++这些引用计数去gc的语言才会有的问题吗?
鸿蒙的GC主要使用标记清理、标记复制、标记整理等gc算法,这些都是通过跟对象追踪的方式去完成的,所以对于一般的循环依赖,鸿蒙的gc算法都是可以正常回收的。本次遇到的问题是二班情况,虽说问题发生在鸿蒙上,但在Android平台也是可以复现的。由于鸿蒙的UI框架设计上非常容易触发这个问题,本次就做个深入分享理一理这里面的门道,方便大家后续继续出现类似的问题。
详细的鸿蒙gc算法可以看这里:gitee.com/openharmony…
问题模拟
在进入案例之前,我们先看一个例子,方便理解真实案例。
普通循环依赖
假设当一个业务场景使用完后,有三个普通对象A、B、C,A只引用了B,B只引用了C,C只引用了A。由于在GC的时候,从根对象追踪不到这三个,所以统一都被回收。
普通循环依赖+弱引用
这次增加了一个静态单例对象,由于弱引用不会被追踪到,所以gc后,只会剩下D。
普通循环依赖+弱引用plus
这次继续增加一个条件。
D弱引用了C,D强引用了B,并且当C被析构的时候,D会移除对B的引用。
这个条件很重要,鸿蒙很容易触发该场景,Java如果弱引用使用不当也会触发该问题,或者是使用了WeakHashMap
此时我们只需要断掉B引用C这一环,gc即可回收A、B、C对象。
跳着看的不要懵,这里有个条件是C回收了之后,D会移除对B的引用。在鸿蒙上,B、C、D这类关系会非常的常见,因为鸿蒙ARKUI 的核心类BuildNode为了方便各个场景复用,就是这么实现的,弱引用持有key,强引用持有value,弱引用被释放后,再回收强引用。。
真实案例(直播播放器泄露)
在我们的专家平台上,有一个内存泄漏非常严重,平均每个用户泄漏50mb。
分析内存快照后发现其中一个实例A 有71个,并且最终的根引用都指向了B,这类对象B不具备作为跟对象的能力,只有一种可能就是它们还被native层对象持有,所以初步怀疑是native层泄露导致,跟相关的同学沟通后,他们也没有map或者list保存相关对象,最多就泄露一个,不可能泄露这么多。于是就想先将A跟B解耦。
第一次尝试修复
第一次尝试将onLoad置空,断掉A对B的引用。
不出意外还是出意外了,重新抓取了一份快照发现,内存泄漏还是存在。于是,我怀疑是我的代码没走到。
@Component
export struct B {
onLoad: (window: WindowClient) => void = (window: WindowClient) => {
}
...
aboutToDisappear(): void {
this.onLoad = (window: WindowClient) => {
}
}
打日志后发现果然这里的代码没走到。事情变得有趣起来了,退出直播间的时候B的aboutToDisappear(类似Android的onDestroy)没有被调用。也就是说,NodeController没有自动释放Component。
“物理学不存在了?”
NodeController如果不能自动释放Component组件,无异于三体中的物理学不存在了。
那么为什么B 的 aboutToDisappear 不会调用呢,B 是通过NodeController动态加载,于是我在直播顶层的NodeController中也加了日志,发现NodeContoller的 aboutToDisappear 会被调用,但是它的子Node不会被释放。
二次修复
既然NodeContoller不会泄露,那么我就帮它调用一下看看会发生什么事情。
export class MyNodeController extends NodeController {
aboutToDisappear() {
node?.disposeTree()
node?.dispose()
}
这么改之后,B终于不泄露了,但是A依然有泄露,不过泄露路径已经开始变了,根对象已经变成了系统层的对象了。
系统层是弱引用,不会导致内存泄露?
由于现在的根对象在系统层,于是我就去找了厂商的同学一起分析这个问题,他们给出的答复是JsBuilderNode是弱引用,不会导致内存泄露,可能还有其他地方引用了该对象,他们也无能为力。
由于引用这个对象的地方比较多,一个个置空成本比较巨大。
既然内存快照已经指向了这个类,于是我决定探一探系统的JSBuilderNode到底是如何导致泄露的 , 于是找到了这块的源码开始分析 。
首先我们在NodeControler中会创建一个BuilderNode,这里会创建JSBuilderNode,同时会把创建Node用到的参数存都放到JSBuilderNode中,这个参数就包括我们的LivePlayerShareLenderClient。
同时再把JSBuilderNode放入到全局map中(罪魁祸首在这里)。
//foundation/arkui/ace_engine/frameworks/bridge/declarative_frontend/ark_node/src/builder_node.ts
class BuilderNode { constructor ( uiContext, options ) { let jsBuilderNode = new JSBuilderNode (uiContext, options); this . _JSBuilderNode = jsBuilderNode; let id = Symbol ( 'BuilderRootFrameNode' ); BuilderNodeFinalizationRegisterProxy . ElementIdToOwningBuilderNode_ . set (id, jsBuilderNode); BuilderNodeFinalizationRegisterProxy . register ( this , { name : 'BuilderRootFrameNode' , idOfNode : id }); } }
class BuilderNodeFinalizationRegisterProxy {
constructor() {
this.finalizationRegistry_ = new FinalizationRegistry((heldValue: RegisterParams) => {
if (heldValue.name === 'BuilderRootFrameNode') {
const builderNode = BuilderNodeFinalizationRegisterProxy.ElementIdToOwningBuilderNode_.get(heldValue.idOfNode); BuilderNodeFinalizationRegisterProxy.ElementIdToOwningBuilderNode_.delete(heldValue.idOfNode);
builderNode.dispose();
}
});
}
构造函数里面还做了一件事,在BuilderNodeFinalizationRegisterProxy 中创建了FinalizationRegistry,这个对象就比较牛了。
它有以下作用:
- 监听对象被 GC 回收:当某个对象不再被引用时,触发注册的回调函数。
- 资源释放:适用于管理 原生句柄(Native Handle)、文件描述符 或 跨语言资源(如 C++ 对象)。
- 避免内存泄漏:确保即使开发者忘记手动释放资源,GC 也能自动回收。
这个场景主要就是监听BuildNode的生命周期,如果BuildNode被回收,就会移除全局map中的JSBuilderNode。
本质上还是BuildNode泄露了
从源码上看,根因还是 BuildNode 被上层持有导致。
export class MyNodeController extends NodeController {
protected node ?: BuilderNode<[ViewParams]>
...
aboutToDisappear() {
// this.node?.dispose() 这行没必要调用,node回收之后会自动调用
this . node = undefined
}
}
于是我尝试把node置空,经过测试,播放器已经完全没有内存泄露,所有对象都被回收干净了。这里没必要调用node.dispose方法,node回收之后会自动调用。
“物理学还存在”
只要NodeController被回收,子component还是可以被自动回收的,这个铁律还是存在的,可以放宽心,物理学还存在。如果NodeController没有被回收,就说明存在内存泄露,属于异常情况。
其他问题解答
虽然内存泄露解决了,但是还是有一堆疑问。
- NodeController为什么不会自动释放子节点。
NodeController回收子节点的操作在析构方法里面,所以不会自动释放子节点的情况属于异常情况。
子节点回收调用链:NodeContainer 卸载 → FrameNode::RemoveFromTree() → Element::Destroy() → 子节点 aboutToDisappear。
- 鸿蒙为什么要这么设计NodeController
这样的话跨页面复用就会比较方便,只要不回收NodeController,BuildNode就能被复用。这在Android中就做不到。
- 为什么手动调用NodeControler的dispose方法不能释放子节点,disposeTree()却可以。
首先手动回调不能断开引用链,其次子component的回收需要node被父节点移除,而dispose方法不会调用remove方法,而disposeTree方法会调用FrameNode::RemoveFromTree()。
- 日常开发中如何避免这些问题的发生。
使用NodeController的时候,如果Node不需要复用,记得及时置空。
总结
由于鸿蒙的 ARKUI 框架非常容易触发这个问题,所以我们在使用NodeController的时候,一定要记得将buildNode的引用置空。dispose可以不调用,置空后也会自动再次调用。但如果只调用dispose不置空还是会泄露。
当然这个解法也不是万能的,具体情况具体分析。如果在内存快照中分析发现根对象是JsBuilderNode,那么很有可能就是这个问题。