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 的风险会成倍放大。

参考链接

相关推荐
song5011 小时前
React Native for OpenHarmony 实战:三方库 react-native-popup-menu 的鸿蒙化适配指南
css·深度学习·react native·react.js·transformer·harmonyos
梦想的颜色19 小时前
【编程实战】AI 时代 APP 开发全栈硬核指南:技术选型 + AI 架构 + 模型落地全维度决策
python·flutter·react native·react.js·ai·桌面应用·milvus
song5011 天前
React Native for OpenHarmony 实战:三方库 react-native-css-transformer 的鸿蒙化适配指南
css·人工智能·深度学习·react native·transformer
enjoywindstorm4 天前
移动端原生框架对比评测
react native
传奇开心果编程4 天前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
传奇开心果编程5 天前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程6 天前
【现代声明式UI学与练】第7课 组件通信——父传子、子传父、跨层级通信、状态提升与 Context
学习·flutter·react native·ui·swiftui·android jetpack
500846 天前
React Native for OpenHarmony 实战:三方库 react-native-url-polyfill 的鸿蒙化适配指南
javascript·react native·react.js·性能优化·electron·harmonyos
传奇开心果编程6 天前
【现代声明式UI学与练】第9课 性能优化——渲染优化、列表优化、内存优化、启动优化
学习·flutter·react native·ui·性能优化·swiftui·android jetpack
500846 天前
React Native for OpenHarmony 实战:三方库 react-native-volume-control 的鸿蒙化适配指南
javascript·react native·react.js·electron·harmonyos