一个循环依赖引发的严重内存泄露分析

735 阅读7分钟

前言

这是一个发生在鸿蒙上的真实案例,同时所有使用对象追踪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的时候,从根对象追踪不到这三个,所以统一都被回收。

image.png

普通循环依赖+弱引用

这次增加了一个静态单例对象,由于弱引用不会被追踪到,所以gc后,只会剩下D。

image.png

普通循环依赖+弱引用plus

这次继续增加一个条件。

D弱引用了C,D强引用了B,并且当C被析构的时候,D会移除对B的引用

这个条件很重要,鸿蒙很容易触发该场景,Java如果弱引用使用不当也会触发该问题,或者是使用了WeakHashMap

image.png

此时我们只需要断掉B引用C这一环,gc即可回收A、B、C对象。

跳着看的不要懵,这里有个条件是C回收了之后,D会移除对B的引用。在鸿蒙上,B、C、D这类关系会非常的常见,因为鸿蒙ARKUI 的核心类BuildNode为了方便各个场景复用,就是这么实现的,弱引用持有key,强引用持有value,弱引用被释放后,再回收强引用。。

image.png

真实案例(直播播放器泄露)

在我们的专家平台上,有一个内存泄漏非常严重,平均每个用户泄漏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没有被回收,就说明存在内存泄露,属于异常情况。

其他问题解答

虽然内存泄露解决了,但是还是有一堆疑问。

  1. NodeController为什么不会自动释放子节点。

NodeController回收子节点的操作在析构方法里面,所以不会自动释放子节点的情况属于异常情况。

子节点回收调用链:NodeContainer 卸载 → FrameNode::RemoveFromTree()Element::Destroy() → 子节点 aboutToDisappear

  1. 鸿蒙为什么要这么设计NodeController

这样的话跨页面复用就会比较方便,只要不回收NodeController,BuildNode就能被复用。这在Android中就做不到。

  1. 为什么手动调用NodeControler的dispose方法不能释放子节点,disposeTree()却可以。

首先手动回调不能断开引用链,其次子component的回收需要node被父节点移除,而dispose方法不会调用remove方法,而disposeTree方法会调用FrameNode::RemoveFromTree()

  1. 日常开发中如何避免这些问题的发生。

使用NodeController的时候,如果Node不需要复用,记得及时置空。

总结

由于鸿蒙的 ARKUI 框架非常容易触发这个问题,所以我们在使用NodeController的时候,一定要记得将buildNode的引用置空。dispose可以不调用,置空后也会自动再次调用。但如果只调用dispose不置空还是会泄露。

当然这个解法也不是万能的,具体情况具体分析。如果在内存快照中分析发现根对象是JsBuilderNode,那么很有可能就是这个问题。