ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JNI数组操作进阶:从拷贝机制到零拷贝与崩溃排查

JNI数组操作进阶:从拷贝机制到零拷贝与崩溃排查 JNI 数组操作这块说实话是最容易让人写崩的地方。你说 JNI 难吧字符串操作、对象字段访问这些顶多算繁琐但数组一进来模式就完全不一样了——关键不是你会不会调 Get 函数而是你知不知道每次调用背后发生了什么数据是被复制出来的还是拿到指针直接访问释放的时候用哪种模式这些决策直接决定程序是稳如老狗还是时不时崩一下。这篇指南主要解决四个问题怎么正确读取和修改 Java 传入的数组、怎么优雅地创建数组返回给 Java、怎么用直接缓冲区减少拷贝、以及那些真正生产环境里会把程序搞挂的坑。适合已经能写基础 JNI 函数、但想深入掌握数组场景的 Android NDK / JNI 开发者。看完你能直接用这套思路重构手里现有的数组相关代码把内存访问和异常处理理顺。1. JNI 数组操作的整体设计思路拆解1.1 为什么 JNI 数组操作是个“坑”先说个实际现象很多人在 Java 层写了个 int[] 传给 native 方法然后 Native 代码里直接 GetIntArrayElements 拿指针闷头改完再 Release感觉一切正常。但上线之后发现两种诡异 Bug——第一数组内容在某些设备上改了没生效第二更常见的偶尔直接崩日志里只有一行a JNI error has occurred。大部分问题都出在一个最基础的概念上JNI 并没有保证你拿到的数组元素指针就是 Java 堆里那份数据的指针。JNI 规范说得非常直白返回的指针可能是原始数据的直接指针也可能是一份临时拷贝。这取决于虚拟机实现、数组类型、垃圾收集器状态。也就是说你以为你在“改原数组”其实可能改的只是一块副本如果改完没有正确 Release这副本的变动根本不会同步回 Java 层。所以整个 JNI 数组编程的第一原则就是把你拿到的任何指针都当成“可能是一份拷贝”来看待。在这个前提下所有关于锁定、释放、提交commit、临界区 API 的设计逻辑就都说得通了。1.2 核心 API 体系三组函数各管一摊JNI 的数组操作 API 大体可以分成三类我建议你在脑子里建立这个划分类别代表函数适用场景元素访问类GetIntArrayElements / ReleaseIntArrayElements基本类型数组通用场景区域读写类GetIntArrayRegion / SetIntArrayRegion只操作数组某一段避免全量拷贝临界区类GetPrimitiveArrayCritical / ReleasePrimitiveArrayCritical性能敏感超大数组能接受短暂停顿对象数组类GetObjectArrayElement / SetObjectArrayElementString[]、自定义对象数组直接缓冲区NewDirectByteBuffer / GetDirectBufferAddress大块连续内存零拷贝这五组不是说越多越好而是每一类都有不可替代的使用场景。我见过很多代码全程只用GetIntArrayElements连区域操作都不知道这在大数组场景里是真的浪费。打个比方GetElements 就像你把整本书复印了一遍再读GetArrayRegion则像只复印你需要的几页——当数组很大而只需要处理其中一小段时性能差距非常直观。2. 基本类型数组Get/Release 与临界区的正确姿势2.1 基础用法GetIntArrayElements 和配套的 Release先看最标准的写法。以本地方法接收一个 int[]求和并返回为例JNIEXPORT jint JNICALL Java_com_example_NativeBridge_sumArray( JNIEnv *env, jobject thiz, jintArray arr) { if (arr nullptr) { return -1; } jsize len env-GetArrayLength(arr); jboolean isCopy JNI_FALSE; jint *elems env-GetIntArrayElements(arr, isCopy); if (elems nullptr) { // OOM 或虚拟机上锁失败 return -1; } jint sum 0; for (jsize i 0; i len; i) { sum elems[i]; } env-ReleaseIntArrayElements(arr, elems, 0); return sum; }几点容易被忽略的细节返回值判空GetIntArrayElements不是一定成功的当内存不足或底层锁失败时可能返回 null。不判空直接访问就是崩溃这种崩溃还特别难定位因为错误信息可能不会指向你这行。jsize是长度类型它是带符号的通常 32 位。不要跟 C/C 的 size_t 混用循环变量和数组索引都要注意符号匹配。每个 Get 必须配对 Release这个没有例外。即使是提前 return也要先 Release 再 return否则就是内存泄漏或锁没释放。2.2 isCopy 参数到底在告诉你什么这个参数是我见过被忽略得最严重的一个。jboolean *isCopy是输出参数虚拟机用它告诉你当前拿到的指针到底是原始数据还是拷贝JNI_FALSE拿到的是原始内存指针修改会直接反映到 Java 数组。JNI_TRUE拿到的是拷贝你改的是副本必须通过正确的 Release 模式把数据写回去。但这里有个很实用的建议不要在业务逻辑里依赖 isCopy 做判断。因为同一个数组、同一个虚拟机两次调用可能返回不同的 isCopy 值——它受 GC 状态影响。正确做法是无视它统一按“可能是拷贝”处理最后一定用 Release 把数据同步回去。2.3 Release 模式的三种选择Release 函数的第三个参数 mode 通常被草率地填 0但它其实有三种模式mode 值行为使用场景0将修改写回 Java 数组释放本地指针修改过数组内容后JNI_COMMIT将修改写回 Java 数组但不释放本地指针需要多次修改、写入一次性释放JNI_ABORT不写回修改仅释放本地指针只读数组或放弃修改说实话我日常写代码绝大多数时候就是 mode 0因为它语义最清楚修改写回指针释放。JNI_ABORT的典型场景是你拿数组指针只是为了做一次只读计算比如统计总和、找最大值这类纯读逻辑根本不需要把内容写回此时用JNI_ABORT可以免掉一次可能的回写拷贝。一个经典的反面案例我在代码审查里见过有人读取数组算平均值Release 用的却是 0。如果 isCopy 恰好是 JNI_TRUE那么 0 模式会把一份被改为原封不动的副本回写一次——这就是无意义的一整块内存拷贝。改成 JNI_ABORT 后性能在那段代码上能快不少。JNI_COMMIT则适合频繁修改的场景。比如你要在循环里多次更新数组内容又不想每次更新都 Get/Release 一遍可以这样jint *elems env-GetIntArrayElements(arr, nullptr); for (int iter 0; iter 10; iter) { // 修改 elems 内容 env-ReleaseIntArrayElements(arr, elems, JNI_COMMIT); // 写回但保留指针 } // 最终释放 env-ReleaseIntArrayElements(arr, elems, 0);当然这个场景有更优的替代方案比如区域操作但 JNI_COMMIT 在兼容老代码时很实用。2.4 GetPrimitiveArrayCritical临界区的性能利刃如果数组非常大比如几 MB 甚至几十 MB每次 Get 都拷贝一次会很难受。JNI 提供了GetPrimitiveArrayCritical它有两个目标一是尽可能返回直接指针零拷贝二是让 GC 在临界区内暂停对象移动。JNIEXPORT jlong JNICALL Java_com_example_NativeBridge_sumLongArray( JNIEnv *env, jobject thiz, jlongArray arr) { jsize len env-GetArrayLength(arr); jboolean isCopy JNI_FALSE; jlong *elems env-GetPrimitiveArrayCritical(arr, isCopy); if (elems nullptr) return 0; jlong sum 0; for (jsize i 0; i len; i) sum elems[i]; env-ReleasePrimitiveArrayCritical(arr, elems, JNI_ABORT); return sum; }但这个名字里的 Critical 不是白叫的它对应的使用约束极其严格在 Get 和 Release 之间不能调用任何可能阻塞的 JNI 函数——不能分配对象、不能调用 Java 方法、不能做文件 I/O、不能加锁等待。这期间虚拟机为了复用内存可能让持有直接指针的调用线程住进“GC 临界区”如果你在里面 sleep 或调 Java轻则死锁重则崩。必须是配对使用不能跟 GetIntArrayElements 混用一个指针。只支持基本类型数组不支持对象数组。实践经验这个 API 只适合“获取指针 → 紧凑地处理 → 释放”这种模式处理逻辑必须短小精悍。如果处理逻辑里面有日志输出、回调 Java、内存分配那就老老实实用 GetIntArrayElements。3. 对象数组与二维数组的处理3.1 GetObjectArrayElement逐一持有对象数组比如 String[]没有 GetElements 这种一次性拿全部指针的 API因为对象数组的元素本身是引用C 侧无法直接用连续内存表示。你只能逐个获取。JNIEXPORT jstring JNICALL Java_com_example_NativeBridge_getFirstString( JNIEnv *env, jobject thiz, jobjectArray arr) { jsize len env-GetArrayLength(arr); if (len 1) return nullptr; jobject first env-GetObjectArrayElement(arr, 0); if (first nullptr) { // 元素可能为 null或下标越界 if (env-ExceptionCheck()) env-ExceptionClear(); return nullptr; } return static_castjstring(first); // 谨慎需确认元素确实是 String }这里有两个非常容易踩的雷第一元素类型检查。如果 Java 层传进来的 array 是 Object[]你在 C 侧强转成 jstring 然后传给需要 jstring 的函数在多数实现上不会立刻崩但行为未定义。更稳妥的方式是用env-IsInstanceOf检查类型或者直接把数组类型定死在 Java 层方法签名里就用 String[]。第二局部引用溢出。在循环里 GetObjectArrayElement 获取大量对象每个得到的 jobject 都是局部引用。局部引用表在 JNI 里的容量有限旧版本默认只支持几百个循环里聚集不释放函数返回时会检测到局部引用溢出。典型的如for (int i 0; i len; i) { jobject element env-GetObjectArrayElement(arr, i); // 处理 element env-DeleteLocalRef(element); // 必须手动释放 }至于为什么 GetObjectArrayElement 的索引越界会抛 ArrayIndexOutOfBoundsException而 GetArrayLength 不抛这得从 JNI 层对异常的处理说起。JNI 中绝大多数的 JNI 函数在出错后不会取消本地代码执行而是让异常挂起。如果挂起异常后你继续调用其他 JNI 函数行为是未定义的。所以每当涉及 JNI 调用都要有“异常可能正在挂起”的警觉。3.2 二维数组的“锯齿”问题Java 的 int[][] 在 JNI 里其实是 jobjectArray每个元素又是一个 jintArray。很多新人以为可以直接拿到一个 int** 往下遍历这是不可能的。JNIEXPORT jint JNICALL Java_com_example_NativeBridge_sum2DArray( JNIEnv *env, jobject thiz, jobjectArray outer) { jsize rows env-GetArrayLength(outer); jint total 0; for (jsize i 0; i rows; i) { jintArray inner static_castjintArray( env-GetObjectArrayElement(outer, i)); if (inner nullptr) continue; // 该行可能为 null jint *innerElems env-GetIntArrayElements(inner, nullptr); jsize cols env-GetArrayLength(inner); for (jsize j 0; j cols; j) { total innerElems[j]; } env-ReleaseIntArrayElements(inner, innerElems, JNI_ABORT); env-DeleteLocalRef(inner); } return total; }有一点要额外提醒二维数组每一行的长度完全可能不一样——这就是 Java 的“锯齿数组”特性。你必须在遍历时对每一行单独取 GetArrayLength不能假设每个 inner 都是相同大小。这也是二维数组性能优化的难点外层是对象数组内层才是基本类型数组如果你想一次性做批处理基本不可能只能一层层展开。如果性能真的很敏感建议不要用 jobjectArray 传递矩阵改用一维 int[] 加宽度高度参数C 侧按行偏移计算。实战中很多图像处理库都这样设计接口不是为了省事而是避免每行一个对象引用的开销和 GC 压力。4. 直接缓冲区数组零拷贝的正确打开方式4.1 NewDirectByteBuffer从 Native 分配大块内存除了从 Java 数组复制数据JNI 还支持直接字节缓冲区DirectByteBuffer模式。这种模式下你可以在 native 侧分配一块内存封装成 ByteBuffer 返回给 Java或者接收 Java 层创建的 ByteBuffer直接拿到其内存地址完全绕开拷贝。native 侧分配并返回JNIEXPORT jobject JNICALL Java_com_example_NativeBridge_allocBuffer( JNIEnv *env, jobject thiz, jint size) { void *buf malloc(size); if (buf nullptr) return nullptr; // capacity 参数必须是正数long 类型的 address 在这里不传而是用 buf return env-NewDirectByteBuffer(buf, size); }Java 侧ByteBuffer buffer NativeBridge.allocBuffer(1024 * 1024);拿到这个 buffer 后的地址native 侧要访问它时用GetDirectBufferAddressJNIEXPORT jlong JNICALL Java_com_example_NativeBridge_bufferAddress( JNIEnv *env, jobject thiz, jobject buffer) { return reinterpret_castjlong(env-GetDirectBufferAddress(buffer)); }要求注意的地方NewDirectByteBuffer分配后这块内存的释放由 native 侧负责必须记得在某个时机 free否则就是内存泄漏。这跟普通 JNI 数组的自动回收完全不同。不是所有 JVM 都支持直接缓冲区Android 上支持但 API level 9 以下不支持现在基本不用考虑老版本。如果传入的 jobject 不是 DirectByteBuffer 实例GetDirectBufferAddress返回 NULL。所以调用前先env-IsInstanceOf(buffer, clsDirectByteBuffer)判断一下。直接缓冲区的使用场景很清晰音频数据、图像像素、协议包这类需要高频读写的连续大块数据用它就对了。Java 层持有一个ByteBuffernative 层直接内存地址读写零拷贝性能最接近纯 C 的水平。4.2 直接缓冲区与数组的配合什么时候不用它但直接缓冲区不适合所有场景。比如你需要频繁随机访问小数组或者读写模式很离散那直接缓冲区的地址运算和 byte 级的位操作反而容易写错不如普通 JNI 数组方便。我一般这样取舍数据量小64KB且读写模式简单用 GetIntArrayElements 或 GetArrayRegion。数据量中等64KB ~ 几 MB且处理逻辑不阻塞用 GetPrimitiveArrayCritical。数据量大、跨多次 JNI 调用、希望零拷贝直接用 DirectByteBuffer。注意不管选择哪种方式JNI 数组操作中数据一致性始终是首要问题。你从 Java 拿一个数组在 native 改了必须确保 Java 层能看到改动或者明确知道自己看不到。在 Get/Release 的语境下Release 时用模式 0 或 JNI_COMMIT 就能保证写回。在 DirectByteBuffer 语境下天然零拷贝但要注意多线程并发访问同一块缓冲区的同步问题JNI 不会帮你做任何内存屏障。5. 数组的创建与区域读写5.1 NewIntArray 与 SetIntArrayRegion从 Native 构造数组native 代码不只是消费 Java 传进来的数组还经常需要创建数组返回出去。最常用的一套组合是NewIntArray配上SetIntArrayRegionJNIEXPORT jintArray JNICALL Java_com_example_NativeBridge_generateIntArray( JNIEnv *env, jobject thiz, jint size) { jintArray result env-NewIntArray(size); if (result nullptr) { // 抛 OutOfMemoryError return nullptr; } std::vectorjint tmp(size); for (jint i 0; i size; i) { tmp[i] i * i; } env-SetIntArrayRegion(result, 0, size, tmp.data()); return result; }用SetIntArrayRegion而不是GetIntArrayElements写数据最大的好处是你不需要请求整块内存的指针可以分多次往不同位置写也避免了每次只改一个元素时获取指针的开销。底层实现是 memcpy速度快。NewXxxArray回来的数组初始值是全零而不是未定义内容这一点值得确认。如果业务逻辑依赖“先清零”的语义可以省掉一个 memset。5.2 GetIntArrayRegion局部读的优雅方案与 Set 对应的是GetIntArrayRegion它专门用于只读取数组的一部分。用法JNIEXPORT jint JNICALL Java_com_example_NativeBridge_readRange( JNIEnv *env, jobject thiz, jintArray arr, jsize start, jsize len) { std::vectorjint buf(len); env-GetIntArrayRegion(arr, start, len, buf.data()); if (env-ExceptionCheck()) { // 通常下标越界 env-ExceptionDescribe(); env-ExceptionClear(); return -1; } jint maxVal buf[0]; for (jsize i 1; i len; i) maxVal std::max(maxVal, buf[i]); return maxVal; }这段代码有个隐藏问题GetIntArrayRegion在越界时会抛出ArrayIndexOutOfBoundsExceptionJNI 函数挂起异常后返回。很多新手继续往下走此时 buf 里的数据是未定义的可能算出一个错的极值如果不处理异常直接 returnJava 层会收到这个挂起异常——在某些场景下会导致整个进程异常。所以区域操作后一定要检查ExceptionCheck。对比一下两个 API 的定位GetArrayRegion适合只读指定区间不需要先拿到整个数组的指针代码更安全不存在“指针悬浮”的问题。GetArrayElements适合需要随机反复访问整个数组的场景一次拿指针多次访问。释放时写回或放弃由你决定。5.3 用数组方法签名时的一个细节如果你在 native 方法签名里声明jintArrayJava 侧调用时传的是int[]这个没问题。但如果你想返回一个由 native 创建的数组别忘记返回类型必须匹配。我见过有人返回jobject然后 Java 层类型写int[]编译符号可以过但运行时是类型不匹配直接抛ArrayStoreException或更恶心的a JNI error has occurred。关于这个错误其实值得单独展开。它在不同环境下的具体表现差异很大在 Android 上经常是日志art::JNI相关的一个崩溃栈在桌面 JVM 上则是A fatal error has been detected by the Java Runtime Environment暗示是 native 代码 bug。多数情况下不是数组类型匹配问题而是别的更深层问题下一部分详细说。6. 常见问题排查与避坑实录6.1error: a JNI error has occurred, please check your installation and try again的排查思路这个报错是 JNI 新手最容易搜到的但它往往并不是指“JNI 环境装错了”。在桌面 JVM 上这个错误通常意味着native 方法名解析失败也就是 Java 层声明的native方法和 C 层函数对不上符号。native 函数抛出异常后没处理JNI 调用链返回后直击 JVM。更隐蔽的在数组操作中某个 JNI 函数调用后没有检查异常比如GetIntArrayRegion越界后异常挂起随后又调用了其他 JNI 函数行为未定义——有的虚拟机干脆直接终止进程报这个通用错误。排查步骤我建议按这个顺序来先看完整堆栈别只看第一行。确认 native 库加载路径正确System.loadLibrary的库名和实际 so/dll 名称是否匹配。在 C 侧每个 JNI 调用后加if (env-ExceptionCheck()) env-ExceptionDescribe();临时定位异常源头。检查所有 Get 是否有对应的 Release所有函数返回值是否判空。如果是数组操作后崩重点检查索引是否越界以及GetArrayLength是否对空数组调用。6.2 GetArrayLength 与空数组的边界GetArrayLength对 null 数组调用会返回 0 吗不会。如果 arr 为 null调用GetArrayLength会抛出空指针异常。所以在 JNI 函数入口处先判空永远是第一件事。这一点在 C/C 侧尤其容易被忽略因为 C 里传 nullptr 很常见而 Java 侧可能因为一些框架自动传空数组过来。防御性编程在这里不是可选项而是必选项。另外要小心GetArrayLength返回的长度类型是jsize它是 int 类型。如果你把它赋值给size_t在 64 位平台如果长度是负数不太可能但类型是带符号的会发生符号扩展的问题。稳妥一点统一用jsize或用jsize len env-GetArrayLength(arr);。6.3 在 Clion 中配置 JNI 环境的几个关键点CLion 是常用的 JNI 开发 IDE但默认它对 JNI 头文件的支持并不好。网上搜到这个热词说明踩坑的人不少。简易配置步骤安装 JDK把JAVA_HOME环境变量设置好。在 CMakeLists.txt 中添加头文件路径和库路径find_package(JNI REQUIRED) include_directories(${JNI_INCLUDE_DIRS})或者手动指定include_directories( $ENV{JAVA_HOME}/include $ENV{JAVA_HOME}/include/darwin # macOS $ENV{JAVA_HOME}/include/linux # Linux $ENV{JAVA_HOME}/include/win32 # Windows )生成头文件的命令一般是javac -h ./jnih NativeBridge.java这个命令在 JDK 8 之后是替代javah的标准方式。在 CLion 里如果你打开项目时头文件爆红确认是 JDK 的 include 目录没加对。Linux 和 macOS 的 jni_md.h 路径不同这是最常见的配置错误。6.4 数组相关 JNI 异常速查表问题现象可能原因解决方案数组内容修改后 Java 层没变化Release 使用了 JNI_ABORT或忘记 Release改用 mode 0 / JNI_COMMIT修改数组时崩溃下标越界严格用 GetArrayLength 约束循环尤其注意二维数组内层长度大量循环处理对象数组后崩溃局部引用表溢出循环内手动 DeleteLocalRefGetArrayRegion 后数据随机越界后未处理异常调用后检查 ExceptionCheckNewDirectByteBuffer 返回对象无法 get 地址不是 DirectByteBuffer或内存申请失败检查类型判空桌面 JVM 报 a JNI error方法签名不匹配 / 异常挂起核对 javah -h 生成的签名检查异常处理6.5 几条实操心得最后分享几个我自己写 JNI 数组操作的固定习惯。第一能不拿指针就不拿指针。很多数组读取用 GetArrayRegion 就够了不要总是 GetArrayElements。指针拿得越少生命周期管理越简单出错可能性越低。代码可读性也更高。第二把写入数据和释放分开思考。释放的 mode 取决于你是否修改了内容而不是取决于你当时的心情。修改了就用 0只读就用 JNI_ABORT。这是最简单的判断标准。第三对象数组的局部引用一定是用完即删。尤其在 Android 这样的系统上JNI 局部引用表管理不好不是崩溃就是 GC 压力大。我见过一个图像标注项目循环里读了 2000 个 String 没 DeleteLocalRef频繁调用后 CPU 占用和内存直接飙升最后就是逐行排查引用泄漏才定位到。第四性能调优时先用 region 统计别急着上 Critical。先明确数组到底多大、每次操作频率多高再用GetPrimitiveArrayCritical。这 API 属于“高手向”一旦误用会出现从死锁到随机崩溃等致命问题。第五JNI 数组操作的同步问题。默认情况下Java 层对该数组的并发修改和 native 层对它的操作没有同步保护。如果多线程同时操作同一个 JNI 数组最坏情况下是数据竞态甚至虚拟机崩溃。建议在 Java 层通过 synchronized 或让 native 层自己用 mutex双保险。根据我这些年踩坑的经验JNI 数组操作最大的趋势就是“尽可能减少跨边界的数据搬运”。Java 与 C 之间本来就是一堵墙数组是数据量最大的跨界形式之一。理解拷贝机制、掌握临界区和直接缓冲区、警惕局部引用和异常挂起——这四件事做好了数组这块基本就稳了。
返回列表