第20章 JNI与Native Bridge:JNI函数注册、ArtMethod与native方法、JNI临界区
说到JNI,很多Android开发者第一反应就是「性能损耗」和「各种崩溃」。说实话,我早年也被JNI坑过不少次。有一次线上版本,因为JNI临界区没处理好,导致多线程同时调用native方法时直接死锁,用户反馈App卡死,那叫一个惨烈。
这一章,咱们就把JNI的底层机制掰开揉碎。重点看三个东西:JNI函数怎么注册到ART里的、ArtMethod和native方法到底什么关系、以及JNI临界区为什么那么重要。
20.1 JNI函数注册:两种方式,各有千秋
JNI函数的注册,说白了就是让Java层能调用到C/C++函数。ART提供了两种注册方式:静态注册和动态注册。
20.1.1 静态注册
静态注册是最传统的方式。你写一个native方法,然后用javah生成头文件,函数名必须严格按照Java_包名_类名_方法名的格式来。ART在加载so库时,会通过dlsym去查找这些符号。
// Java层
public class NativeBridge {
public static native int add(int a, int b);
}
// C/C++层
extern "C" JNIEXPORT jint JNICALL
Java_com_example_NativeBridge_add(JNIEnv* env, jclass clazz, jint a, jint b) {
return a + b;
}
这种方式的好处是简单,IDE直接帮你生成。但缺点也很明显:函数名太长,容易写错;而且每次新增方法都要重新生成头文件。
JNI_OnLoad阶段会直接报UnsatisfiedLinkError。我遇到过有人把包名大小写写错,排查了半天。
20.1.2 动态注册
动态注册就灵活多了。你在JNI_OnLoad里调用RegisterNatives,把Java方法和C函数手动绑定。函数名可以随便起,不用遵守那套命名规则。
static const JNINativeMethod methods[] = {
{"add", "(II)I", (void*)native_add},
{"sub", "(II)I", (void*)native_sub},
};
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = nullptr;
if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
jclass clazz = env->FindClass("com/example/NativeBridge");
if (clazz == nullptr) {
return JNI_ERR;
}
if (env->RegisterNatives(clazz, methods, sizeof(methods)/sizeof(methods[0])) < 0) {
return JNI_ERR;
}
return JNI_VERSION_1_6;
}
我个人习惯用动态注册。原因有三:
- 函数名简洁,不用写那串长长的前缀
- 可以在
JNI_OnLoad里做初始化检查,比如so库版本校验 - 支持运行时动态替换native方法,做热修复时特别有用
JNINativeMethod结构体里的签名一定要写对。比如"(II)I"表示两个int参数返回int。签名错了,ART在调用时会直接崩溃,而且日志里只会给个模糊的NoSuchMethodError。
20.2 ArtMethod与native方法:底层是怎么关联的?
你可能会问:ART里到底怎么区分一个方法是Java方法还是native方法?答案就在ArtMethod结构体里。
每个Java方法在ART内部都对应一个ArtMethod对象。这个对象里有个关键字段叫access_flags_,其中有一个标志位kAccNative。如果这个标志位被置位,ART就知道这是个native方法。
// ART源码中的ArtMethod关键字段(简化版)
class ArtMethod {
uint32_t access_flags_; // 包含kAccNative标志
uint32_t dex_method_index_; // DEX中的方法索引
uint16_t method_index_; // vtable或ifTable中的索引
GcRoot<mirror::Class> declaring_class_;
void* entry_point_from_jni_; // JNI函数指针,native方法专用
void* entry_point_from_quick_compiled_code_; // 编译后的代码入口
};
当一个native方法被调用时,ART的执行流程是这样的:
- Java代码调用
nativeAdd() - ART检查
ArtMethod.access_flags_,发现kAccNative被置位 - ART跳转到
entry_point_from_jni_指向的地址 - 这个地址就是JNI函数在so库中的实际地址
- ART准备好
JNIEnv和参数,调用C函数
嗯,这里要注意一点:entry_point_from_jni_在静态注册和动态注册时的填充方式不同。静态注册是在so加载时通过dlsym查找并填充;动态注册则是你在RegisterNatives里直接指定的。
20.3 JNI临界区:多线程的噩梦
JNI临界区,说白了就是一段代码,同一时间只能有一个线程执行。为什么需要这个?因为JNI调用涉及到Java和Native两个世界,稍不注意就会出问题。
20.3.1 临界区的典型场景
我举个例子。假设你有一个native库,里面维护了一个全局计数器:
// 全局变量,多线程共享
static int g_counter = 0;
extern "C" JNIEXPORT void JNICALL
Java_com_example_Counter_increment(JNIEnv* env, jclass clazz) {
// 没有保护,多线程下会出问题
g_counter++;
}
extern "C" JNIEXPORT jint JNICALL
Java_com_example_Counter_getValue(JNIEnv* env, jclass clazz) {
return g_counter;
}
这段代码在单线程下没问题。但多线程同时调用increment时,g_counter++不是原子操作,会导致数据竞争。结果就是计数不准,甚至可能崩溃。
20.3.2 如何正确使用临界区
ART提供了JNIEnv中的MonitorEnter和MonitorExit来实现临界区。说白了就是Java层的synchronized关键字的native版本。
extern "C" JNIEXPORT void JNICALL
Java_com_example_Counter_incrementSafe(JNIEnv* env, jclass clazz, jobject lock) {
// 进入临界区
if (env->MonitorEnter(lock) != JNI_OK) {
// 处理错误
return;
}
g_counter++;
// 退出临界区
if (env->MonitorExit(lock) != JNI_OK) {
// 处理错误
}
}
这里有个坑:MonitorEnter和MonitorExit必须成对出现。如果MonitorExit没调用,这个锁就永远不释放了,其他线程会一直阻塞。我曾经在异常处理分支里忘了调用MonitorExit,结果线上出现了诡异的死锁,排查了两天才找到原因。
MonitorEnter/MonitorExit时,一定要用RAII或者try-finally保证成对调用。C++里可以用std::lock_guard的思想封装一下。另外,不要在临界区里做耗时操作,否则会阻塞其他线程。
20.3.3 临界区的性能影响
临界区不是免费的。每次进入和退出都有开销,包括:
- 锁的获取和释放操作
- 线程上下文切换(如果发生竞争)
- 内存屏障(保证可见性)
所以我的建议是:临界区要尽量小,只保护真正需要保护的代码。不要图省事,把整个函数都包进去。
20.4 本章小结
JNI这块,说白了就是Java和Native之间的桥梁。理解JNI函数注册、ArtMethod与native方法的关联、以及JNI临界区的使用,是写出稳定高效native代码的基础。
我个人觉得,动态注册是更现代的做法,推荐大家使用。ArtMethod的kAccNative标志位是理解native方法调用的关键。而临界区,嗯,一定要小心使用,多线程下的JNI代码,稍不留神就会出问题。
最后送大家一句话:JNI不是银弹,该用的时候用,不该用的时候别硬上。很多性能问题,其实优化Java代码就能解决,没必要引入JNI的复杂度。
公众号:蓝海资料掘金营,微信deep3321