本原创文章帖发布在华为开发者联盟社区,欢迎开发者前往访问评论交流,更多与该内容相关讨论,请点击原帖查看:

Local Handle与Global Handle内存分析实践-华为开发者话题 | 华为开发者联盟

概述

       在HarmonyOS的ArkTS与C/C++跨语言交互场景中,应用频繁通过Node-API在Native层创建并持有ArkTS对象的引用句柄。当这些句柄的生命周期管理不当时,会引发Native侧的内存泄漏:Local Handle(对应 napi_value)未在作用域结束前正确释放、Global Handle(对应 napi_ref)强引用创建后未调用 napi_delete_reference 删除,都会使ArkTS对象被长期持有,垃圾回收器无法回收,从而造成内存持续增长。

       然而,这类泄漏由于发生在Native侧,使用常规的ArkTS内存快照(rawheap)难以直接定位"哪一次napi句柄创建未释放",因为快照只能看到未被回收的对象,无法直接关联到创建该句柄的Native调用栈。

       ArkTS对象经Node-API被Native层长期持有造成的内存泄漏,通常会带来以下影响:

1. 性能:应用占用内存持续增长,系统为释放内存频繁触发GC,GC执行时会暂停应用主线程(Stop-The-World机制),导致界面卡顿、滑动不流畅;长期泄漏也会使内存碎片化严重,分配/释放效率降低。

2. 内存:泄漏内存持续积累并达到ArkTS堆或进程OOM的上限阈值时,会产生JS Crash。

3. 功耗:系统频繁GC消耗大量CPU资源,持续高占用会导致设备发热,加速电量消耗。

4. 功能:部分泄漏会因对象引用残留间接导致功能异常(如回调重复执行、状态错乱等)。

       本文将介绍以下内容:

   • Local Handle与Global Handle简介

   • 采集机制

   • 生成数据说明

   • ArkTS堆快照聚类分析规则

   • 命令行采集

   • 场景案例

   • 常见问题

实现原理

Local Handle与Global Handle简介

       HarmonyOS通过Node-API在Native层操作ArkTS对象时,涉及两类引用句柄:

• Local Handle:用于管理ArkTS对象生命周期的引用句柄,对应Node-API中的 napi_value。napi_value 是一个表示ArkTS值的抽象类型,可表示基本类型(数字、字符串、布尔值)和复杂对象类型(数组、函数、对象等)。Node-API通过handle scope(句柄作用域)管理其生命周期:使用 napi_open_handle_scope 创建作用域,在作用域内创建的 napi_value 句柄会在 napi_close_handle_scope 关闭作用域时自动释放。框架层在执行开发者编写的native函数前会自动open scope、函数结束后自动close scope,因此定义在接口映射表中的函数无需手动管理作用域。若开发者在native侧脱离框架自动scope管理(如异步回调、长期存活的native对象中)持有 napi_value,或未正确关闭自行打开的作用域,句柄将无法被回收。

• Global Handle:用于跨作用域管理ArkTS值生命周期的引用句柄,对应Node-API中的 napi_ref。napi_ref 分为强引用和弱引用两种:弱引用创建时引用计数初始化为0,不会阻止垃圾回收;强引用创建时引用计数初始化为1(大于0),会阻止垃圾回收器回收被引用的对象,必须手动调用 napi_delete_reference 释放,否则会导致内存泄漏。创建强引用 napi_ref 后忘记删除,或引用计数管理不当,会使ArkTS对象被Native层长期强引用,GC无法回收。

       说明:采集时不会抓取弱引用(引用计数为0的napi_ref)的调用栈,因为它不阻止对象被GC回收,不构成泄漏。Local Handle仅支持Phone和PC设备采集。

       二者对比如下:

维度

Local Handle

Global Handle

对应Node-API类型

napi_value

napi_ref

生命周期管理方式

handle scope(作用域自动释放)

手动创建/删除引用计数

泄漏典型原因

作用域未正确关闭、异步持有句柄

强引用创建后未delete

采集标签

RES_ARK_LOCAL_HANDLE

RES_ARK_GLOBAL_HANDLE

IDE泳道呈现

