JVM——JNI 的运行机制
引入
在 Java 开发中,我们常常会遇到一些 Java 语言难以直接处理的场景,例如需要调用特定体系架构或操作系统的功能,或者利用汇编语言的 SIMD 指令来优化关键代码性能。这时,Java Native Interface(JNI)就成为了我们实现跨语言调用的强大工具。JNI 允许我们在 Java 代码中调用 C/C++ 代码,以实现所需功能。
JNI 的基本概念
JNI 是 Java 虚拟机提供的一种机制,用于在 Java 代码中调用原生代码(通常为 C/C++ 代码)。它使得 Java 应用程序能够与操作系统的本地函数库交互,从而实现对硬件资源的直接访问、调用系统 API 以及执行性能敏感型任务等功能。
JNI 的主要特点包括:
-
平台相关性 :JNI 允许 Java 应用调用特定平台的本地代码,因此使用 JNI 开发的应用通常不具备 Java 的跨平台特性,只能在特定平台上运行。
-
性能优化 :对于一些对性能要求极高的计算任务,通过 JNI 调用原生代码可以绕过 Java 虚拟机的一些性能限制,直接利用底层资源,从而获得更高的执行效率。
-
功能扩展 :JNI 为 Java 应用提供了访问原生库中丰富功能的途径,这些功能可能是 Java 标准类库所不具备的。
native 方法的链接
在 Java 中,native 方法是指那些没有提供 Java 实现,而是通过 JNI 调用原生代码的方法。当在 Java 代码中调用这些 native 方法时,Java 虚拟机需要将它们链接到对应的原生函数上。native 方法的链接主要有两种方式:
自动链接
Java 虚拟机会自动查找符合默认命名规范的原生函数,并将 native 方法链接到这些函数上。为了生成符合命名规范的原生函数声明,我们可以使用 javac -h
命令。例如,对于以下 Java 类:
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
的头文件。该头文件中包含了符合 JNI 命名规范的原生函数声明。命名规范要求原生函数以 Java_
为前缀,接着是完整的包名和类名,中间用下划线连接,并且将原类名中的斜杠 /
替换为下划线 _
。如果存在重载方法,则还需要将方法的参数类型编码追加到函数名后,参数类型中的特殊字符(如 ;
和 [
)也会被替换为 _2
和 _3
等。
显式链接
除了自动链接方式外,还可以在原生代码中显式地进行 native 方法的链接。这种方式通常会使用一个名为 registerNatives
的 native 方法,并在其中调用 RegisterNatives
JNI API 来手动注册其他 native 方法与原生函数的对应关系。例如,Java 中的 Object
类就使用了这种方式来注册其 native 方法。这种方式使得原生函数的命名可以更加灵活,不受默认命名规范的限制。
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]));
}
在使用显式链接时,需要注意必须在原生函数被调用之前完成注册工作。通常会在 Java 类的静态初始化块中调用 registerNatives
方法来完成注册。
JNI 的 API
JNI 提供了一系列丰富的 API,允许原生代码访问和操作 Java 虚拟机中的各种资源。这些 API 通过一个名为 JNIEnv
的数据结构提供给原生代码。JNIEnv
是一个线程私有的数据结构,每个 Java 线程都有一个对应的 JNIEnv
实例。它包含了指向各种 JNI 函数的指针,原生代码可以通过这些指针调用 JNI 函数。
数据类型映射
JNI 定义了一套与 Java 基本类型相对应的 C 语言数据类型,例如 jint
对应 Java 的 int
类型,jlong
对应 Java 的 long
类型等。对于引用类型,JNI 提供了 jobject
类型及其派生类型(如 jclass
、jstring
、jarray
等)来表示 Java 中的各种对象引用。
常见 API 函数
对象操作 :可以创建 Java 对象、获取对象的类信息、访问对象的字段和方法等。例如,NewObject
用于创建新对象,GetObjectClass
用于获取对象的类,GetFieldID
和 GetObjectField
用于访问对象的字段。
数组操作 :提供了操作 Java 数组的 API,如 NewIntArray
创建新的 int
数组,GetIntArrayElements
获取数组元素等。
字符串操作 :可以将 Java 的 String
对象转换为 C 风格的字符串,以及反之。例如,GetStringUTFChars
用于获取 String
对象的 UTF-8 编码的 C 字符串,NewStringUTF
用于从 C 字符串创建 Java String
对象。
异常处理 :JNI 提供了处理异常的 API,如 ExceptionOccurred
用于检查是否发生了异常,ExceptionDescribe
用于输出异常信息,ExceptionClear
用于清除已发生的异常等。
局部引用与全局引用
在原生代码中,引用 Java 对象时需要考虑垃圾回收的问题。JNI 提供了局部引用和全局引用来解决这一问题。
局部引用
局部引用是在原生函数调用期间有效的引用。它们在原生函数返回之前一直有效,一旦原生函数返回,局部引用将自动被释放。局部引用主要用于确保在原生代码执行期间,所引用的 Java 对象不会被垃圾回收。例如,原生函数的参数、JNI API 创建的对象等都是局部引用。
JNIEXPORT void JNICALL Java_org_example_Foo_nativeMethod(JNIEnv *env, jobject obj) {jclass cls = (*env)->GetObjectClass(env, obj);jfieldID fid = (*env)->GetFieldID(env, cls, "instanceVar", "I");jint value = (*env)->GetIntField(env, obj, fid);printf("Value: %d\n", value);// 这里 cls、fid、value 都是局部引用,原生函数返回后它们将被自动释放
}
全局引用
全局引用与局部引用不同,它在整个应用程序的生命周期内有效,直到显式地被释放。如果需要在多个原生函数调用之间共享对某个 Java 对象的引用,或者需要缓存某些对象以供后续使用,则需要使用全局引用。全局引用可以通过 NewGlobalRef
函数从局部引用水创建,使用完毕后需要通过 DeleteGlobalRef
显式释放。
jobject globalRef;
JNIEXPORT void JNICALL Java_org_example_Foo_init(JNIEnv *env, jobject obj) {globalRef = (*env)->NewGlobalRef(env, obj);// 现在 globalRef 是一个全局引用,可以安全地在其他原生函数中使用
}
JNIEXPORT void JNICALL Java_org_example_Foo_cleanup(JNIEnv *env, jobject obj) {(*env)->DeleteGlobalRef(env, globalRef);// 释放全局引用,允许垃圾回收
}
引用管理的注意事项
避免引用泄漏 :由于全局引用会阻止 Java 对象被垃圾回收,因此必须确保在不再需要时及时释放全局引用。否则,可能会导致内存泄漏。
局部引用的限制 :局部引用只能在原生函数调用期间使用。如果需要将引用传递给其他线程或者保存到后续调用中,必须使用全局引用。
性能开销 :频繁地创建和销毁全局引用可能会增加垃圾回收的负担,进而影响应用程序的性能。因此,在使用全局引用水时需要权衡其必要性和性能影响。
JNI 的调用过程与性能开销
调用过程
当 Java 代码调用 native 方法时,Java 虚拟机会执行以下步骤:
-
查找原生函数 :根据 native 方法的链接方式(自动链接或显式链接),找到对应的原生函数。
-
准备调用 :将 Java 虚拟机的执行环境切换到原生代码执行模式,包括设置原生函数的参数、保存 Java 虚拟机的上下文等。
-
执行原生函数 :调用原生函数,原生函数使用 JNI API 与 Java 虚拟机进行交互,访问 Java 对象、调用 Java 方法等。
-
返回 Java 环境 :原生函数执行完毕后,将控制权返回给 Java 虚拟机,恢复 Java 执行环境。
性能开销
JNI 调用会引入一定的性能开销,主要包括以下几个方面:
-
上下文切换 :在 Java 和原生代码之间切换执行环境会产生一定的开销,包括保存和恢复寄存器、堆栈指针等。
-
数据类型转换 :Java 和 C/C++ 的数据类型表示不同,JNI 需要在两者之间进行数据类型转换,这也会占用一定的时间。
-
句柄管理 :为了支持垃圾回收,JNI 使用句柄来间接引用 Java 对象。句柄的管理和使用会增加内存访问的开销。
-
跨语言调用开销 :跨语言调用涉及到的调用约定、参数传递等也会带来额外的性能损耗。
JNI 的异常处理
在 JNI 编程中,异常处理是一个重要的方面。JNI 提供了处理异常的 API,允许原生代码检测、抛出和清除异常。
检测异常
在调用可能抛出异常的 JNI 函数后,可以通过 ExceptionOccurred
函数检测是否发生了异常。如果发生了异常,可以使用 ExceptionDescribe
输出异常信息。
JNIEXPORT void JNICALL Java_org_example_Foo_nativeMethod(JNIEnv *env, jobject obj) {jclass cls = (*env)->FindClass(env, "org/example/SomeClass");if ((*env)->ExceptionOccurred(env)) {(*env)->ExceptionDescribe(env);(*env)->ExceptionClear(env);return;}// 继续处理...
}
抛出异常
原生代码可以使用 Throw
或 ThrowNew
函数抛出异常。Throw
函数用于抛出已有的异常对象,而 ThrowNew
函数用于创建并抛出新的异常对象。
JNIEXPORT void JNICALL Java_org_example_Foo_nativeMethod(JNIEnv *env, jobject obj) {jclass exceptionCls = (*env)->FindClass(env, "java/lang/Exception");(*env)->ThrowNew(env, exceptionCls, "An error occurred in native code");
}
清除异常
在某些情况下,原生代码可能需要清除已发生的异常,以便继续执行或重新抛出新的异常。可以使用 ExceptionClear
函数来清除异常。
if ((*env)->ExceptionOccurred(env)) {(*env)->ExceptionClear(env);// 处理异常或重新抛出...
}
实际应用案例
性能优化案例
假设我们有一个图像处理应用程序,其中包含一个对性能要求极高的滤镜算法。该算法在 Java 中实现时性能不够理想,因此我们决定使用 JNI 调用 C 语言实现的版本。通过 JNI,我们将算法的核心部分移植到 C 语言中,并利用其对底层硬件的直接访问能力,实现了显著的性能提升。
系统集成案例
在另一个场景中,我们需要开发一个与特定硬件设备交互的 Java 应用程序。该硬件设备提供了 C 语言的 SDK,我们通过 JNI 在 Java 应用中调用这些 C 函数,实现了对硬件设备的控制和数据采集。这使得 Java 应用能够充分利用硬件设备的功能,满足了项目需求。
JNI 的优点与局限性
优点
功能扩展性强 :能够调用原生代码,访问 Java 核心类库无法提供的功能。
性能优化潜力大 :对于性能敏感型任务,原生代码通常能提供更高的执行效率。
兼容性好 :可以与现有的 C/C++ 库进行集成,充分利用已有的代码资源。
局限性
破坏可移植性 :使用了 JNI 的 Java 应用通常依赖于特定平台的原生库,导致应用无法跨平台运行。
开发复杂度高 :JNI 涉及 Java 和 C/C++ 两种语言的混合编程,增加了开发和调试的难度。
安全风险高 :原生代码中的错误(如内存泄漏、野指针等)可能会导致 Java 虚拟机崩溃或出现不稳定的行为。
总结
本文详细介绍了 JNI 的运行机制,包括 native 方法的链接、JNI 的 API、局部引用与全局引用等内容。通过学习 JNI 的相关知识,我们能够更好地理解 Java 与原生代码之间的交互方式,掌握在 Java 应用中调用原生代码的方法和技巧,从而在实际开发中充分利用 JNI 的优势,解决 Java 语言难以直接处理的问题。
最佳实践
尽量减少 JNI 的使用 :由于 JNI 会破坏 Java 应用的可移植性,并且增加了开发和维护的复杂度,因此在可能的情况下,应优先考虑使用纯 Java 实现。
封装原生代码 :将原生代码封装在一个单独的模块中,与 Java 代码的其他部分解耦,这样可以降低维护难度,并且便于后续的修改和替换。
严格管理引用 :合理使用局部引用和全局引用,避免出现内存泄漏等问题。在不再需要全局引用时,及时释放它。
充分测试 :JNI 代码的调试和测试相对复杂,需要在不同的平台和环境下进行充分的测试,以确保原生代码的正确性和稳定性。