原文链接:www.callstack.com/blog/memory…
作者:Kamil Paradowski,Mike Grabowski
你正在开发一个处理视频帧的React Native应用。每个帧都以ArrayBuffer的形式从摄像头、网络流或文件源传入JavaScript。你决定将繁重的工作交给原生模块处理。
你将ArrayBuffer传递给原生模块,让它完成任务并获取结果。很简单,对吧?其实不然。
保持同步处理时一切正常。但当你试图通过将任务卸载到后台线程进一步优化时,就会发现:将JavaScript的垃圾回收机制与原生代码的显式内存管理相结合,远非表面看起来那么简单。
本文将深入剖析这两个世界,揭示其底层运作机制。
同步成功案例
我们先从简单的情况开始:同步处理。将一个ArrayBuffer传递给本机代码,它立即进行处理并返回。
const pixels = new ArrayBuffer(1920 * 1080 * 4); // 4K RGBA image nativeModule.applyFilter(pixels); // Synchronous call // Done! Buffer is processed
在原生端,你可以直接访问缓冲区的内存:
// Native side
void applyFilter(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
uint8_t* pixels = buffer.data(runtime); // Raw pointer
size_t size = buffer.size(runtime);
// Process the image using optimized native libraries
processImageFilter(pixels, size);
// Function returns, buffer is still valid
}
为什么速度更快? 原生代码能直接调用成熟的处理库(如OpenCV),这些库可直接操作原始内存,并自动利用SIMD或硬件加速。而JavaScript目前尚无同等低级别的工具,因此原生代码能让你直接调用现成的高度优化程序。
异步处理
但随后你会想到:"如果处理耗时更长怎么办?我不希望在原生代码执行重负载时阻塞 JavaScript 线程。"
于是你尝试通过将任务卸载到后台线程来优化:
void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
uint8_t* data = buffer.data(runtime);
size_t length = buffer.size(runtime);
// Offload to background thread for better performance
backgroundQueue.async([data, length]() {
processImage(data, length);
});
}
这似乎合理。你捕获原始指针和长度,将任务放入队列,并立即返回以避免阻塞 JavaScript。但问题正出在这里。
当 processImage(data, length) 实际在后台线程执行时,数据指针可能已指向空值,更糟的是,它可能指向已被完全重新用于其他用途的内存区域。
内存管理
要理解这里究竟发生了什么,我们需要看看每个世界如何管理内存:
JavaScript:垃圾回收器自动管理
大多数JavaScript开发者生活在一个内存自动管理的环境中:
const buffer = new ArrayBuffer(1024);
// Use it
// Forget about it
// GC cleans it up eventually
你永远不必考虑内存何时释放,不必担心悬空指针,更不必协调线程间的访问——毕竟只有单线程运行。
在底层,JavaScript引擎(V8、JavaScriptCore、Hermes)会将该缓冲区分配到其托管堆中。
对于ArrayBuffer,引擎会创建两部分:对象头部和后备存储区。
- 对象头部通常占用约16-24字节,包含缓冲区类型、长度等元数据,以及指向实际数据的指针。
- 后备存储区则是存放1024字节数据的实际内存区域,对于较大缓冲区通常会单独分配。
引擎随后通过垃圾回收系统追踪该对象,将其加入对象图谱以识别仍在使用的资源。
当你的ArrayBuffer变得不可达(没有任何GC根对象引用时),垃圾回收器最终会启动。它会标记可达对象,清除未标记的对象,并可选地进行内存压缩以减少碎片(将对象移近以消除释放对象留下的空隙)。你无法选择何时触发此过程。
但这种简洁性伴随着取舍。垃圾回收在它需要时运行,而非你期望时启动。现代垃圾回收器(包括Hermes中使用的Hades)大多采用并发机制,在后台运行且不暂停JavaScript执行。你无法指定"立即释放此对象"或"保持此对象存活",只能顺其自然。
本机代码:显式控制
本机代码遵循不同的规则:
void processData(uint8_t* data, size_t length) {
// Who allocated this?
// When will it be freed?
// Can I use it after this function returns?
// What if another thread modifies it?
}
该指针数据仅是一个64位整数(在64位系统上),包含一个虚拟内存地址。
它可能指向栈(局部变量)、堆(动态分配的内存)或内存映射区域(直接从文件映射的内存或进程间共享的内存)。 该地址可能是只读的,也可能是可写的。 分配它的代码或线程随时可能释放该内存,导致指针失效。 就在你阅读本文的此刻,另一个线程可能正在向data[100]写入数据。 每个原始指针(即未加安全封装的直接内存地址)都意味着契约。要安全使用内存,你必须明确:谁负责释放内存?有效期多长?使用期间是否可能变更?是否支持多线程访问?是否正确对齐?有效地址范围为何?编译器不会为你检查任何这些细节。
每个原始指针——即没有任何安全封装的直接内存地址——都是一种契约。 要安全使用内存,你必须明确:谁负责释放内存?内存有效期多长?使用期间是否可能发生变化?是否支持多线程访问?是否正确对齐?有效地址范围为何?编译器不会为你检查任何这些细节。
原生代码赋予你控制权,却要求严谨自律。编译器无法避免"释放后使用"错误(访问已被释放的内存),这类错误可能引发未定义行为(如数据损坏或程序崩溃)。运行时环境不会检测数据竞争。而CPU呢?它会欢天喜地地加载该地址处的任意垃圾数据,或者在操作系统不再将该内存地址映射到真实内存时,直接因段错误而崩溃。
JSI:围绕GC管理的对象的智能封装器
JSI 介于这两个世界之间。
一个 jsi::Object(及其所有子类,如 jsi::ArrayBuffer)本质上是对垃圾回收管理值的智能封装。它保持底层 JavaScript 对象存活,并在对象于压缩过程中移动时允许引擎更新内部指针。实际应用中,这意味着只要你始终在 JSI API 内部操作,即使垃圾回收器正在工作,一切也能"正常运行"。
调用 jsi::ArrayBuffer 的 data(runtime) 方法将返回指向底层存储的原始原生指针。该指针并非智能封装器。若引擎决定移动底层存储,JSI 虽会保持 jsi::ArrayBuffer 对象本身有效,但您先前捕获的任何原始指针都不会被更新。
在当前的Hermes中,ArrayBuffer后备存储区通过
malloc()在C堆中分配,因此不会被GC压缩移动。但这属于实现细节而非跨引擎保证,JSI本身无法承诺ArrayBuffer数据永远不可移动。在围绕原始指针设计API时,应基于抽象模型进行考量(数据可能移动或脱离),而非依赖单一引擎的当前实现。
问题开始了
既然我们已经了解了JavaScript与原生环境间内存管理的理论基础,现在让我们回到之前的异步代码片段。
void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
uint8_t* data = buffer.data(runtime);
size_t length = buffer.size(runtime);
// Offload to background thread for better performance
backgroundQueue.async([data, length]() {
processImage(data, length);
});
}
这里存在两个问题:
- 当 processAsync 返回时,jsi::ArrayBuffer 便超出作用域,因此垃圾回收器可在后台任务仍在运行时将其回收。
- 你在调度任务时捕获了原始指针和长度。若后端存储发生变更,该指针将不再指向实际缓冲区。
更规范的版本会保持
jsi::ArrayBuffer 的存活,仅在异步任务内部请求数据和大小:
void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
auto sharedBuffer = std::make_shared<jsi::ArrayBuffer>(buffer);
backgroundQueue.async([sharedBuffer, &runtime]() {
uint8_t* data = sharedBuffer->data(runtime);
size_t length = sharedBuffer->size(runtime);
processImage(data, length);
});
}
该模式解决了两个重要问题:
- 生命周期:只要lambda表达式存活,
sharedBuffer就会保持JavaScript对象的存活状态,因此垃圾回收器不会回收它。 - 指针新鲜度:你在使用点调用
data(runtime)和size(runtime),因此始终能获得反映引擎当前后备存储的指针。
但即使采用此模式,您仍需对JSI外部的所有情况负责:
- 在您获取指针后,引擎(理论上)仍可分离或重新分配后备存储区。
- 原生代码读取缓冲区期间,JavaScript代码仍可能对其进行修改。
面对这些挑战,你有哪些选择?
方案1:保持同步
保持同步处理。这种方式效果完美,但会阻塞JavaScript线程。对于快速操作尚可接受,但对于耗时较长的操作则会损害用户体验。
理论上,任何JS操作(包括由原生代码触发的操作)都可能引发GC压缩,因此最安全的模式是在尽可能接近实际使用位置处获取指针。
方案二:复制数据
处理前将缓冲区复制到原生拥有的内存中:
void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
size_t length = buffer.size(runtime);
uint8_t* copy = new uint8_t[length];
memcpy(copy, buffer.data(runtime), length);
backgroundQueue.async([copy, length]() {
processImage(copy, length); delete[] copy;
});
}
复制操作为你提供了一个简单可靠的边界:一旦数据驻留在本机内存中,你的异步工作就完全脱离了垃圾回收行为或 JavaScript 变异的影响。
其代价在于成本。每帧移动数兆字节的数据会迅速累积,对于高带宽工作负载而言,这可能成为瓶颈。
方案三:原生拥有缓冲区
另一种方法是在原生端完全分配和管理内存,并将其作为ArrayBuffer暴露给JavaScript。JavaScript可向这些缓冲区写入数据,但内存本身始终由原生端控制。这既能提供稳定不可移动的内存,又能确保异步处理安全无生命周期意外。
Nitro Modules 采用此模型,其暴露的 ArrayBuffer 具有明确的所有权语义。Nitro 允许原生代码创建拥有权缓冲区,并将 JS 创建的缓冲区视为非拥有权缓冲区,从而保持原生生命周期的保证。
func doSomething() -> ArrayBuffer {
let buffer = ArrayBuffer.allocate(1024 * 10)
print(buffer.isOwner) // <-- ✅ true
let data = buffer.data // <-- ✅ safe, we own it!
self.buffer = buffer // <-- ✅ safe
DispatchQueue.global().async {
let data = buffer.data // <-- ✅ also safe!
}
return buffer
}
若您当前在 React Native 中需要零拷贝缓冲区,Nitro 的方案是现有的最完整解决方案。
除非 JavaScript API 能直接写入原生提供的
ArrayBuffer,否则始终至少存在一次复制。若数据源于 JS,在执行任何安全的异步操作前,必须将其复制到原生内存中。原生拥有的缓冲区可避免后续所有复制,但除非 JS API 本身支持写入外部分配的内存,否则无法消除首次复制。
结论
在JavaScript与原生代码之间传递对象涉及两种截然不同的内存模型。JavaScript依赖垃圾回收堆,其中的对象可能移动或脱离关联;而原生代码则操作手动分配和管理的原始指针。JSI通过保持JavaScript对象可访问性,并让原生代码受控访问其后备存储,从而架起了这两个世界的桥梁。
同步使用通常是安全的,因为所有操作都在单次调用内完成——前提是您在使用前立即获取指针,且在此期间避免触发额外的 JavaScript 执行。
异步代码的情况则更为复杂。通过保持 jsi::ArrayBuffer 的存活状态,并在使用时获取新指针,可实现异步处理。但您仍需考虑垃圾回收行为,以及工作运行前可能发生的任何 JavaScript 执行。
或者,你可以:
- 将数据复制到本机拥有的内存中,完全避免这些问题,代价是需要提前进行复制;或者
- 使用本机拥有的缓冲区(例如通过Nitro提供的缓冲区),在跨异步边界时获得稳定的内存,无需重复复制,前提是数据源自本机。
唯一需要避免的做法是捕获原始指针并假设其在时间或线程间保持有效。
特别感谢 Tzvetan Mikov(@tmikov)提供的补充背景、详细说明及审慎审阅。