Native Heap子泳道-ArkLocalHandle

Native Heap子泳道-ArkGlobalHandle

采集机制

       Local Handle与Global Handle采集能力由HiProfiler的native hook插件提供,通过 restrace_tag 参数指定要采集的资源类型。支持两种采集入口:

• DevEco Studio Profiler(Allocation任务):在"All Heap & Anonymous VM"泳道的录制配置中,通过 Record Data Range Options(DevEco Studio 6.1.0 Release新增)勾选 Local Handle 和 Global Handle,默认仅勾选 Malloc。展开 All Heap 泳道的 Native Heap 子泳道可分别查看 Malloc、ArkLocalHandle、ArkGlobalHandle 的内存分配。DevEco Studio Profiler相关操作可参考:

• 命令行 hiprofiler_cmd:通过 restrace_tag 参数指定 RES_ARK_LOCAL_HANDLE 或 RES_ARK_GLOBAL_HANDLE,适合脚本化、长时间采集场景。本文实践demo以此方式为主。

       native hook插件通过hook Ark引擎中句柄的创建与销毁接口,记录每一次Local Handle/Global Handle创建的调用栈。结合分配与释放的匹配机制:在匹配间隔内分配并释放的调用栈不被记录,未被匹配释放的即为泄漏对象。对于Local Handle,由于要求被测应用在启动时替换加载维测库,采集到的句柄记录均为未被回收的泄漏对象。

       在profiler代码中,restrace类型通过索引区分:

restrace类型

起始版本

RES_ARK_GLOBAL_HANDLE

API 23

RES_ARK_LOCAL_HANDLE

API 23

       栈采集数据以protobuf格式写入htrace文件。API 26.0.0起,统计模式新增local/global handle地址和buildId信息。

生成数据说明

数据内容

说明

trace文件(.htrace)

记录句柄创建的函数调用栈、线程与动态库维度的内存分配情况、调用栈次数与分配大小聚类信息。

调用栈

通过fp或dwarf回栈得到native栈,开启 js_stack_report 可从native向js层回栈,完成跨语言栈缝合,定位到创建句柄的ArkTS代码行。

地址信息

非统计模式实时返回;统计模式从API 26.0.0起支持采集Local Handle/Global Handle地址。

       trace文件可通过DevEco Studio Profiler的离线导入功能进行解析,导入的单个文件大小不超过1.5G。解析后Native Heap子泳道展示ArkLocalHandle/ArkGlobalHandle的分配统计(Statistics标签页)、调用树(Call Trees标签页)与分配列表(Allocations List标签页)。

ArkTS堆快照聚类分析规则

       trace文件(htrace)定位的是"哪个Native调用栈创建了未释放的句柄",而ArkTS堆快照(rawheap)记录的是所有无法被GC回收的ArkTS对象;两者配合使用——先用trace文件定位句柄创建栈,再结合rawheap从对象维度做聚类分析,可加速锁定泄漏对象。以下聚类规则针对rawheap快照分析。

       通过rawheap定位内存泄漏时,快照中对象数量可达十万级以上,无法人工快速识别同类型不同业务对象各自的内存占用,需聚类规则指导分析。建议优先聚类top20目录下、retained size占比5%以上的对象。聚类规则主要有三种:

聚类规则

适用对象类型

最短引用链聚类

Method、js_set、js_map、string、JSNativePointer、jsarray等。对比各对象到GC ROOT的最短引用链(多条时取retained size最大者),相同则归为一类。建议基本类型对象默认采用此规则。

名字+引用链聚类

Function、framework、(array)、业务对象(业务侧创建的类对象和函数对象)。对象带路径名与行号,相同类/函数调用点不同则引用链不同,需按调用点区分。

属性+引用链聚类

jsobject、js_shared_object。对象名相同但语义不同,通过引用链区分调用点;distance为1的对象无法用引用链区分时,按直接持有对象的名称及持有关系归类。

       特殊对象无需或另行处理:SourceTextModule(每ts文件对应一个,天然聚类)、HiddenClass(与对象1对多,每个一类)、GlobalEnv/GlobalObject(快照内唯一)无需聚类;Promise/PromiseRecord等异步对象按PromiseReaction下handle信息+最短引用链聚类;proxy按target信息+引用链聚类。
