在Java生态里,纯Java代码并不能覆盖所有场景。有些底层操作必须借助C或C++才能完成,比如调用操作系统API、复用成熟的C语言算法库,或者对执行速度有苛刻要求的图像处理任务。这时候就需要用到JNI,也就是Java Native Interface。它是一套标准的编程框架,允许运行在JVM中的Java代码与本地代码(通常编译成so或dll文件)互相调用,是Java与Native世界之间的官方通道。

JNI到底是什么:从一次完整调用说起
JNI可以理解为一座双向桥梁:Java方法可以声明为native,把具体实现交给本地代码;反过来,本地代码也可以通过JNI接口创建Java对象、调用Java方法。要理解这套机制,最直观的方式是看一个最小可运行的例子。假设我们有一个Java类,其中包含一个native方法。
public class HelloJNI {
static {
// 加载本地动态库,Linux下是libhello.so,Windows下是hello.dll
System.loadLibrary("hello");
}
// 声明native方法,实现由C/C++提供
public native String sayHello(String name);
public static void main(String[] args) {
String result = new HelloJNI().sayHello("World");
System.out.println(result);
}
}上面的代码中有两个关键点。第一,System.loadLibrary("hello")负责在类加载时把本地库载入进程地址空间,JVM会根据平台自动拼接前缀和后缀,比如在Linux上会去找libhello.so。第二,native关键字告诉编译器这个方法没有Java实现,具体逻辑在本地库中。接下来用javac编译后,再用javah(JDK 8及以前)或javac -h(JDK 10以后)生成头文件,就能得到C语言层面需要实现的函数签名。
#include <jni.h>
#include <stdio.h>
// 函数名由包名、类名、方法名拼接而成,必须严格一致
JNIEXPORT jstring JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject thisObj, jstring name) {
// 将Java字符串转换为C字符串
const char *cName = (*env)->GetStringUTFChars(env, name, NULL);
if (cName == NULL) {
return NULL; // 内存不足时JVM会抛出OutOfMemoryError
}
printf("Hello, %s!\n", cName);
// 释放引用,防止内存泄漏
(*env)->ReleaseStringUTFChars(env, name, cName);
// 构造新的Java字符串返回
return (*env)->NewStringUTF(env, " JNI 返回的数据");
}可以看到,native方法的实现函数名遵循固定的命名规则:Java_加上包名(点换成下划线)加上类名和方法名。JVM在加载本地库后,会按照这个规则自动查找并绑定对应的函数。除了这种静态注册方式,还可以用RegisterNatives函数在JNI_OnLoad回调中动态注册,Android开发中大量使用这种做法,因为动态注册不依赖函数名,混淆代码后依然能正常工作。
JNIEnv与数据类型:JNI开发的核心知识点
写过JNI代码的人都知道,JNIEnv是整个JNI开发绕不开的结构体。它本质上是一个函数表指针,里面包含了JVM提供的全部操作接口:创建对象、调用方法、获取字段、处理字符串和数组等。每一个native方法的前两个参数都有讲究:第一个参数是JNIEnv指针(C++中是JNIEnv对象,C语言中是二级指针),第二个参数如果是实例方法则是jobject,表示调用该方法的对象本身;如果是静态方法则是jclass,表示声明该方法的类。
Java和C的数据类型并不一一对应,JNI定义了一套自己的中间类型体系。基本类型直接映射,比如int对应jint、long对应jlong、boolean对应jboolean;引用类型则统一以jobject为根,jstring、jarray、jclass都是它的子类型。特别注意Java的char是16位无符号类型,对应jchar,而C语言的char是8位,两者千万不要混淆,处理中文字符串时如果用错了类型,很容易出现乱码。
字符串和数组属于引用类型,在native代码中访问它们必须通过JNIEnv提供的方法,而且要严格遵守获取和释放的配对原则。以字符串为例,GetStringUTFChars拿到的是修改过的UTF-8编码,用完必须调用ReleaseStringUTFChars释放,否则会造成内存泄漏,长时间运行的服务端程序甚至会因此被系统杀掉。
// 数组的处理示例:修改Java传入的int数组
JNIEXPORT void JNICALL Java_HelloJNI_modifyArray(JNIEnv *env, jobject obj, jintArray arr) {
jint *elements = (*env)->GetIntArrayElements(env, arr, NULL);
jsize len = (*env)->GetArrayLength(env, arr);
for (int i = 0; i < len; i++) {
elements[i] = elements[i] * 2; // 每个元素翻倍
}
// 第三个参数为0表示把修改同步回Java数组并释放临时内存
(*env)->ReleaseIntArrayElements(env, arr, elements, 0);
}另一个容易踩坑的点是引用管理。JNI的引用分为局部引用和全局引用:局部引用在native方法返回后由JVM自动回收,但它并非只存在于方法作用域内,在循环中大量创建局部引用同样会撑爆引用表,必要时可以调用DeleteLocalRef提前释放;全局引用则需要手动通过NewGlobalRef创建,用于在多次调用之间持有Java对象,比如把一个Java回调对象缓存起来,离开时记得调用DeleteGlobalRef,否则对象永远无法被GC回收。
为什么需要JNI:典型应用场景与性能权衡
既然写Java已经够用了,为什么还要费力去碰C代码?原因主要有三类。第一类是复用已有资产,很多经典的算法库如OpenCV、FFmpeg、SQLite都是C或C++写的,通过JNI可以直接调用,没必要用Java重写一遍。第二类是性能要求极高的计算密集型任务,比如音视频编解码、大数运算、图像滤镜,C代码配合SIMD指令往往比纯Java快数倍。第三类是访问底层系统能力,Java标准库没有覆盖的硬件接口、驱动通信等,只能借助本地代码完成。
不过JNI并非银弹,跨层调用本身有开销。每次从Java进入native代码,JVM要完成状态切换和参数传递,单次调用开销虽然只有纳秒级别,但在高频循环里调用细粒度的native方法,累积成本可能反而超过Java直接执行。正确的做法是把计算逻辑做成粗粒度接口,一次native调用完成尽量多的工作,减少跨界次数。另外,native代码中的错误不会被JVM的异常机制捕获,段错误会直接让整个进程崩溃,所以JNI代码必须格外小心指针和边界检查。
在Android开发中,JNI的身影更加常见。Android NDK本质上就是围绕JNI构建的工具链,从相机数据的高效处理,到加固防逆向的密钥逻辑,再到跨平台游戏引擎,都依赖Java层与Native层的紧密配合。Google近年力推的AABB和Kotlin生态虽然降低了部分场景的使用门槛,但只要涉及本地库,JNI的底层机制依然是绕不过去的基础知识。掌握JNI,等于打开了Java世界通向系统底层的大门。