32 深入理解JNI:Java与C/C++跨语言调用的底层原理

图片[1]-32 深入理解JNI:Java与C/C++跨语言调用的底层原理-速优课

本文导读

在实际开发中,我们经常会遇到 Java 语言难以胜任甚至无法完成的场景:比如需要用汇编指令(如 X86_64 的 SIMD 指令)来极致优化性能,又或者需要调用操作系统特有的系统功能。这时候,我们就需要牺牲一定的可移植性,通过 Java 调用 C/C++ 代码来实现需求。

这种跨语言调用的桥梁就是 JNI(Java Native Interface)。本文将带你深入了解 JNI 的运行机制,包括 native 方法的链接过程、JNI API 的使用方式,以及局部引用和全局引用的设计原理。

通过阅读本文,你将了解:

  • native 方法是如何链接到对应的 C 函数的
  • JNI 命名规范的具体规则
  • JNIEnv 是什么以及它的设计思路
  • JNI 中的异常处理机制
  • 局部引用与全局引用的区别与实现原理
  • JNI 调用的性能开销来源

一、native 方法的链接机制

1.1 什么是 JNI

在 Java 中,我们经常会看到标记为 native 的方法——它们只有声明但没有方法体,比如 Object.hashCode() 就是一个典型的 native 方法:

public class Object {
  public native int hashCode();
}

当 Java 代码调用这些 native 方法时,JVM 会通过 JNI 机制调用到对应的 C/C++ 函数(下文简称 C 函数)。以 hashCode 对应的 C 函数会计算对象的哈希值,并缓存在对象头、轻量级锁记录或 Monitor 中,确保对象生命周期内哈希值不变。

但在调用之前,JVM 首先需要把 native 方法和对应的 C 函数链接起来。链接方式主要有两种:自动链接手动注册

1.2 自动链接:按命名规范匹配

第一种方式是让 JVM 自动查找符合默认命名规范的 C 函数并完成链接。

你不需要死记硬背命名规范——使用 javac -h 命令就可以根据 Java 中的 native 方法声明,自动生成符合命名规范的 C 头文件。

举个例子,假设 Foo 类有三个 native 方法:

package org.example;
​
public class Foo {
  public static native void foo();
  public native void bar(int i, long j);
  public native void bar(String s, Object o);
}

执行以下命令:

$ javac -h . org/example/Foo.java

会在当前目录生成 org_example_Foo.h 头文件,内容如下:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class org_example_Foo */
​
#ifndef _Included_org_example_Foo
#define _Included_org_example_Foo
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     org_example_Foo
 * Method:    foo
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_org_example_Foo_foo
  (JNIEnv *, jclass);
​
/*
 * Class:     org_example_Foo
 * Method:    bar
 * Signature: (IJ)V
 */
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
  (JNIEnv *, jobject, jint, jlong);
​
/*
 * Class:     org_example_Foo
 * Method:    bar
 * Signature: (Ljava/lang/String;Ljava/lang/Object;)V
 */
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
  (JNIEnv *, jobject, jstring, jobject);
​
#ifdef __cplusplus
}
#endif
#endif

1.3 JNI 命名规范详解

我们来拆解一下这个命名规范:

基本规则:

  1. 所有 native 方法对应的 C 函数都以 Java_ 为前缀
  2. 之后跟着完整的包名和方法名
  3. 由于 C 函数名不支持 /,所以用 _ 替换
  4. 如果方法名本身包含 _,则替换为 _1

比如 org.example.Foo 类的 foo 方法,对应的 C 函数名就是 Java_org_example_Foo_foo

重载方法的命名:

当类中有重载的 native 方法时,JVM 还会把参数类型也考虑进去。具体做法是:在函数名后面追加 __(双下划线)和方法描述符作为后缀。