ArkTS内存快照聚类分析规则详见ArkTS内存快照聚类分析规则

命令行采集

功能概述

       ArkTS内存快照聚类分析规则借助 hiprofiler_cmd,您无需编写任何代码,只需在命令行中调整 Native Hook 插件配置参数,便能轻松为指定Debug签名应用快速开启Local Handle/Global Handle调用栈追踪功能。

使用方法

       • 确认应用为可调试应用(使用调试证书签名):

       以包名com.example.myapplication为例,执行:

hdc shell "bm dump -n com.example.myapplication | grep appProvisionType"

       预期返回 "appProvisionType": "debug"。构建可调试应用需使用调试证书签名,申请调试证书可参考debug版本应用 。user版本设备上的release签名应用不支持采集。

• 构建应用时保留符号表:参考模块级build-profile.json5文件,增加strip字段并赋值为false,不移除.so文件中的符号表、调试信息。采集到的函数栈在解析符号时需附带符号表信息,如无符号表则无法解析到正确的函数名。

• 确认设备已连接,hdc环境已就绪。

规格说明

规格项

说明

起始版本

API 23 起支持 RES_ARK_LOCAL_HANDLE 与 RES_ARK_GLOBAL_HANDLE

设备约束

Local Handle 仅支持 Phone 和 PC 设备

应用签名

仅支持使用调试证书签名的应用(debug签名应用)

弱引用

采集时不抓取创建弱引用(引用计数为0的napi_ref)的调用栈

Local Handle启动要求

需在应用启动时替换加载维测库(startup_mode为true)

统计模式地址

从 API 26.0.0 开始,统计模式支持采集 Local Handle/Global Handle 地址信息

性能影响

Local Handle替换维测库后本次运行打开时长变长、有性能损失,但不影响下次使用;建议仅在开发调试与压测阶段使用

输出路径

命令行 -o 指定的输出路径须以 /data/local/tmp 开头,子文件夹具备写权限

       说明:若应用在生命周期内被强制终止后重启,再次录制Local Handle时仍会重启应用。

场景案例

场景描述

       开发人员观测到应用进程内存持续增长,且应用中存在大量Node-API跨语言交互代码(如C++侧缓存ArkTS对象、异步回调持有句柄等)。需定位是哪一次 napi_value 或 napi_ref 的创建未释放,并分析其引用关系与涉及代码行。

开发步骤

       1. 抓取指定进程Global Handle对象的调用栈

       从API version 23开始支持抓取指定进程创建 napi_ref 的调用栈,不会抓取创建弱引用的调用栈。以抓取进程号为11237的进程为例:

$ hiprofiler_cmd \
  -c - \
  -t 60 \
  -o /data/local/tmp/hiprofiler_data.txt \
  -s \
  -k \
<<CONFIG
request_id: 1
session_config {
  buffers {
  pages: 16384
  }
}
plugin_configs {
  plugin_name: "nativehook"
  sample_interval: 5000
  config_data {
  save_file: false
  smb_pages: 16384
  max_stack_depth: 20
  pid: 11237
  string_compressed: true
  fp_unwind: true
  blocked: true
  callframe_compress: true
  record_accurately: true
  offline_symbolization: true
  startup_mode: false
  statistics_interval: 10
  malloc_disable: true
  memtrace_enable: true
  restrace_tag: "RES_ARK_GLOBAL_HANDLE"
  js_stack_report: 1
  max_js_stack_depth: 10
  }
}
CONFIG

       说明:

• malloc_disable: true 与 memtrace_enable: true 配合 restrace_tag 使用,用于过滤常规malloc抓栈数据,仅采集指定的资源类型。

• js_stack_report: 1 开启跨语言回栈,回溯出native到js的调用栈,定位到ArkTS代码行。

• -o 指定的输出路径需以 /data/local/tmp 开头,否则可能采集不到数据。

