1 问题概述

1.1 什么是资源泄漏和OOM

资源泄漏是指应用在分配资源(内存、文件句柄、网络连接等)后,未能在适当时机释放,导致资源持续增长。当内存持续增长超过系统限制时,应用会被系统强制终止,即OOM(Out of Memory)。

1.2 RNOH应用资源泄漏的特点

RNOH应用涉及多层内存管理,资源泄漏可能发生在任何一层,且跨层引用关系复杂,定位难度较高:

  • JS引擎堆(Hermes):JS对象、闭包、Promise、全局变量
  • Native C++堆:TurboModule实例、ShadowNode、ComponentInstance、动画节点、napi_ref持有的引用
  • ArkTS运行时:ArkUI组件实例、TurboModule ArkTS实现、被napi_ref阻止GC的对象
  • 系统资源:文件句柄、网络连接、图片解码缓冲区、VSync句柄、定时器

1.3 跨层引用关系

RNOH中关键的跨层引用关系是泄漏分析的核心:

JS对象 ←── jsi::Value/HostObject ──── C++对象
  ↑                                      │
  │                                      │
  └──── napi_ref ←── NapiRef ───────────┘
         │
         └── ArkTS对象(被阻止GC)
  • **JS → C++**:通过jsi::HostObject持有C++对象(如BlobCollector)
  • C++ → JS:通过jsi::Value、CallbackWrapper持有JS对象
  • C++ → ArkTS:通过napi_ref(封装为NapiRef)持有ArkTS对象
  • **ArkTS → C++**:通过TurboModule实例引用C++ Context

1.4 常见泄漏现象

现象说明
反复进入退出页面后内存持续上涨页面相关对象未被释放
调用Native接口后内存无法回落Native侧持有JS对象或资源未释放
页面已销毁但JS/Native对象仍长期存活跨层引用阻止GC
长时间运行后明显内存膨胀缓存无上限、监听器累积、资源未清理
最终触发OOM被系统终止内存超过系统限制

2 排查思路和整体分析

2.1 整体排查思路

资源泄漏定位的核心是判断泄漏发生在哪一层,然后使用对应工具深入分析:

1. 复现问题 → 使用稳定可重复的方式复现场景
2. 观察整体内存趋势 → 使用hidumper采集多次操作后的内存数据
3. 初步判断泄漏类型 → 根据AnonPage other和Native Heap的增长情况判断
4. 采集JS内存快照 → 如果怀疑JS层问题,使用React Native DevTools
5. 采集Native分配信息 → 如果怀疑Native层问题,使用DevEco Profiler Allocation
6. 结合代码审查 → 定位具体泄漏代码
7. 修复并验证 → 确认内存不再持续增长

2.2 泄漏分类

RNOH应用的资源泄漏可分为四大类:

类别1:JS对象被JS自身持有

完全发生在JS层,与RNOH框架无直接关系,但在RN应用中常见。

原因说明快照特征
事件监听器未移除addEventListener后组件卸载时未removeEventListener引用链经过EventEmitter
定时器未清理setInterval/setTimeout未在组件卸载时清除引用链经过Timer对象
全局缓存无上限Map/Set/Array持续增长无淘汰策略引用链顶部为global
闭包持有大对象回调函数闭包捕获了页面对象或大数据引用链经过Closure
未完成PromisePromise长期pending,持有resolve/reject闭包引用链经过Promise对象

快照标签:global、Closure、GC roots

类别2:JS对象被Native/C++持有

发生在JS与Native的跨语言交互层,是RNOH应用中最常见且最难定位的泄漏类型。

原因1:napi_ref未释放

napi_ref是C++侧持有JS对象的强引用。如果创建后未调用napi_delete_reference,对应的JS对象永远不会被GC。

RNOH框架通过NapiRef RAII包装器管理napi_ref生命周期,但以下场景仍存在泄漏风险:

  • RNArkTSComponentDelegate使用原始napi_ref而非NapiRef RAII包装,异常路径可能泄漏
  • TurboModule析构时通过runTask将NapiRef投递到目标线程销毁,如果TaskExecutor已失效,任务可能永远不执行
  • TurboModuleFactory析构时同样依赖TaskExecutor投递清理任务

