HarmonyOS 7 新特性实战(25):Native 强引用的受控持有与释放
进入展厅、退出,再进入,内存持续升高。页面已经消失,模型对象却可能仍被 Native 回调或全局句柄持有。此时只检查 ArkTS 页面是否置空,往往找不到最后一条引用链。
DevEco Profiler 的跨语言分析能力可以帮助观察 Native 与 ArkTS 之间的持有关系。实验应从一条可重复操作路径开始,先找到谁拥有对象,再修复释放时机,最后用相同路径比较。
增长不一定就是泄漏
图片缓存、分配器保留内存和首次加载都会造成曲线上升。真正值得调查的是重复进入退出后,已经无业务用途的对象仍被保留,并形成可解释的持有路径。
先规定测试次数、每次等待时间和结束条件,区分首次预热与后续循环。仅截取两个不同时刻的总内存值,无法判断对象是否仍然可达。
故障样本只放在实验模块
可以设计一个 Native 回调注册后未解除的样本,让它持有页面传入的对象。实验对象应很小且可控,不在生产路径中故意制造大规模泄漏。
// 伪代码:说明所有权,不对应某个完整 N-API 实现。
class CallbackOwner {
public:
void Attach(PageCallback callback);
void Detach();
void Close();
private:
bool closed = false;
// 注册回调持有页面对象,Detach 必须解除该引用。
};
对真实 N-API 实现,需要分别管理引用、异步任务和回调生命周期。页面对象置空不会自动解除所有 Native 持有;销毁 Native 对象也必须遵守线程和环境有效性要求。
从存活对象追到持有者
固定执行进入页面、注册回调、触发一次操作、退出页面。采集后查找仍存活的目标对象,沿引用链确认 Native 分配栈与关联句柄,区分 LocalHandle、GlobalHandle 或其他资源的实际情况。
工具版本和支持对象范围影响结果展示,应保存版本信息和采集配置。没有出现某条关联不代表它不存在,也可能是该版本或采集方式不覆盖。
按所有权修复释放顺序
合理顺序通常是停止新任务、解除回调、等待在途工作安全结束,再释放持有对象的资源。具体次序依据接口合同调整,不能在工作线程仍使用回调时直接删除引用。
页面离开 → 会话停止接收请求 → 取消/等待在途任务
→ 解除Native回调 → 释放引用 → 销毁所属资源
失败路径与成功路径都要执行清理。初始化完成一半就抛错时,只释放已经成功创建的对象;重复关闭应保持幂等,不引入双重释放。
修复后重放同一操作序列
对照包使用同一设备、同一素材和同样次数。比较目标对象存活数量、引用链是否消失与内存趋势。对象释放正确而进程内存没有立刻下降,可能来自分配器行为,应结合对象证据解释。
不要为了让曲线下降而每次退出清空全局业务缓存。那会改变性能与功能,掩盖实际的所有权问题。修复应针对泄漏资源,不影响收藏和阅读记录。
将结果沉淀为资源合同
最小 Demo 需要故障版与修复版,文章展示持有链、关键释放变化和同条件复验结果。截图应能辨认目标对象与分配来源,不能只放一个泛内存曲线。
后续把同样规则应用到模型、播放器和图片处理服务:谁创建、谁关闭、异步结果如何失效、回调由谁解除。一次清晰的跨语言实验,可以减少多个功能中重复出现的生命周期错误。
释放正确性与曲线回落分别下结论
| 修复后观察 | 可以得出的结论 |
|---|---|
| 目标引用链消失,对象不再存活 | 该持有关系已解除 |
| 进程内存暂未回落 | 还需区分分配器保留与其他对象 |
| 多轮仍增加同类存活对象 | 继续查找漏掉的创建或清理路径 |
强制触发清理后截取最低点容易掩盖真实生命周期,应保持故障包与修复包采集条件一致。实施先构造一条小对象保留路径,再取得分配与持有证据,最后修改所有权并重放。增加多种泄漏样本会扩大排查范围,首篇只保留一个可解释问题。
参考:Profiler 跨语言内存分析官方介绍、DevEco 工具文档入口。
强引用、外部内存与释放入口的归属
实验模块通过 napi_create_reference 创建计数为 1 的强引用。每个对象带有 8 KiB 的 ArrayBuffer,一次只创建一个对象,最多持有 32 个,避免为了观察泄漏无限增长内存。
引用保存在模块实例状态中,releaseAll 逐一调用 napi_delete_reference;释放失败时保留待释放引用,允许后续重试。重复释放空集合返回零,环境销毁钩子执行兜底清理。页面退出默认释放实验引用。
#include "napi/native_api.h"
#include <vector>
struct State { napi_env env; std::vector<napi_ref> refs; };
static napi_value Fail(napi_env env, const char* message) {
napi_throw_error(env, nullptr, message); return nullptr;
}
static napi_value Number(napi_env env, size_t value) {
napi_value result = nullptr;
if (napi_create_uint32(env, static_cast<uint32_t>(value), &result) != napi_ok) return Fail(env, "number creation failed");
return result;
}
static napi_value Retain(napi_env env, napi_callback_info info) {
void* data = nullptr; napi_value args[1]; size_t argc = 1;
if (napi_get_cb_info(env, info, &argc, args, nullptr, &data) != napi_ok || argc != 1) return Fail(env, "one object required");
auto* state = static_cast<State*>(data); napi_valuetype type;
if (napi_typeof(env, args[0], &type) != napi_ok || type != napi_object) return Fail(env, "object required");
if (state->refs.size() >= 32) return Fail(env, "lab reference cap reached");
napi_ref ref = nullptr;
if (napi_create_reference(env, args[0], 1, &ref) != napi_ok) return Fail(env, "create reference failed");
state->refs.push_back(ref);
return Number(env, state->refs.size());
}
static napi_value Count(napi_env env, napi_callback_info info) {
void* data = nullptr;
if (napi_get_cb_info(env, info, nullptr, nullptr, nullptr, &data) != napi_ok) return Fail(env, "callback read failed");
return Number(env, static_cast<State*>(data)->refs.size());
}
static napi_value Release(napi_env env, napi_callback_info info) {
void* data = nullptr;
if (napi_get_cb_info(env, info, nullptr, nullptr, nullptr, &data) != napi_ok) return Fail(env, "callback read failed");
auto* state = static_cast<State*>(data);
while (!state->refs.empty()) {
if (napi_delete_reference(env, state->refs.back()) != napi_ok) return Fail(env, "delete reference failed");
state->refs.pop_back();
}
return Number(env, 0);
}
static void Cleanup(void* data) {
auto* state = static_cast<State*>(data);
for (napi_ref ref : state->refs) napi_delete_reference(state->env, ref);
delete state;
}
static napi_value Init(napi_env env, napi_value exports) {
auto* state = new State{env, {}};
state->refs.reserve(32);
if (napi_add_env_cleanup_hook(env, Cleanup, state) != napi_ok) { delete state; return Fail(env, "cleanup registration failed"); }
napi_property_descriptor properties[] = {
{"retain", nullptr, Retain, nullptr, nullptr, nullptr, napi_default, state},
{"releaseAll", nullptr, Release, nullptr, nullptr, nullptr, napi_default, state},
{"count", nullptr, Count, nullptr, nullptr, nullptr, napi_default, state}
};
if (napi_define_properties(env, exports, 3, properties) != napi_ok) return Fail(env, "export registration failed");
return exports;
}
static napi_module module = {1, 0, nullptr, Init, "reference_lab", nullptr, {0}};
extern "C" __attribute__((constructor)) void RegisterReferenceLab() { napi_module_register(&module); }
ArkTS 侧保留与释放的调用如下,每次显式操作仅创建一个实验对象:
class RefPayload {
label: string = 'articlelab_retained_object';
buffer: ArrayBuffer = new ArrayBuffer(8192);
}
const held: number = referenceLab.retain(new RefPayload());
const remaining: number = referenceLab.releaseAll();
把释放动作放进真实页面销毁路径
只把应用退到后台,再次打开,页面可能仍留在路由栈中。这个动作无法触发预期的组件销毁。实验增加了一个独立页面,一次持有四个 8 KiB 对象,再用 replaceUrl 将它替换为结果页。销毁回调负责释放引用,结果页负责重新读取 Native 计数。
aboutToDisappear(): void {
const remaining: number = referenceLab.releaseAll();
AppStorage.setOrCreate('referenceLifecycleRemaining', remaining);
AppStorage.setOrCreate('referenceLifecycleDisposed', true);
}
这里的 releaseAll 管理的是实验模块中唯一一组引用。生产应用若有多个页面共享 Native 会话,应按所有者或句柄释放,不能照搬一个全局清空方法,否则可能释放另一个页面仍在使用的资源。
在 HarmonyOS 7.0.0.106、API 26 模拟器上,进入时计数为 0,持有后为 4,销毁完成后再次读取为 0。结果页首次 onPageShow 可能发生在旧页 aboutToDisappear 之前,因此界面保留“读取销毁与引用状态”按钮,让回读动作发生在页面切换完成后。

