React Native JSI 深入剖析 — 第 6 部分中文技术整理:跨 JS 与 C++ 两个世界的内存所有权

文章主旨

Part 5 让 JavaScript 可以持有 HostObject,但真正麻烦的问题从这里才开始:

JS 和 C++ 之间流动的那些字节,到底归谁所有?

这篇文章回答的是 JSI 最核心、也最容易出事故的问题:

  • JS 传给 native 的数据是复制还是共享?
  • native 返回给 JS 的对象什么时候会被释放?
  • shared_ptr 在 JS GC 参与后到底扮演什么角色?
  • 为什么字符串通常是 copy,而 ArrayBuffer 才有机会 zero-copy?

原文把这篇定位为"两个堆之间的边界地图",这个说法非常准确。

两个堆、两套规则、一个边界

这篇首先把问题讲得很直白:

  • JavaScript 有自己的 GC heap;
  • C++ 有自己的 native heap;
  • 它们不是同一套内存系统;
  • 它们也不共享同一套回收协议。

所以在 JSI 中最危险的思维错误是:

把 JS 和 C++ 想成同一个内存宇宙。

它们不是。

更准确的理解是:

  • JS GC 只理解 JS 对象图;
  • C++ 只理解自己显式管理的对象生命周期;
  • JSI 只是让你在边界上建立受控连接。

这也是为什么原文用"两个图书管理员共管一本书"来类比所有权问题。

shared_ptr 是连接两个世界的桥,但它不是魔法

Part 5 里 shared_ptr 已经出现过,这篇把它的作用讲得更完整:

  • C++ 侧持有一份共享所有权;
  • JS runtime 包装出来的 HostObject 也间接持有一份;
  • 最后一个引用消失时,底层对象析构。

这意味着 shared_ptr 的价值不只是"方便不手写 delete",而是:

它把 JS 对 native 对象的持有关系,纳入 C++ 可推理的生命周期模型。

但这不代表问题自动消失。因为:

  • shared_ptr 只能管理它知道的对象;
  • 它不自动托管你从对象里再解出来的裸指针;
  • 它不自动帮你处理跨线程 use-after-free;
  • 它也不替你决定数据该 copy 还是 share。

所以它只是桥,不是保险箱。

一个非常关键的问题:返回的方法怎么安全持有对象

这篇专门展开了 Part 5 留下来的一个坑:在 HostObject 的 get() 里返回方法时,如何保证方法闭包里的对象不悬空。

如果闭包只抓裸 this,那么:

  • JS 可能长期持有这个方法;
  • HostObject 本体却可能先被释放;
  • 之后调用方法时就会命中悬空地址。

原文把这称为 dangling method problem。

更安全的思路是:

  • 让对象由 shared_ptr 托管;
  • shared_from_this() 获取安全共享所有权;
  • 或在需要避免循环持有时配合 weak_ptr

这背后的本质是:

从 HostObject 返回出去的方法,本身也会把对象生命周期再拉长一层。

很多 JSI 崩溃都不是对象"没创建",而是对象"死得比你以为的早"。

字符串:默认就是复制,不要幻想零拷贝

这篇一个非常值得记住的结论是:

JSI 字符串基本上总是 copy。

无论是:

  • JS string 转 C++ std::string
  • C++ UTF-8 string 转 JS string

都会发生拷贝。

这件事的工程意义很大:

  • 小字符串问题不大;
  • 高频大文本就会贵;
  • 如果你处理的是二进制或大块数据,字符串不是正确通道。

很多人第一次做 JSI 容易把一切都往 string 上塞,这篇明确告诉你:那通常不是高性能路径。

ArrayBuffer 才是 JSI 里真正有价值的零拷贝能力

与 string 相对,原文强调 ArrayBuffer 是 JS 世界中少数适合和 native 共享底层字节的类型。

这意味着:

  • JS 可以把二进制数据给 native;
  • native 可以读取底层 buffer;
  • native 也可以构造 ArrayBuffer 再交给 JS;
  • 在某些设计下,双方可以看的是同一片字节,而不是两份副本。

这也是为什么音频、图像、视频帧、模型输入输出这类场景,会天然偏向 ArrayBuffer 而不是 string / JSON。

但原文同时提醒:零拷贝不是白送的。

只要你选择共享,就必须把所有权问题想清楚:

  • 谁分配的?
  • 谁释放的?
  • 共享期间谁可以写?
  • 跨线程还能不能安全读?

零拷贝换来的不是"只有性能收益",而是"更高的所有权复杂度"。

这篇最值钱的一部分:所有权矩阵

原文其实在反复建立一个判断框架:数据跨边界时,通常落在四种模式之一:

  1. JS 拷贝给 C++
  2. C++ 拷贝给 JS
  3. JS 与 C++ 共享同一份底层数据
  4. 某一方只做短期借用,不保留长期所有权

你在设计 JSI API 时,真正该先问的不是"能不能实现",而是:

  • 这次边界跨越属于哪一类?
  • 生命周期是否超出当前调用栈?
  • 是否会跨线程继续使用?

这个判断比具体 API 名字更重要。

三类典型内存 bug

原文把跨边界内存问题浓缩成三类 bug,这个总结非常好:

1. Use-after-free

对象已经被释放,但另一边还在继续读写。

最常见场景:

  • 背景线程还拿着裸指针;
  • HostObject 已被 GC 释放;
  • 闭包里仍然访问旧内存。