原因2:LongLivedObjectCollection中的CallbackWrapper未释放

LongLivedObjectCollection用于阻止JSI对象(如RNOHPromise、CallbackWrapper)被GC。对象通过allowRelease()释放。

RNOH通过CallbackGuard/CallbackGuardRegistry机制管理释放:

  • CallbackGuard在析构时调用release() → CallbackWrapper::allowRelease()
  • CallbackGuardRegistry在达到128条阈值时自动压缩已释放的Guard
  • CallbackGlobalRegistry静态单例跟踪所有模块的Registry

泄漏风险:

  • 如果Promise永远不resolve/reject,对应的CallbackWrapper永远不会被释放
  • 如果jsInvoker->invokeAsync投递的releaseAll()任务未执行(JS队列已关闭),CallbackWrapper泄漏
  • CallbackGlobalRegistry使用weak_ptr避免阻止析构,但如果unregisterModule未被调用,过期条目可能残留

原因3:HostObject依赖JS GC释放Native资源

HostObjectProxy通过new分配,依赖JSVM finalizer回调delete。如果JS对象未被GC(如被全局变量持有),HostObjectProxy及其持有的shared_ptr<HostObject>永远不会释放。

典型案例:BlobCollector(HostObject)的析构触发BlobTurboModule::release()释放blob数据。如果JS Blob对象未被GC,blob数据持续堆积。

快照标签:HostObject、Global handles

类别3:纯Native/C++泄漏

完全发生在C++层,不涉及JS对象。

原因1:TurboModule缓存无淘汰

TurboModuleProvider::m_cache是一个unordered_map<string, shared_ptr<TurboModule>>,模块一旦创建就永久缓存,没有淘汰或清除机制。每个缓存条目持有:

  • NapiRef(阻止ArkTS模块对象GC)
  • CallbackGuardRegistry(可能持有pending回调)
  • Context中的shared_ptr图(jsInvoker、taskExecutor、eventDispatcher、scheduler等)

原因2:图片URI缓存无上限

ImageSourceResolver::remoteImageSourceMap是一个unordered_map<string, string>,每个远程图片URI解析后永久缓存,没有大小限制或淘汰策略。对于图片密集型应用(如信息流),该Map持续增长。

原因3:VSync/UITicker回调中的原始指针泄漏

VSyncListener::scheduleNextVsync和UITicker::requestNextTick使用new分配weak_ptr传递给C回调。如果VSync句柄在回调触发前被销毁(如应用退出),weak_ptr永远不会被delete。

原因4:Subject/Observer原始指针

Subject::m_observers使用原始指针存储观察者。如果观察者析构时未正确调用unregisterObserver(如对象切片、异常路径),悬空指针残留。

原因5:静态单例跨实例累积

多个静态单例在RNInstance销毁后仍保留数据:

单例数据清理机制泄漏风险
TurboModuleProvider::m_cache模块缓存随Instance析构无(Instance级)
CallbackGlobalRegistry模块回调注册unregisterModule未调用时残留
RNOHMarker::listeners性能标记监听器notifyListeners时压缩不notify时累积
ImageCacheMemoryCache图片内存缓存内存压力回调清空全局单例不析构
PerformanceMetricsRegistry性能指标removeRNInstanceMetrics未调用时残留
FreezeStackCollector冻屏栈缓存unregisterRuntime未调用时残留

类别4:系统资源泄漏

资源类型泄漏场景影响
文件句柄打开后未关闭,异常路径跳过close句柄数耗尽,后续open失败
网络连接HTTP请求未设置超时,回调未清理连接数耗尽,内存增长
图片解码缓冲区大图片解码后未及时释放Native Heap快速增长
VSync句柄注册后未注销持续触发回调,CPU和内存开销
定时器libuv Timer未取消事件循环无法退出,内存残留

2.3 初步判断泄漏类型

内存表现初步判断优先分析工具
AnonPage other持续增长,Native Heap基本稳定JS对象被JS持有React Native DevTools
AnonPage other和Native Heap都增长,前者更明显JS对象被C++持有React Native DevTools + Allocation
仅Native Heap增长,匿名页基本稳定纯Native对象泄漏DevEco Studio Profiler / Allocation

