3. JNI基础与类型系统
JNI 的稳定性问题,绝大多数最终都能追溯到类型系统理解不到位:把 jobject 当成 C++ 对象直接解引用、把局部引用当全局引用长期持有、签名写错导致 NoSuchMethodError、把 jstring 当 char* 使用。本节把 JNI 的类型系统拆成三层来看:运行环境层(JNIEnv)、对象句柄层(jobject/jclass/jmethodID/jfieldID)、类型映射层(基本类型与引用类型、签名规则)。三层理解清楚,后面章节的引用管理、异常处理、线程绑定才有落脚点。
一、JNIEnv:线程私有的函数表指针
在 C 语言中,JNIEnv 是 const struct JNINativeInterface* 的别名,本质是一个指向函数表的指针;在 C++ 中,它是 _JNIEnv 结构体,内部同样持有一个函数表指针,但把函数封装成成员方法,因此调用时不需要显式传 env。两种写法等价:
// C 风格
JNIEXPORT jstring JNICALL Java_com_example_Native_greet(JNIEnv* env, jobject thiz) {
return (*env)->NewStringUTF(env, "hello");
}
// C++ 风格
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_Native_greet(JNIEnv* env, jobject thiz) {
return env->NewStringUTF("hello");
}
关键约束是:JNIEnv 与线程绑定,不能跨线程传递。同一个线程内多次调用 JNI 方法,JNIEnv 指针通常相同;但把某个线程的 JNIEnv 缓存到全局变量,再在另一个线程使用,属于未定义行为。子线程要调用 JNI,必须通过 JavaVM::AttachCurrentThread 获取属于该线程的 JNIEnv,退出前 DetachCurrentThread。这是后续“线程与 JNIEnv 绑定”章节的核心前提。
二、对象句柄:jobject、jclass、jmethodID、jfieldID
JNI 不暴露 Java 对象的内存布局,所有 Java 侧实体在 native 侧都以不透明句柄表示:
jobject:任意 Java 对象的通用句柄,包括实例、数组、字符串。jclass:类对象的句柄,可由FindClass、GetObjectClass或GetSuperclass获得。jmethodID:方法标识,由GetMethodID/GetStaticMethodID返回,用于CallXxxMethod。jfieldID:字段标识,由GetFieldID/GetStaticFieldID返回,用于GetXxxField/SetXxxField。
这四者的生命周期差异极大,是生产事故高发区。jobject 和 jclass 属于引用,受局部/全局/弱全局引用规则约束,局部引用在 native 方法返回后自动失效;而 jmethodID 和 jfieldID 是标识符,只要对应类未被卸载就一直有效,可以安全地缓存到全局变量中复用。很多团队把 jclass 缓存成全局引用却忘了释放,或者把 jmethodID 当成需要 Delete 的引用,都是把两类语义混为一谈。
NewGlobalRef/DeleteGlobalRef 管理的是 jobject/jclass;jmethodID/jfieldID 只需缓存,不需要释放。三、基本类型映射与引用类型转换
Java 的八种基本类型在 JNI 中有固定的一一映射,宽度明确、无平台歧义:
| Java 类型 | JNI 类型 | 宽度 | 签名 |
|---|---|---|---|
| boolean | jboolean | 8 位无符号 | Z |
| byte | jbyte | 8 位有符号 | B |
| char | jchar | 16 位无符号 | C |
| short | jshort | 16 位有符号 | S |
| int | jint | 32 位有符号 | I |
| long | jlong | 64 位有符号 | J |
| float | jfloat | 32 位 | F |
| double | jdouble | 64 位 | D |
注意 long 对应 jlong(64 位),而 C 的 long 在 32 位平台只有 32 位,直接强转会在 32 位 ABI 上截断。引用类型则没有直接映射,必须通过 JNI 函数转换:jstring 用 GetStringUTFChars/ReleaseStringUTFChars 或 GetStringChars 转换;数组用 GetIntArrayElements 等;普通对象只能通过方法调用或字段访问逐项读取,不能整体 memcpy。
// jstring 转换:必须成对释放,且注意 UTF-8 修改版编码
const char* utf = env->GetStringUTFChars(jstr, nullptr);
if (utf == nullptr) return; // OOM,异常已挂起
size_t len = strlen(utf);
// ... 使用 utf ...
env->ReleaseStringUTFChars(jstr, utf);
GetStringUTFChars 返回的是 Modified UTF-8,与标准 UTF-8 在补充平面字符(如 emoji)和 \0 上不同:Java 的 String 允许内嵌 \0,Modified UTF-8 会把它编码为两字节 0xC0 0x80,而补充平面字符以 CESU-8 代理对形式存储。若 native 侧要交给标准 UTF-8 解析器,需要额外转换,否则会出现乱码或截断。
四、签名规则:JNI 类型描述符
方法签名由参数类型描述符按顺序拼接,加返回类型描述符构成,格式为 (参数...)返回类型。引用类型用 L包名/类名; 表示,包名中的点替换为斜杠,末尾必须带分号;数组用 [ 前缀,如 [I 表示 int[],[[Ljava/lang/String; 表示 String[][]。
| Java 声明 | JNI 签名 |
|---|---|
| void foo() | ()V |
| int add(int a, int b) | (II)I |
| String name() | ()Ljava/lang/String; |
| void set(int[] arr, long id) | ([IJ)V |
| Object get(String k, boolean d) | (Ljava/lang/String;Z)Ljava/lang/Object; |
签名写错是 NoSuchMethodError 的直接原因,且错误信息往往只报方法名不报签名,排查成本高。生产上推荐用 javap -s -p 直接导出签名,而不是手写:
javap -s -p com.example.NativeBridge
// 输出示例:
// public native void onEvent(int, java.lang.String);
// descriptor: (ILjava/lang/String;)V
下面这张图把 JNI 类型系统的三层关系与生命周期差异集中呈现,便于对照记忆:
五、生产实践中的类型系统检查清单
把上述规则落到代码审查和排障中,可以形成一份可执行的检查清单:
- 所有
GetStringUTFChars/GetXxxArrayElements是否与对应的Release成对出现,且异常路径也覆盖。 - 缓存到全局的
jclass是否用NewGlobalRef创建,并在卸载时DeleteGlobalRef。 jmethodID/jfieldID是否被误当作引用释放。- 子线程是否通过
AttachCurrentThread获取 JNIEnv,而非复用主线程的指针。 - 方法签名是否由
javap -s -p导出,而非手写。 - 跨语言传递的字符串是否确认过 Modified UTF-8 与标准 UTF-8 的差异。
理解这三层之后,后续的局部/全局引用管理、异常检查、线程绑定与性能优化,都可以看作是在这套类型系统上做“生命周期”和“边界”的加固。类型系统是 JNI 稳定性的地基,地基不牢,后面的优化手段都会变成补丁。