这个实验验证的是应用已经删除自己持有的强引用。对象是否还有其他根引用、何时被 GC 回收,以及 RSS 是否下降,需要分别观察。
三阶段快照如何安排
对照顺序应固定为“持有前→持有后→释放后”。每个阶段只发起一次采集,等待成功或失败后才允许下一步。服务层也要限制并发,避免两个页面同时触发昂贵的堆导出。
| 阶段 | 页面动作与引用计数 | 分析时要回答的问题 |
|---|---|---|
| baseline | 进入独立页面,计数 0 | 预热后同类对象的基线数量是多少? |
| held | 持有四个对象,计数 4 | 新对象是否能追溯到实验创建的 Native 强引用? |
| released | 销毁旧页并回读,计数 0 | 采集前 GC 后对象是否消失;若仍存在,谁还在持有? |
const source = await hidebug.dumpJsRawHeapData(true, true);
const destination = `${filesDir}/refs_${phase}_${Date.now()}.rawheap`;
await fileIo.copyFile(source, destination);
const size = fileIo.statSync(destination).size;
if (size <= 0) { throw new Error('empty raw heap snapshot'); }
调用外围使用 try/finally 恢复采集状态,并将失败原因显示在页面上。该接口返回 rawheap 路径,后续按官方转换工具说明处理,再在分析工具中观察对象与引用关系。实验每次请求 GC 并清理 nodeId 缓存,比较时应依据对象类型与引用关系,不能假定三份快照中的节点 ID 保持一致。处理完毕后清理实验快照,避免副本累积。
同步调用 dumpJsHeapData 会阻塞调用线程。本例首次在 UI 线程采集触发了 THREAD_BLOCK_6S,因此改为异步导出。6 GiB 和 32 GiB 数据盘均返回 11400110,后者日志报告剩余 31,803,654,144 字节。问题在于检查的是剩余空间:OpenHarmony 官方实现的 CheckDumpDiskSpace要求应用 FILE 目录所在文件系统剩余空间严格大于 30 GiB,即 32,212,254,720 字节。32 GiB 总容量扣除已用空间后,仍差约 389.7 MiB。
保留旧实例备份后,将实验实例初始化为 64 GiB 数据盘,df 可用空间约 57 G。重新安装相同 HAP,三个阶段均成功导出。开源实现与商业镜像不能直接视为同一版本,但这次容量对照与源码门槛相符;排查时应同时核对剩余字节数与接口日志。
ArkTS 原始堆应使用 SDK 中的 rawheap_translator,或按工具支持范围直接导入 Profiler。js_rawheap_translator 面向 ArkWeb/JSVM;选择工具时要确认虚拟机来源,不能仅凭相同的 .rawheap 扩展名混用。
从三份快照得到对象级证据
在同一应用进程中依次采集 baseline、held、released,使用 SDK 的 rawheap_translator 转成 heapsnapshot,得到以下结果:
| 阶段 | 原始快照字节数 | LifecyclePayload 实例数 | 持有关系 |
|---|---|---|---|
| baseline | 14,986,395 | 0 | 没有目标实例 |
| held | 14,995,059 | 4 | 四个实例均可追到 GlobalHandleRoot |
| released | 15,013,931 | 0 | 采集时已没有目标实例 |
统计时限定对象类型为 object、类名来自 ReferenceLifecyclePage 中的 LifecyclePayload,并要求拥有 label 字符串属性和指向 ArrayBuffer 的 buffer 属性。这能排除同名构造函数与原型。此次快照中的字符串内容为空,不能靠搜索标签文本识别对象。
held 快照中,GlobalHandleRoot[4248] 通过编号 4216~4219 的四条 element 边分别持有这四个实例。编号仅属于该快照,不应跨文件关联。实例自身 self_size 为 80 字节,不包含所引用的 8 KiB 缓冲区,不能把它当成对象的完整内存占用。