3 分步排查过程(工具使用)

3.1 步骤1:复现问题

使用稳定可重复的方式复现场景:

  • 反复进入和退出某个页面(建议10次以上)
  • 多次调用某个Native API
  • 连续执行上传、下载、解析、渲染等操作
  • 最后返回首页或离开目标页面,模拟组件卸载和页面销毁

3.2 步骤2:观察整体内存趋势

hdc shell hidumper --mem $(pidof com.example.rnoh)

每次执行泄漏操作后采集一次,重点观察:

指标含义泄漏指向
AnonPage otherJS引擎堆 + 应用私有匿名页JS对象堆积
Native HeapC++/ArkTS直接分配的内存Native对象泄漏
Pss进程实际占用物理内存总体内存趋势

注意:最后一次操作后,先在React Native DevTools中主动触发GC,或等待约2分钟再采集,避免因GC延迟误判。

3.3 步骤3:初步判断泄漏类型

根据AnonPage other和Native Heap的增长情况,判断泄漏发生在哪一层:

内存表现初步判断优先分析工具
AnonPage other持续增长,Native Heap基本稳定JS对象被JS持有React Native DevTools
AnonPage other和Native Heap都增长,前者更明显JS对象被C++持有React Native DevTools + Allocation
仅Native Heap增长,匿名页基本稳定纯Native对象泄漏DevEco Studio Profiler / Allocation

3.4 步骤4:采集JS内存快照

使用React Native DevTools进行分析:

# 启动react-devtools
npx react-devtools

# 打通端口
hdc rport tcp:8097 tcp:8097

操作步骤:

  1. 通过Metro加载bundle
  2. 打开React Native Dev Menu → Open React Native DevTools
  3. 进入Memory页签
  4. 抓取第一次快照(首页baseline)
  5. 执行多次泄漏操作并触发GC
  6. 回到首页抓取第二次快照
  7. 选择第二次快照 → 顶部选择"比较" → 以baseline为基准 → 按大小增量排序

JS快照分析技巧:

快照特征可能原因排查方向
大量相同类型对象增量集合无上限增长检查Map/Set/Array的清理逻辑
对象被Closure持有闭包捕获检查回调函数捕获的变量
对象被HostObject持有C++侧持有JS对象检查napi_ref、jsi::Value生命周期
对象被Global handles持有JSI全局引用检查LongLivedObjectCollection
Detached DOM/组件已卸载但仍被引用检查事件监听器、定时器

3.5 步骤5:采集Native分配信息

如果怀疑Native/C++持有问题,使用DevEco Studio Profiler > Allocation:

  1. 创建Session并开始录制
  2. 执行多次泄漏操作
  3. 等待1到2分钟后停止录制
  4. 切换到Native Heap
  5. 在Details区域中过滤Created & Existing
  6. 在Call Trees中查看未释放对象的调用路径

如果构建产物剥离了符号表,需要手动导入符号文件(unstripped.so)。

Native Allocation分析技巧:

  1. 过滤Created & Existing:只显示创建后仍未释放的对象
  2. 按调用栈聚合:找出哪个分配路径产生了最多未释放对象
  3. 对比两次录制:确认增长趋势是否持续
  4. 符号化:导入unstripped.so获取完整函数名和行号

3.6 步骤6:监控内存压力事件

RNOH框架通过onMemoryLevel回调响应系统内存压力:

// RNOHAppNapiBridge.cpp
void onMemoryLevel(int level) {
    // 通知所有活跃RNInstance
    for (auto& instance : instances) {
        instance->onMemoryLevel(level);
    }
}

ImageCacheMemoryCache注册了内存压力回调:

  • MEMORY_LEVEL_CRITICAL:清空全部缓存
  • MEMORY_LEVEL_MODERATE / MEMORY_LEVEL_LOW:清空一半缓存

如果应用收到频繁的内存压力事件,说明内存已接近系统限制,需要重点排查泄漏。

3.7 步骤7:OOM现场紧急分析

