第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直接帮你生成。但缺点也很明显:函数名太长,容易写错;而且每次新增方法都要重新生成头文件。

注意:静态注册的函数名如果拼写错误,ART在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的执行流程是这样的:

  1. Java代码调用nativeAdd()
  2. ART检查ArtMethod.access_flags_,发现kAccNative被置位
  3. ART跳转到entry_point_from_jni_指向的地址
  4. 这个地址就是JNI函数在so库中的实际地址
  5. ART准备好JNIEnv和参数,调用C函数

嗯,这里要注意一点:entry_point_from_jni_在静态注册和动态注册时的填充方式不同。静态注册是在so加载时通过dlsym查找并填充;动态注册则是你在RegisterNatives里直接指定的。

核心理解:ArtMethod就像是一个「方法描述符」,它告诉ART这个方法的所有信息。对于native方法,ART不需要去解释执行DEX字节码,而是直接跳转到native代码。这也是native方法性能好的原因之一——省去了dex指令的解释执行开销。

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中的MonitorEnterMonitorExit来实现临界区。说白了就是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) {
        // 处理错误
    }
}

这里有个坑:MonitorEnterMonitorExit必须成对出现。如果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