![图片[1]-32 深入理解JNI:Java与C/C++跨语言调用的底层原理-速优课](http://www.suyouke.com/wp-content/uploads/2026/07/doubao_img_2304x1728_20260722_174942-1-1024x768.png)
本文导读
在实际开发中,我们经常会遇到 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 命名规范详解
我们来拆解一下这个命名规范:
基本规则:
- 所有 native 方法对应的 C 函数都以
Java_为前缀 - 之后跟着完整的包名和方法名
- 由于 C 函数名不支持
/,所以用_替换 - 如果方法名本身包含
_,则替换为_1
比如 org.example.Foo 类的 foo 方法,对应的 C 函数名就是 Java_org_example_Foo_foo。
重载方法的命名:
当类中有重载的 native 方法时,JVM 还会把参数类型也考虑进去。具体做法是:在函数名后面追加 __(双下划线)和方法描述符作为后缀。
方法描述符中的特殊字符也需要转义:
;替换为_2[替换为_3
所以上面例子中两个重载的 bar 方法:
bar(int, long)→Java_org_example_Foo_bar__IJbar(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_IHashCode、JVM_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 有几个重要特点:
- 线程私有:每个线程都有自己的
JNIEnv,C 代码不能把当前线程的JNIEnv共享给其他线程,否则无法保证正确性。 - 设计目的:
- 提供独立的命名空间,避免与其他库的函数名冲突
- 允许 JVM 通过修改函数指针来切换 JNI 函数的实现(比如从带参数检查的慢速版本切换到不带检查的快速版本)
- 在 HotSpot 中的实现:
JNIEnv被内嵌到 Java 线程的数据结构中。部分 JVM 代码甚至可以从JNIEnv的地址反推出 Java 线程的地址。所以如果在其他线程中使用当前线程的JNIEnv,会导致线程识别错误。
2.2 数据类型映射
JNI 会把 Java 层面的类型映射成 C 代码可以使用的数据结构。
基本类型映射:
![图片[2]-32 深入理解JNI:Java与C/C++跨语言调用的底层原理-速优课](https://www.suyouke.com/wp-content/uploads/2026/07/image-41.png)
引用类型映射:
引用类型之间也有继承关系:
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 函数(除了 NewGlobalRef 和 NewWeakGlobalRef 之外)返回的引用类型对象,都属于局部引用。
但局部引用有一个重要特性:一旦从 C 函数返回到 Java 方法后,局部引用就失效了。 也就是说,垃圾回收器在标记垃圾时不再考虑这些局部引用了。
这意味着:不能缓存局部引用,不能把它保存起来供另一个 C 线程或下一次 native 方法调用时使用。
3.3 全局引用(Global Reference)
如果需要跨线程或跨次调用都要使用同一个引用,怎么办?答案是使用 NewGlobalRef 把局部引用转换成全局引用。全局引用会确保它指向的对象始终不被垃圾回收。
相应地,不需要时用 DeleteGlobalRef 来释放全局引用,让对象可以被回收。
此外,如果 C 函数运行时间特别长时,也应该考虑用 DeleteLocalRef 主动删除不再使用的局部引用,以便及时释放内存。
3.4 句柄机制
你可能会问:垃圾回收时对象在内存中的位置可能会移动,那局部引用和全局引用怎么保证始终指向正确的位置?
HotSpot 虚拟机的答案是句柄(handle)。句柄本质上是一个指针的指针——它指向存储了一个指向 Java 对象的指针。当垃圾回收移动了对象,句柄指向的指针值会更新,但句柄本身的地址不变。
无论是局部引用还是全局引用,本质上都是句柄。
局部引用的句柄有两种存储方式:
- 本地方法栈帧:主要存放 C 函数接收的来自 Java 层面的引用类型参数
- 线程私有句柄块:主要存放 C 函数运行过程中创建的局部引用
当从 C 函数返回到 Java 时,本地方法栈帧中的句柄会被自动清除。而线程私有句柄块则需要 JVM 显式清理。
3.5 JNI 调用的性能开销
JNI 调用为什么会有额外的性能开销?主要来自这几个方面:
- 进入 C 函数时,需要把引用类型参数句柄化
- 需要调整参数位置(C 调用和 Java 调用的传参方式不同
- 从 C 函数返回时,需要清理线程私有句柄块
这些操作都需要时间,导致了 JNI 调用比普通 Java 方法调用慢一些。
总结与思考
本文要点回顾
- native 方法的两种链接方式:
- 自动链接:按照 JNI 命名规范命名 C 函数,由 JVM 自动查找并链接
- 手动注册:通过
RegisterNativesAPI 主动注册,C 函数名可以自定义
- JNI 命名规范:
- 前缀
Java_+ 包名 + 类名 + 方法名 - 重载方法还要加上
__+ 方法描述符作为后缀
- 前缀
- JNIEnv 的设计:
- 线程私有,不能跨线程共享
- 通过函数指针表实现,便于切换实现版本
- 在 HotSpot 中内嵌于线程数据结构
- JNI 异常处理:
- 异常发生后不会中断,只是缓存起来
- 需要开发者主动检查和处理异常
- 局部引用与全局引用:
- 都是句柄实现,对象移动时句柄本身地址不变
- 局部引用在 native 方法返回后失效
- 全局引用需要手动创建和销毁
- 是 JNI 调用性能开销的来源之一
思考与实践
- 你在项目中有没有使用过 JNI?是在什么场景下使用的?
- 为什么 JNI 的异常处理和 Java 不一样?这种设计有什么优缺点?
- 如果让你来设计一套跨语言调用机制,你会怎么考虑性能和安全性的平衡?
JNI 是 Java 与底层交互的重要桥梁,掌握它的运行机制,不仅能帮助我们写出正确使用 native 代码,也能让我们更深入地理解 JVM 的内部运作方式。








请登录后查看评论内容