当应用因OOM被系统终止时:

  1. 查看系统日志中的ProcessDied或MEM_RECLAIM事件
  2. 确认进程终止前的内存占用(Pss)
  3. 回查hilog中的内存压力事件(onMemoryLevel)
  4. 如果可能,在内存压力事件回调中添加日志,记录各层内存状态
  5. 使用hidumper定期采样,绘制内存增长曲线,判断增长速率和拐点

4 根因定位

4.1 案例1:事件监听器未移除导致JS泄漏

现象:反复进入退出某页面后,AnonPage other持续上涨,Native Heap基本稳定。

分析步骤:

  1. 在React Native DevTools中抓取两次快照(baseline + 操作后)
  2. 快照对比,按大小增量排序,发现页面对象的 retained size 持续增大
  3. 查看保留器,引用链为:GC roots → EventEmitter → listener → PageComponent
  4. 确认页面卸载时未调用removeEventListener

根因:页面组件在useEffect中注册了事件监听器,但组件卸载时未在清理函数中移除监听器,导致EventEmitter持续持有页面对象引用,阻止GC。

4.2 案例2:TurboModule回调未释放导致OOM

现象:长时间运行后Native Heap和AnonPage other同步增长,最终OOM。

分析步骤:

  1. hidumper显示AnonPage other和Native Heap均持续增长
  2. JS快照发现大量RNOHPromise对象被HostObject/Global handles持有
  3. Allocation显示CallbackWrapper对象持续创建但未释放
  4. 排查代码发现某TurboModule的异步方法在特定条件下未调用resolve/reject

根因:TurboModule的异步方法在某些异常路径下未调用resolve或reject,导致Promise长期pending,对应的CallbackWrapper被LongLivedObjectCollection持有,永远不会释放。

4.3 案例3:图片URI缓存无限增长

现象:信息流应用中,滑动浏览大量图片后内存持续上涨。

分析步骤:

  1. hidumper显示Native Heap持续增长
  2. Allocation显示ImageSourceResolver相关分配持续增加
  3. 代码审查发现remoteImageSourceMap无大小限制
  4. 确认应用展示了数百张不同的远程图片

根因:ImageSourceResolver::remoteImageSourceMap是一个unordered_map<string, string>,每个远程图片URI解析后永久缓存,没有大小限制或淘汰策略。对于图片密集型应用,该Map持续增长导致内存泄漏。

4.4 案例4:Blob数据依赖GC释放导致OOM

现象:频繁使用文件上传/图片选取功能后OOM。

分析步骤:

  1. Native Heap快速增长,AnonPage other增长较慢
  2. Allocation显示大量blob数据缓冲区未释放
  3. 代码审查发现BlobCollector(HostObject)的析构触发blob释放
  4. 但JS Blob对象被业务代码全局缓存,GC无法回收

根因:BlobCollector(HostObject)的析构触发BlobTurboModule::release()释放blob数据,但JS Blob对象被业务代码全局缓存,GC无法回收JS对象,导致Native侧的blob数据永远不会释放。

4.5 案例5:多实例场景下静态单例累积

现象:反复创建和销毁RNInstance后内存持续增长。

分析步骤:

  1. 确认应用使用了多RNInstance模式
  2. hidumper显示每次创建新Instance后内存上涨,销毁后不完全回落
  3. 代码审查发现多个静态单例(CallbackGlobalRegistry、RNOHMarker::listeners等)在Instance销毁后仍保留数据
  4. 确认unregisterModule、removeListener等清理方法是否被正确调用

根因:多个静态单例在RNInstance销毁后仍保留数据,如果unregisterModule、removeListener等清理方法未被正确调用,过期条目会持续累积,导致内存泄漏。

5 解决方案

5.1 JS事件监听器泄漏修复

问题:页面组件在useEffect中注册了事件监听器,但组件卸载时未移除。

解决方案:

useEffect(() => {
  const listener = eventEmitter.addListener("event", handler);
  return () => listener.remove(); // 组件卸载时移除监听
}, []);

最佳实践:

  • 所有addEventListener/addListener必须在组件卸载时移除
  • 使用useEffect返回清理函数
  • 对于类组件,在componentWillUnmount中移除监听器

5.2 TurboModule回调未释放修复

问题:TurboModule的异步方法在某些路径下未调用resolve/reject。

解决方案:

确保所有Promise路径都有resolve或reject:

async myMethod(): Promise<string> {
  try {
    const result = await doSomething();
    return result; // resolve
  } catch (e) {
    throw e; // reject
  }
  // 确保没有遗漏的路径
}

最佳实践:

  • 所有异步方法必须确保Promise在所有路径都有resolve或reject
  • 使用try-catch包裹所有可能抛出异常的代码
  • 对于复杂的异步流程,使用Promise.race设置超时

5.3 图片URI缓存无限增长修复

问题:remoteImageSourceMap无大小限制,持续增长。

解决方案:

  1. 增加LRU淘汰策略:
void updateImageSourceMap(const std::string& uri, const std::string& resolvedUri) {
  std::lock_guard lock(m_imageSourceMapMutex);
  remoteImageSourceMap[uri] = resolvedUri;

  // 超过上限时淘汰最早的条目
  if (remoteImageSourceMap.size() > MAX_IMAGE_URI_CACHE) {
    remoteImageSourceMap.erase(remoteImageSourceMap.begin());
  }
}
  1. 限制缓存大小上限:
static constexpr size_t MAX_IMAGE_URI_CACHE = 1000;
  1. 页面退出时清理不再需要的图片URI映射。

5.4 Blob数据依赖GC释放修复

问题:Blob数据依赖JS GC释放,但JS对象被全局缓存。

解决方案:

  1. 业务代码避免全局缓存Blob对象:
// 修改前:全局缓存Blob
const blobCache = new Map();
blobCache.set(id, blob);

// 修改后:使用完毕后主动释放
const blob = await fetchBlob(id);
useBlob(blob);
blob.close(); // 主动释放
  1. 在Native侧增加blob数据的主动释放接口:
// TurboModule接口
releaseBlob(blobId: string): void;

// 业务代码调用
BlobModule.releaseBlob(blobId);
  1. 不完全依赖GC,对于关键资源提供主动释放接口。

5.5 多实例场景下静态单例累积修复

问题:静态单例在RNInstance销毁后仍保留数据。

解决方案:

  1. 确保RNInstance销毁时所有清理回调被正确执行:
~RNInstanceInternal() {
  // 确保所有清理方法被调用
  CallbackGlobalRegistry::instance().unregisterModule(name_, m_callbackGuardRegistry.get());
  RNOHMarker::removeListener(m_markerListener);
  PerformanceMetricsRegistry::getInstance().removeRNInstanceMetrics(m_id);
  FreezeStackCollector::getInstance().unregisterRuntime(m_id);
}
  1. 对静态单例增加Instance级别的清理逻辑。

  2. 使用weak_ptr存储Instance相关数据,允许自动回收。

5.6 常见泄漏模式代码审查清单

JS层:

  • useEffect返回的清理函数是否移除了事件监听器
  • setInterval/setTimeout是否在组件卸载时清除
  • 全局Map/Set/Array是否有大小上限和淘汰策略
  • Promise是否在所有路径都有resolve/reject
  • 异步回调是否持有已卸载页面对象的引用

Native/C++层:

  • napi_ref是否在所有路径(包括异常路径)都有对应的napi_delete_reference
  • jsi::Value/jsi::Object是否被捕获到长生命周期的Lambda中
  • TurboModule的Promise是否在所有路径都resolve/reject
  • HostObject是否依赖JS GC释放关键Native资源
  • 缓存容器是否有大小上限和淘汰策略
  • 析构函数是否正确清理了所有跨线程引用

系统资源层:

  • 文件句柄是否在异常路径也能正确关闭
  • 网络连接是否设置了超时
  • VSync/定时器是否在组件卸载时注销
  • 图片解码缓冲区是否及时释放

6 经验总结

6.1 资源泄漏定位的核心原则

  1. 先判断泄漏发生在哪一层:通过AnonPage other和Native Heap的增长情况,初步判断是JS层、Native层还是跨层引用问题。

  2. 使用对应工具深入分析:JS层问题用React Native DevTools,Native层问题用DevEco Profiler Allocation,跨层问题需要两者结合。

  3. 关注跨层引用关系:RNOH应用中,JS、Native、ArkTS三层通过napi_ref、jsi::Value、HostObject等机制相互引用,泄漏往往发生在跨层引用链上。

  4. 修复后必须严格验证:泄漏修复后需要重复操作20次以上,触发GC,对比快照和Allocation,确认内存不再持续增长。