释放后目标实例由 4 变为 0,说明采集时这组对象已经不在堆中。与此同时,整个快照的节点数从 138,813 增至 139,047,文件也略大;界面状态和采集过程仍会产生其他对象,因此不能用整个文件大小判断这组引用是否释放。
这三份快照的 trace_function_count 均为 0,无法提供分配栈。本实验完成了对象数量和全局句柄持有链对照;Native 分配栈还需使用支持该采集信息的 Profiler 配置单独验证。
这份证据已经说明什么,尚未说明什么
| 观察到的事实 | 可以写入的结论 | 不能据此推导的结论 |
|---|---|---|
| baseline、held、released 的目标实例为 0→4→0 | 实验模块创建并解除了一组可识别的 Native 强引用 | 进程 RSS 必然立刻回落 |
held 的 4 条路径均从 GlobalHandleRoot 到目标实例 | 持有阶段存在全局句柄根链,释放后目标实例消失 | 已取得 Native 分配栈 |
三份快照 trace_function_count=0 | 本次 rawheap 没有可用的分配调用栈 | 不存在其他业务资源泄漏 |
DevEco Profiler 插件已在 D 盘独立配置下加载过,但其 ArkHeapSnapshotProcessor.startTakeHeapSnapshot 要求设备键、PID 与实时会话 ID;它不能把这里已经导出的离线 rawheap 直接当作实时采集结果。现有验收覆盖对象数量、根链与释放后消失;Profiler 工具窗口中的实时采集、Native 分配栈和 RSS 趋势留到下一阶段,不能用一张堆快照代替这三项证据。
查看官方内存泄漏检测说明时,应把对象保留、Native 引用释放和进程内存曲线分开记录:引用归零后,如果同类对象仍持续累积,继续沿引用链查找其他持有者;如果对象已回收而 RSS 暂未下降,则再分析分配器与缓存行为。
当前包的真机回归
2026-09-23 在 HBN-AL80(API 26)执行 baseline → 持有 4 个对象 → held → 销毁页。baseline rawheap 成功导出,大小为 14,703,355 B。首次回归发现 replaceUrl 后仅依赖 aboutToDisappear 没有完成释放,页面回读仍为 4;因此把释放操作移到跳转前显式执行,离页钩子只作为幂等兜底。重新构建、签名、安装后复测,结果为 销毁回调=true;回调释放后=0;当前引用=0。

这验证当前实验页的引用计数释放闭环和 rawheap 导出可用。截至9月23日,尚未把当日导出的 rawheap 转换并重做对象链统计,也没有取得 Profiler 实时窗口或 Native 分配栈,所以这些更强结论仍保持未验收。
9月24日补齐当前三阶段对象链
在同一API 26真机重新采集并转换三份rawheap,LifecyclePayload对象数为 0→4→0。持有阶段四条非弱element边来自GlobalHandleRoot[4248],编号4215~4218;释放后目标实例消失。原始文件分别为14,704,171、14,711,483、14,728,715字节,整份堆的节点总数仍可能增加,不能用文件变大判断释放失败。


三份原件已独立归档并校验哈希,详见采集与复算记录、原件清单和对象分析结果。这些材料补齐了当前包的对象级证据,但traceFunctionCount仍全部为0;Profiler实时界面、Native分配栈和RSS趋势没有对应结果,因此结论只覆盖受控对象的持有与释放。
更多推荐


所有评论(0)