2. 抓取指定进程Local Handle对象调用栈

       从API version 23起支持Local Handle对象内存录制功能。Local Handle对象内存录制功能要求被测应用在启动时自动替换并加载维测库后,才能正常采集Local Handle内存栈信息。以包名为com.example.insight_test_stage的进程为例,须在命令行中设置参数 startup_mode: true:

$ hiprofiler_cmd \
  -c - \
  -t 60 \
  -o /data/local/tmp/hiprofiler_data.txt \
  -s \
  -k \
<<CONFIG
request_id: 1
session_config {
  buffers {
  pages: 16384
  }
}
plugin_configs {
  plugin_name: "nativehook"
  sample_interval: 5000
  config_data {
  save_file: false
  smb_pages: 16384
  max_stack_depth: 20
  process_name: "com.example.insight_test_stage"
  string_compressed: true
  fp_unwind: true
  blocked: true
  callframe_compress: true
  record_accurately: true
  offline_symbolization: true
  startup_mode: true
  statistics_interval: 10
  malloc_disable: true
  memtrace_enable: true
  restrace_tag: "RES_ARK_LOCAL_HANDLE"
  js_stack_report: 1
  max_js_stack_depth: 10
  }
}
CONFIG

       应用替换加载维测库方法:

• 应用处于退出状态:下发上述Local Handle录制命令(startup_mode为true),然后启动应用,应用启动后即可进行数据采集。

• 应用处于运行状态:下发录制命令(startup_mode为true),然后重启应用,应用重启后即可进行数据采集。

说明:

• 应用加载维测库后,只要应用不退出,维测库持续生效。此后可通过非启动模式录制Local Handle内存,此时startup_mode参数必须设置为false。

• 使用此种方式后,此次应用打开的时长会变长,此次运行的性能上也会有损失,但不影响下次使用。

• 此种方式抓取到的Local Handle内存一定是泄漏的(未被回收的句柄才被记录)。

• 命令行方式获取的trace文件,可通过DevEco Profiler离线导入功能解析,单个文件大小不超过1.5G。

• 从API版本26.0.0开始,统计模式支持采集local/global handle地址信息能力。

       3. 文件导出

       采集完成后,将设备上的trace文件导出到本地:

hdc file recv /data/local/tmp/hiprofiler_data.txt ./

       4. DevEco Profiler离线导入解析

       首先将导出的.htrace文件后缀改为.txt,然后在DevEco Studio的Profiler功能的会话区,点击Open File导入。该文件将会被自动解析为以下数据:

   • Native Heap子泳道(ArkLocalHandle/ArkGlobalHandle):展示分配统计信息,包括分配方式、总分配内存大小、总分配次数、尚未释放的内存大小与次数。

   • Call Trees标签页:展示内存分配栈,定位创建句柄的函数与所在so库。

   • Allocations List标签页:展示内存块起始地址、时间戳、活动状态、调用库与具体函数。

       说明:Release签名应用不支持跳转Native侧调用栈。开发者可双击可能存在问题的调用栈,跳转至相关代码执行分析、优化。

       5. 结合Node-API代码定位与修复

       定位到创建泄漏句柄的Native调用栈后,结合应用中的Node-API代码确认泄漏成因。以下是两类典型泄露场景代码示例:

       (1)Global Handle泄露场景

       Global Handle全局引用忘记delete:

#include <napi.h>

// 全局引用(泄漏重灾区)
static napi_ref g_my_ref = nullptr;

napi_value LeakRef(napi_env env, napi_callback_info info){
  napi_value obj;
  napi_get_cb_info(env, info, nullptr, nullptr, &obj, nullptr);

  // 创建强引用(初始计数=1)
  napi_create_reference(env, obj, 1, &g_my_ref); // ❌ 只创建不释放
  return nullptr;
}

// 缺少清理函数:
// void Cleanup(napi_env env) {
  // if (g_my_ref) {
    // napi_delete_reference(env, g_my_ref);
    // g_my_ref = nullptr;
  // }
// }

       Global Handle循环/重复创建不释放:

napi_value CreateAndLeak(napi_env env, napi_callback_info info) {
  napi_value obj;
  napi_get_cb_info(env, info, nullptr, nullptr, &obj, nullptr);

  napi_ref ref;
  // 每次调用都新建引用
  napi_create_reference(env, obj, 1, &ref); // ❌ 无delete
  // 错误:覆盖旧ref,旧ref句柄永久丢失
  // g_ref = ref;
  return nullptr;
}

       Global Handle类/​实例持有引用、析构不清理:

class NativeHolder {
  public:
  napi_ref m_ref;
  NativeHolder(napi_env env, napi_value obj) {
    napi_create_reference(env, obj, 1, &m_ref);
  }

  // ❌ 析构不delete
  ~NativeHolder() {
    // 缺少napi_delete_reference(env, m_ref)配对调用
  }
};

napi_value CreateHolder(napi_env env, napi_callback_info info) {
  napi_value obj;
  napi_get_cb_info(env, info, nullptr, nullptr, &obj, nullptr);
  NativeHolder* holder = new NativeHolder(env, obj);
  // 若不主动清理:holder泄漏 + m_ref泄漏
  return nullptr;
}

       (2)Local Handle泄露场景

       Local Handle局部引用忘记close:

// 通过napi_open_handle_scope/napi_close_handle_scope管理本地句柄
static napi_value HandleScopeTest(napi_env env, napi_callback_info info)
{
    // 创建句柄作用域
    napi_handle_scope scope;
    napi_open_handle_scope(env, &scope);
    // 在作用域内创建对象
    napi_value obj = nullptr;
    napi_create_object(env, &obj);
    napi_value value = nullptr;
    napi_create_string_utf8(env, "handleScope", NAPI_AUTO_LENGTH, &value);
    napi_set_named_property(env, obj, "key", value);
    //  ❌忘记关闭句柄作用域
    // napi_close_handle_scope(env, scope);
    return nullptr;
}

       关于Node-API引用与作用域接口的完整使用规范(napi_open_escapable_handle_scope、napi_escape_handle、napi_reference_ref/unref、napi_get_reference_value、napi_add_finalizer等),可参考

案例关联

       应用侧亦可通过 Performance Analysis Kit 的 HiDebug 资源采集接口(OH_HiDebug_StartProfiler/OH_HiDebug_StopProfiler,资源类型 OH_RES_TYPE_GLOBAL_HANDLE,API 24.0起)主动启动 Global Handle 分配栈采集,实现线上自诊断;

常见问题

现象1:抓取到的trace文件为空。

       可能原因与解决方法:检查 -o 指定的输出路径是否在 /data/local/tmp/ 目录下;若目标路径是该目录下的子文件夹,尝试对文件夹执行 chmod 777 操作;确认应用是否为debug签名应用。

现象2:Service not started。

       可能原因与解决方法:调优服务未能开启,说明正在使用DevEco Studio调优或上次调优异常退出,需执行 hiprofiler_cmd -k 之后再重新执行调优命令。

现象3:Local Handle采集无数据。

       可能原因与解决方法:Local Handle要求被测应用在启动时替换加载维测库。确认是否设置了 startup_mode: true,并按照"应用替换加载维测库方法"在命令下发后启动或重启应用。

现象4:调优时目标进程卡顿。

       可能原因与解决方法:适当减小 max_stack_depth 和 max_js_stack_depth 的值以减少回栈深度;适当增大 smb_pages 的值(默认16384页即64M,可调整到128M);适当增加 sample_interval 的值(默认256,可调整到512)。

现象5:FP回栈异常。

       可能原因与解决方法:检查对应共享库(SO)编译时是否开启了 -fomit-frame-pointer 编译选项,若开启该选项则需要对其关闭(即启用-fno-omit-frame-pointer、-funwind-tables),否则FP回栈失效。若修改上述编译配置仍无法回栈,请改用dwarf回栈(fp_unwind设为false)。

示例代码

• Node-API生命周期开发示例

• HiProfiler性能分析工具

----------------------------------------------------------------------------------------------------

      🔗 官网开发者学堂视频:华为开发者学堂

     🔗 社区DFX专题文章:  华为开发者问答 | 华为开发者联盟

【扫码加入 HarmonyOS DFX 技术交流群】

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