6.2 常见误区

误区正确做法
内存上涨就认为是泄漏JS引擎和系统内存回收有延迟,短时间上涨可能只是GC延迟,需要多次操作后对比
只看总内存判断泄漏方向需要同时看AnonPage other和Native Heap,单看总内存无法判断泄漏方向
快照看到Closure或GC roots就认为是虚拟机问题这通常说明对象仍被JS引用链保留,需要展开保留器向上追到具体代码
只靠JS快照定位Native持有问题JS快照只能证明对象被持有,还需要用Allocation和代码调用链继续定位
修复后只看内存是否下降必须重复操作20次以上,触发GC,对比快照和Allocation,确认泄漏真正消失

6.3 修复验证清单

  1. 重复操作验证:执行泄漏操作20次以上,确认内存不再阶梯式增长
  2. GC后验证:触发GC或等待2分钟后,确认内存回落到接近baseline
  3. 快照对比:对比修复前后的JS快照,确认泄漏对象不再出现
  4. Allocation对比:对比修复前后的Native Allocation,确认Created & Existing数量不再增长
  5. 长时间运行:模拟真实使用场景运行30分钟以上,监控内存曲线是否平稳
  6. 多Instance验证:如果使用多RNInstance,反复创建/销毁Instance,确认内存回落

附录

附录A:常见泄漏根因速查表

泄漏现象可能根因关键代码位置排查方向
AnonPage other持续增长JS事件监听器未移除业务JS代码检查useEffect清理函数
AnonPage other持续增长JS全局缓存无上限业务JS代码检查Map/Set/Array大小
Native Heap + AnonPage other同步增长napi_ref未释放TurboModule/N-API代码检查NapiRef生命周期
Native Heap + AnonPage other同步增长CallbackWrapper未释放ArkTSTurboModule.cpp检查Promise是否resolve/reject
仅Native Heap增长TurboModule缓存无淘汰TurboModuleProvider.h检查m_cache增长
仅Native Heap增长图片URI缓存无上限ImageSourceResolver.h检查remoteImageSourceMap
仅Native Heap增长Blob数据未释放BlobCollector.cpp检查JS Blob引用是否释放
仅Native Heap增长HostObjectProxy未释放JSVMRuntime.cpp检查JS HostObject引用
多Instance累积静态单例未清理多个静态单例检查Instance析构清理路径
VSync相关weak_ptr原始指针泄漏VSyncListener.cpp / UITicker.h检查VSync回调是否正常触发

附录B:HarmonyOS资源泄漏检测工具

检测手段适用场景工具
hidumper内存查看进程级内存趋势监控hdc shell hidumper --mem <pid>
DevEco Profiler AllocationNative堆分配追踪DevEco Studio > Profiler > Allocation
React Native DevTools MemoryJS堆快照对比分析React Native DevTools > Memory
HiAppEvent系统事件内存压力事件通知系统回调onMemoryLevel
ASan/MSan内存错误检测编译时开启AddressSanitizer

附录C:RNOH关键静态单例

单例数据清理机制泄漏风险
TurboModuleProvider::m_cache模块缓存随Instance析构无(Instance级)
CallbackGlobalRegistry模块回调注册unregisterModule未调用时残留
RNOHMarker::listeners性能标记监听器notifyListeners时压缩不notify时累积
ImageCacheMemoryCache图片内存缓存内存压力回调清空全局单例不析构
PerformanceMetricsRegistry性能指标removeRNInstanceMetrics未调用时残留
FreezeStackCollector冻屏栈缓存unregisterRuntime未调用时残留

附录D:系统资源泄漏常见场景

资源类型泄漏场景影响
文件句柄打开后未关闭,异常路径跳过close句柄数耗尽,后续open失败
网络连接HTTP请求未设置超时,回调未清理连接数耗尽,内存增长
图片解码缓冲区大图片解码后未及时释放Native Heap快速增长
VSync句柄注册后未注销持续触发回调,CPU和内存开销
定时器libuv Timer未取消事件循环无法退出,内存残留
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