2. Memory leak

对象本来应该释放,但由于循环持有、遗漏释放或长期共享引用,导致一直活着。

典型场景:

  • shared_ptr 环;
  • JS 长期引用一个原本应当短生命周期的对象;
  • native 缓存没有及时清空。

3. Raw allocation 漏洞

绕过 RAII / 智能指针,手写原始分配后忘记回收。

这是最传统、也最没必要的错误。原文的立场很明确:现代 JSI 代码应尽量避免手写裸分配。

Copy 还是 Share,不是性能题,而是系统设计题

这篇最容易被低估的一点是:零拷贝并不总比 copy 更好。

选择 copy 的场景

  • 数据量小;
  • 生命周期简单;
  • 不希望共享期间互相影响;
  • 希望边界清晰、bug 面更小。

选择 share 的场景

  • 数据量大;
  • 高频传输;
  • 性能敏感;
  • 你能清楚定义所有权和释放协议。

所以"要不要零拷贝"不是一个口号问题,而是一个 tradeoff:

  • copy 增加 CPU / 内存带宽消耗;
  • share 增加生命周期与并发复杂度。

原文给出的态度很稳健:默认先选更安全的 copy,只有在性能真的要求时再上 share。

ArrayBuffer 并不天然等于"可跨线程安全共享"

这一点在后续 Part 8、Part 9 非常关键,这里已经埋下伏笔。

即使底层数据是 ArrayBuffer,你也不能轻易推出:

  • 可随便异步留存;
  • 可随便后台线程继续访问;
  • 可在 JS 侧对象消失后还继续持有裸地址。

因为:

  • JS runtime 生命周期还在变;
  • GC 何时清理你未必能控制;
  • 背景线程使用时往往必须先 copy 出安全副本。

这就是为什么原文后面会说:同步边界上的 zero-copy,和异步 / 跨线程边界上的 zero-copy,不是同一道题。

我的补充理解

1. Part 6 是整个系列最关键的安全边界课

前面几篇更像"能做什么",这一篇开始回答"为什么会崩"。很多 JSI 问题其实都能追溯到这里:

  • 裸指针拿太久;
  • 共享对象死得太早;
  • 以为字符串是 view,实际是 copy;
  • 以为 buffer 还活着,实际已被释放。

2. shared_ptr 管的是对象,不是所有从对象里拿出来的地址

这是很多事故根源。对象本身被 shared_ptr 托管,不代表:

  • 它内部 buffer 自动对所有线程安全;
  • 你缓存出来的裸指针也自动安全;
  • 外部异步任务不会越过生命周期边界。

一旦你把内部地址单独拿出来,新的所有权问题就开始了。

3. 性能优化和内存风险在 JSI 中是直接对价关系

Bridge 时代很多事做不了,所以很多低级所有权问题也被架构隐藏了。JSI 让你获得:

  • 同步调用;
  • 零拷贝;
  • native-backed object;

同时也让你亲自承担:

  • 谁释放;
  • 谁共享;
  • 谁跨线程;
  • 谁在 GC 之后还存活。

这就是 JSI 的能力边界和风险边界是同一件事。

关键结论

  • JavaScript GC heap 和 C++ native heap 是两套独立内存系统,JSI 只是在边界上建立受控交互。
  • shared_ptr 是 JS 与 C++ 共享对象生命周期的关键桥梁,但它不能自动解决裸指针和跨线程问题。
  • 字符串在 JSI 中通常是复制传递,不应把它当成零拷贝通道。
  • ArrayBuffer 才是高性能二进制数据交换的核心类型,但零拷贝会显著提高所有权复杂度。
  • 跨边界内存 bug 主要集中在 use-after-free、memory leak、raw allocation 三类。
  • copy 和 share 的选择,本质上是"性能"与"生命周期复杂度"的权衡。

下一步适合看什么

下一篇 Part 7 会把这些代码真正放到 iOS 和 Android 上跑起来,主题从"内存边界"切到"平台 wiring"。

但从学习顺序上看,Part 8 线程篇和 Part 9 音频篇会真正把这里的所有权问题推到实战强度,因为一旦跨线程,Part 6 的风险会成倍放大。

参考链接

相关推荐
jt君424261 天前
React Native JSI 深入剖析 — 第 5 部分中文技术整理:用 HostObject 把 C++ 类暴露给 JavaScript
前端·react native
花椒技术5 天前
RN 多包热更新实践:更新校验、运行时加载与 Bridge 缓存治理
react native·react.js·harmonyos
互联网推荐官5 天前
上海 APP 开发服务甄选:技术架构设计、全维度判断框架
javascript·react native·react.js·app开发·开发经验·上海
墨狂之逸才9 天前
TRAE IDE 提效实战指南:少加班,多摸鱼
react native
墨狂之逸才9 天前
给 AI Coding Agent 装上 React Native 外挂:callstackincubator/agent-skills 上手指南
react native
墨狂之逸才9 天前
# React Native 人脸识别 UI 方案全对比:嵌入组件 · Activity · Dialog
react native
沙漠10 天前
ReactNative总结系列四 --- FlatList白屏卡顿优化
react native·性能优化
wordbaby12 天前
rn-cross-calendar:一个兼容 React 18/19、RN/RNOH 的跨平台日历组件
前端·react native·harmonyos
沙漠12 天前
ReactNative总结系列三 --- 性能优化
react native·性能优化