方法描述符中的特殊字符也需要转义:

  • ; 替换为 _2
  • [ 替换为 _3

所以上面例子中两个重载的 bar 方法:

  • bar(int, long)Java_org_example_Foo_bar__IJ
  • bar(String, Object)Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2

1.4 手动注册:RegisterNatives

第二种链接方式是在 C 代码中主动注册。这种方式对 C 函数名没有要求,更加灵活。

通常的做法是:定义一个名为 registerNatives 的 native 方法,按照第一种方式自动链接到 C 函数,然后在这个 C 函数中调用 RegisterNatives API 来注册其他 native 方法。

java.lang.Object 就是这么做的:

// Object 类的 registerNatives 方法实现位于 java.base 模块的 C 代码中
static JNINativeMethod methods[] = {
    {"hashCode",    "()I",                    (void *)&JVM_IHashCode},
    {"wait",        "(J)V",                   (void *)&JVM_MonitorWait},
    {"notify",      "()V",                    (void *)&JVM_MonitorNotify},
    {"notifyAll",   "()V",                    (void *)&JVM_MonitorNotifyAll},
    {"clone",       "()Ljava/lang/Object;",   (void *)&JVM_Clone},
};
​
JNIEXPORT void JNICALL
Java_java_lang_Object_registerNatives(JNIEnv *env, jclass cls)
{
    (*env)->RegisterNatives(env, cls,
                            methods, sizeof(methods)/sizeof(methods[0]));
}

可以看到,这里注册的这些 C 函数(如 JVM_IHashCodeJVM_MonitorWait 等)的名字都不符合默认命名规范。

使用手动注册时,需要确保在其他 native 方法被调用之前完成注册。所以通常会在类的静态初始化块中调用 registerNatives

public class Object {
    private static native void registerNatives();
    static {
        registerNatives();
    }
}

1.5 动态链接库的加载

我们用第一种链接方式来实现 bar(String, Object) 方法:

// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
​
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
  (JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
  printf("Hello, World\n");
  return;
}

然后用 gcc 编译成动态链接库:

# macOS 平台
$ gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -o libfoo.dylib -shared foo.c

注意:动态链接库的名字必须以 lib 为前缀,macOS 上扩展名为 .dylib,Linux 上为 .so,Windows 上为 .dll

在 Java 代码中通过 System.loadLibrary("foo") 来加载:

package org.example;

public class Foo {
  public static native void foo();
  public native void bar(int i, long j);
  public native void bar(String s, Object o);

  int i = 0xDEADBEEF;

  public static void main(String[] args) {
    try {
      System.loadLibrary("foo");
    } catch (UnsatisfiedLinkError e) {
      e.printStackTrace();
      System.exit(1);
    }
    new Foo().bar("", "");
  }
}

如果动态库不在当前路径下,可以通过 java.library.path 参数指定:

$ java -Djava.library.path=/PATH/TO/DIR/CONTAINING/libfoo.dylib org.example.Foo
Hello, World

二、JNI API 详解

2.1 JNIEnv 是什么

在 C 代码中,我们也可以使用 Java 的各种语言特性,比如访问字段、调用方法、类型检查等。这些功能都是通过 JNI 函数来实现的。

JVM 把所有 JNI 函数的函数指针聚合到一个名为 JNIEnv 的数据结构中。

JNIEnv 有几个重要特点:

  1. 线程私有:每个线程都有自己的 JNIEnv,C 代码不能把当前线程的 JNIEnv 共享给其他线程,否则无法保证正确性。
  2. 设计目的
    • 提供独立的命名空间,避免与其他库的函数名冲突
    • 允许 JVM 通过修改函数指针来切换 JNI 函数的实现(比如从带参数检查的慢速版本切换到不带检查的快速版本)
  3. 在 HotSpot 中的实现JNIEnv 被内嵌到 Java 线程的数据结构中。部分 JVM 代码甚至可以从 JNIEnv 的地址反推出 Java 线程的地址。所以如果在其他线程中使用当前线程的 JNIEnv,会导致线程识别错误。

2.2 数据类型映射

JNI 会把 Java 层面的类型映射成 C 代码可以使用的数据结构。

基本类型映射:

图片[2]-32 深入理解JNI:Java与C/C++跨语言调用的底层原理-速优课

引用类型映射:

引用类型之间也有继承关系:

jobject
|- jclass (java.lang.Class 对象)
|- jstring (java.lang.String 对象)
|- jthrowable (java.lang.Throwable 对象)
|- jarray (数组)
   |- jobjectArray (对象数组)
   |- jbooleanArray (boolean 数组)
   |- jbyteArray (byte 数组)
   |- jcharArray (char 数组)
   |- jshortArray (short 数组)
   |- jintArray (int 数组)
   |- jlongArray (long 数组)
   |- jfloatArray (float 数组)
   |- jdoubleArray (double 数组)

2.3 函数参数解析

我们回头看 Foo 类三个 native 方法对应的 C 函数参数:

JNIEXPORT void JNICALL Java_org_example_Foo_foo
  (JNIEnv *, jclass);

JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
  (JNIEnv *, jobject, jint, jlong);

JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
  (JNIEnv *, jobject, jstring, jobject);

参数规律:

参数位置静态 native 方法实例 native 方法
第一个参数JNIEnv* 指针JNIEnv* 指针
第二个参数jclass:表示定义该方法的类jobject:表示方法调用者(this)
后续参数方法声明的参数方法声明的参数
  • 静态方法的第二个参数是 jclass,代表定义该 native 方法的类
  • 实例方法的第二个参数是 jobject,代表调用该方法的对象实例(也就是 this)
  • 再后面就是 native 方法声明的参数,按顺序对应

2.4 访问 Java 字段

我们来修改一下 foo.c,在 C 代码中访问 Foo 类实例的 i 字段:

// foo.c
#include <stdio.h>
#include "org_example_Foo.h"

JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
  (JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
  jclass cls = (*env)->GetObjectClass(env, thisObject);
  jfieldID fieldID = (*env)->GetFieldID(env, cls, "i", "I");
  jint value = (*env)->GetIntField(env, thisObject, fieldID);
  printf("Hello, World 0x%x\n", value);
  return;
}

可以看到,JNI 中访问字段的方式和反射很像:先获取类,再获取 FieldID,最后通过 FieldID 获取字段值。

你可能注意到,这段代码里没有异常处理。难道 JNI 不用处理异常吗?

我们来做个实验——尝试访问一个不存在的字段 j

$ java org.example.Foo
Hello, World 0x5
Exception in thread "main" java.lang.NoSuchFieldError: j
 at org.example.Foo.bar(Native Method)
 at org.example.Foo.main(Foo.java:20)

结果很有意思:printf 照常执行了,还打印出了 0x5 这个错误值。直到从 C 函数返回到 Java 后,才抛出 NoSuchFieldError 异常。

2.5 JNI 的异常处理机制

JNI 的异常处理和 Java 不一样:

  • 在 Java 中,异常发生后会立即跳转到异常处理器
  • 在 JNI 中,异常发生后只是把异常实例缓存起来,然后继续执行后面的 C 代码

所以,在调用可能触发异常的 JNI 函数后,我们需要主动用 ExceptionOccurred 检查是否发生了异常,并做出相应处理。如果不需要把异常抛回 Java,就用 ExceptionClear 清空异常。

正确的写法应该是这样:

// foo.c
#include <stdio.h>
#include "org_example_Foo.h"

JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
  (JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
  jclass cls = (*env)->GetObjectClass(env, thisObject);
  jfieldID fieldID = (*env)->GetFieldID(env, cls, "j", "I");
  if((*env)->ExceptionOccurred(env)) {
    printf("Exception!\n");
    (*env)->ExceptionClear(env);
  }
  fieldID = (*env)->GetFieldID(env, cls, "i", "I");
  jint value = (*env)->GetIntField(env, thisObject, fieldID);
  // 这里其实也应该检查异常
  printf("Hello, World 0x%x\n", value);
  return;
}

注意:上面的代码为了简洁只在第一个 GetFieldID 后做了异常检查。实际开发中,每个可能抛出异常的 JNI 调用后都应该检查异常。


三、局部引用与全局引用

3.1 为什么需要引用管理

在 C 代码中,我们既可以访问传入的引用类型参数,也可以通过 JNI 函数创建新的 Java 对象。这些 Java 对象同样会受到垃圾回收的影响。因此,JVM 需要一种机制来告诉垃圾回收器:这些对象正被 C 代码引用着,不要回收它们。

这种机制就是 JNI 的局部引用(Local Reference)全局引用(Global Reference)。垃圾回收器会把这两种引用指向的对象标记为存活,不被回收。

3.2 局部引用(Local Reference)

事实上,无论是传入的引用类型参数,还是通过 JNI 函数(除了 NewGlobalRefNewWeakGlobalRef 之外)返回的引用类型对象,都属于局部引用。

但局部引用有一个重要特性:一旦从 C 函数返回到 Java 方法后,局部引用就失效了。 也就是说,垃圾回收器在标记垃圾时不再考虑这些局部引用了。

这意味着:不能缓存局部引用,不能把它保存起来供另一个 C 线程或下一次 native 方法调用时使用。

3.3 全局引用(Global Reference)

如果需要跨线程或跨次调用都要使用同一个引用,怎么办?答案是使用 NewGlobalRef 把局部引用转换成全局引用。全局引用会确保它指向的对象始终不被垃圾回收。

相应地,不需要时用 DeleteGlobalRef 来释放全局引用,让对象可以被回收。

此外,如果 C 函数运行时间特别长时,也应该考虑用 DeleteLocalRef 主动删除不再使用的局部引用,以便及时释放内存。

3.4 句柄机制

你可能会问:垃圾回收时对象在内存中的位置可能会移动,那局部引用和全局引用怎么保证始终指向正确的位置?

HotSpot 虚拟机的答案是句柄(handle)。句柄本质上是一个指针的指针——它指向存储了一个指向 Java 对象的指针。当垃圾回收移动了对象,句柄指向的指针值会更新,但句柄本身的地址不变。

无论是局部引用还是全局引用,本质上都是句柄。

局部引用的句柄有两种存储方式:

  1. 本地方法栈帧:主要存放 C 函数接收的来自 Java 层面的引用类型参数
  2. 线程私有句柄块:主要存放 C 函数运行过程中创建的局部引用

当从 C 函数返回到 Java 时,本地方法栈帧中的句柄会被自动清除。而线程私有句柄块则需要 JVM 显式清理。

3.5 JNI 调用的性能开销

JNI 调用为什么会有额外的性能开销?主要来自这几个方面:

  1. 进入 C 函数时,需要把引用类型参数句柄化
  2. 需要调整参数位置(C 调用和 Java 调用的传参方式不同
  3. 从 C 函数返回时,需要清理线程私有句柄块

这些操作都需要时间,导致了 JNI 调用比普通 Java 方法调用慢一些。


总结与思考

本文要点回顾

  1. native 方法的两种链接方式
    • 自动链接:按照 JNI 命名规范命名 C 函数,由 JVM 自动查找并链接
    • 手动注册:通过 RegisterNatives API 主动注册,C 函数名可以自定义
  2. JNI 命名规范
    • 前缀 Java_ + 包名 + 类名 + 方法名
    • 重载方法还要加上 __ + 方法描述符作为后缀
  3. JNIEnv 的设计
    • 线程私有,不能跨线程共享
    • 通过函数指针表实现,便于切换实现版本
    • 在 HotSpot 中内嵌于线程数据结构
  4. JNI 异常处理
    • 异常发生后不会中断,只是缓存起来
    • 需要开发者主动检查和处理异常
  5. 局部引用与全局引用
    • 都是句柄实现,对象移动时句柄本身地址不变
    • 局部引用在 native 方法返回后失效
    • 全局引用需要手动创建和销毁
    • 是 JNI 调用性能开销的来源之一

思考与实践

  1. 你在项目中有没有使用过 JNI?是在什么场景下使用的?
  2. 为什么 JNI 的异常处理和 Java 不一样?这种设计有什么优缺点?
  3. 如果让你来设计一套跨语言调用机制,你会怎么考虑性能和安全性的平衡?

JNI 是 Java 与底层交互的重要桥梁,掌握它的运行机制,不仅能帮助我们写出正确使用 native 代码,也能让我们更深入地理解 JVM 的内部运作方式。

© 版权声明
THE END
喜欢就支持一下吧
点赞14
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!