1 引言
本章介绍 Java 本地接口 (JNI)。JNI 是一种原生编程接口,它允许运行在 Java 虚拟机 (VM) 内部的 Java 代码与用其他语言(如 C、C++ 和汇编)编写的应用程序和库进行交互。
JNI 最重要的优势在于:它对底层 Java VM 的实现不施加任何限制。因此,Java VM 供应商可以在不影响 VM 其他部分的前提下添加对 JNI 的支持。程序员可以编写一个版本的原生应用程序或库,并确保它能与所有支持 JNI 的 Java VM 一起工作。
1.1 Java 本地接口概览
虽然您可以完全用 Java 编写应用程序,但某些情况下 Java 本身无法满足您的需求。程序员使用 JNI 编写"Java 本地方法"来处理那些无法完全用 Java 实现的需求。
以下示例说明了何时需要使用 Java 本地方法:
- 标准 Java 类库不支持应用程序所需的平台相关功能。
- 您已经拥有用其他语言编写的库,并希望通过 JNI 使 Java 代码能够访问它。
- 您希望用诸如汇编之类的低级语言实现一小部分时间关键型(time-critical)代码。
通过 JNI 编程,您可以使用本地方法来:
- 创建、检查和更新 Java 对象(包括数组和字符串)。
- 调用 Java 方法。
- 捕获和抛出异常。
- 加载类并获取类信息。
- 执行运行时类型检查。
您还可以将 JNI 与"调用接口 (Invocation API)"结合使用,使任意原生应用程序能够嵌入 Java VM。这使得程序员可以轻松地对其现有应用程序进行"Java 化",而无需与 VM 源代码进行链接。
2 设计综述
本章重点探讨 JNI 的主要设计问题,其中大部分内容针对本地方法(Native Methods)。关于调用接口(Invocation API)的设计请见第五章。
2.1 JNI 接口函数与指针
原生代码通过调用 JNI 函数来访问 Java VM 功能。这些函数通过一个接口指针提供。
- 定义:接口指针是一个指向指针数组的指针,数组中的每个元素都指向一个特定的接口函数。
- 优势:将 JNI 命名空间与原生代码解耦。VM 可以方便地提供多个版本的函数表。例如,一种版本执行详尽的非法参数检查(适合调试),另一种执行最小化检查以获得高性能。
- 线程约束:JNI 接口指针仅在当前线程中有效。因此,本地方法严禁将接口指针从一个线程传递到另一个线程。
- 参数传递:VM 保证在同一个 Java 线程中多次调用本地方法时,传递的接口指针保持一致。但若本地方法被不同线程调用,则可能会接收到不同的指针。
2.2 编译、加载和链接本地方法
- 编译规范 :由于 Java VM 是多线程的,本地库必须使用支持多线程的环境进行编译(例如在 Linux 上使用
-D_REENTRANT)。 - 加载方式 :通过
System.loadLibrary加载。系统会自动处理平台相关的命名规则(如 Unix 下的libName.so与 Windows 下的Name.dll)。 - 静态链接 :若 OS 不支持动态链接,原生方法必须预先与 VM 链接。此时可使用
RegisterNatives()函数手动注册本地方法。
2.3. 本地方法名称解析
动态链接器通过名称解析入口。JNI 使用简单的名称重整(Name Mangling)方案以兼容所有 Unicode 字符:
- 结构 :
Java_+ 类名 +_+ 方法名 + (可选的__+ 参数签名)。 - 下划线转义 :为了保证 C 函数名合法,
/被替换为_,_被替换为_1。 - 重载处理:仅在方法重载时才需要使用带有参数签名的长名称。
2.4 本地方法参数
-
首参数 :永远是
JNIEnv接口指针。 -
次参数:
- 若为非静态方法,第二个参数是
jobject(即this指针)。 - 若为静态方法,第二个参数是
jclass(即类引用)。
- 若为非静态方法,第二个参数是
-
后续参数:对应 Java 方法的实际参数。
2.5 引用 Java 对象
-
传递原则:原始类型(int, float 等)直接拷贝;对象类型通过引用传递。
-
引用分类:
- 局部引用 (Local References) :仅在本地方法调用期间有效,方法返回后由 VM 自动释放。
- 全局引用 (Global References) :必须通过
NewGlobalRef显式创建,且必须显式调用DeleteGlobalRef释放,否则会造成内存泄漏。
-
实现机制:VM 为每次从 Java 到本地的控制转移建立一个"注册表 (Registry)",将局部引用映射到对象,确保在 native 调用期间不会被 GC 回收。
2.6 访问 Java 对象
JNI 提供了一套丰富的访问函数。尽管通过这些函数访问对象的性能开销比直接访问 C 数据结构稍高,但对于非平凡的任务(non-trivial tasks),这种开销是可以接受的。
2.7 访问原始数组
为避免频繁函数调用的开销,JNI 提供了一种折中方案:
- 复制 (Copying) :对于少量元素,使用
Get<Type>ArrayRegion。 - 锁定/固定 (Pinning) :对于大规模运算,使用
GetPrimitiveArrayCritical请求 VM 锁定内存区域,直接获取指针。使用后必须调用ReleasePrimitiveArrayCritical解锁,否则可能导致 GC 挂起或内存碎片化。
2.8 访问字段与方法
JNI 采用两步走策略:
- 获取 ID :先通过
GetFieldID或GetMethodID查找 ID(查找开销大)。 - 重复使用:在代码中缓存这些 ID,在后续操作中直接使用(调用开销小)。
- 注意:如果类被卸载,ID 将失效,必须重新获取。
2.9 编程错误与异常
-
错误自检 :JNI 为了极致性能,不自动检查非法参数(如 NULL 指针)。开发者有责任确保参数合法。
-
异常处理:
- 本地方法可以抛出 Java 异常。
- 若 native 代码调用了会导致异常的 JNI 函数,必须在调用其他 JNI 函数前通过
ExceptionOccurred和ExceptionClear清理异常。 - 异步异常:多线程环境下的异步异常只在执行"会抛出同步异常"的 JNI 函数时被感知。
3 数据类型与结构
本章详细说明了 JNI 如何在 Java 类型与原生 C/C++ 类型之间建立映射。
3.1 原始类型
JNI 为原始类型定义了与机器无关的类型别名,以确保代码的跨平台兼容性。
| Java 类型 | 原生类型 | 说明 |
|---|---|---|
boolean |
jboolean |
无符号 8 位 (0: JNI_FALSE, 1: JNI_TRUE) |
byte |
jbyte |
有符号 8 位 |
char |
jchar |
无符号 16 位 |
short |
jshort |
有符号 16 位 |
int |
jint |
有符号 32 位 |
long |
jlong |
有符号 64 位 |
float |
jfloat |
32 位 (IEEE 754) |
double |
jdouble |
64 位 (IEEE 754) |
void |
void |
无类型 |
注:typedef jint jsize; 常用于定义索引和数组长度。
3.2 引用类型
JNI 将 Java 对象映射为引用类型。在 C 中,它们统一映射为 jobject;在 C++ 中,为了支持静态类型检查,定义了继承关系。
层次结构定义:
-
jobject(基类)-
jclass(类) -
jstring(字符串) -
jarray(数组基类)jobjectArray,jbooleanArray,jbyteArray,jcharArray,jshortArray,jintArray,jlongArray,jfloatArray,jdoubleArray
-
jthrowable(异常)
-
3.3 ID类型
方法 ID (jmethodID) 和字段 ID (jfieldID) 是原生层与 Java 层交互的"索引"。它们在 VM 内部是作为不透明指针 (Opaque Pointers) 存在的,原生层不应直接解引用这些指针。
3.4 值联合体
jvalue 联合体用于在函数参数数组中同时容纳多种 Java 类型,这在动态调用方法时非常有用:
C
ini
typedef union jvalue {
jboolean z; jbyte b; jchar c; jshort s; jint i;
jlong j; jfloat f; jdouble d; jobject l;
} jvalue;
3.5 类型签名
这是 JNI 与 VM 交互时最核心的部分。Java 方法必须通过签名字符串(Signature)来描述其参数和返回值类型。
| 类型签名 | Java 类型 |
|---|---|
Z |
boolean |
B |
byte |
C |
char |
S |
short |
I |
int |
J |
long |
F |
float |
D |
double |
Lfully-qualified-class; |
全限定类名 (例如: Ljava/lang/String;) |
[type |
数组 (例如: [I 代表 int[]) |
(arg-types)ret-type |
方法签名 (例如: (I)V 代表 void f(int)) |
3.6 修改版 UTF-8 字符串
JNI 字符串的处理与标准 UTF-8 有两点不同:
- 空字符处理 :标准的
\u0000在标准 UTF-8 中用单字节表示,但在 JNI 中使用双字节0xc0 0x80表示。这意味着JNI 字符串永远不会包含内嵌的 NULL 字符。 - 增补字符 :对于大于
U+FFFF的 Unicode 字符(例如某些 Emoji),JNI 使用 UTF-16 代理对(Surrogate Pairs)方式编码,每个代码单元分别编码为 3 字节,共计 6 字节。
4 JNI 函数参考
本章定义了所有 JNI 函数,这些函数通过 JNIEnv 指针访问。开发者必须确保传递给这些函数的参数符合规范(如非 NULL),否则可能导致 VM 崩溃。
4.1 类与对象操作
用于在原生代码中查找类、实例化对象或获取对象类型。
-
类查询:
FindClass(env, name):通过全限定名(如java/lang/String)查找类。GetObjectClass(env, obj):获取指定对象的类。GetSuperclass(env, clazz):获取指定类的父类。IsAssignableFrom(env, clazz1, clazz2):判断类之间的继承/实现关系。
-
对象创建:
AllocObject(env, clazz):仅分配内存,不调用构造函数。NewObject(env, clazz, methodID, ...):调用构造函数创建对象。
4.2 字段与方法访问
JNI 通过 ID 来访问成员,这是提升性能的关键。
-
获取 ID(查找开销高,建议缓存) :
GetFieldID(env, clazz, name, sig):获取实例字段 ID。GetStaticFieldID(env, clazz, name, sig):获取静态字段 ID。GetMethodID(env, clazz, name, sig):获取实例方法 ID。GetStaticMethodID(env, clazz, name, sig):获取静态方法 ID。
-
字段读写:
Get<Type>Field系列:获取字段值(如GetObjectField,GetIntField)。Set<Type>Field系列:设置字段值(如SetObjectField,SetIntField)。
-
方法调用:
Call<Type>Method系列:调用实例方法(如CallVoidMethod,CallObjectMethod)。CallStatic<Type>Method系列:调用静态方法(如CallStaticVoidMethod)。CallNonvirtual<Type>Method系列:调用特定父类方法(绕过重写)。
4.3 引用生命周期管理
用于控制 Java 对象的生命周期,防止内存泄漏。
-
局部引用 (Local References) :
DeleteLocalRef(env, localRef):显式删除不再需要的引用。PushLocalFrame(env, capacity):创建一个新的局部引用帧。PopLocalFrame(env, result):弹出当前帧并返回一个结果引用。
-
全局引用 (Global References) :
NewGlobalRef(env, obj):创建跨方法、跨线程的全局引用。DeleteGlobalRef(env, globalRef):显式释放全局引用。
-
弱全局引用 (Weak Global References) :
NewWeakGlobalRef(env, obj):创建不阻碍 GC 的弱引用。DeleteWeakGlobalRef(env, obj):删除弱引用。
4.4 异常处理
用于捕获和处理 Java 层抛出的异常。
Throw(env, throwable):抛出一个已存在的异常对象。ThrowNew(env, clazz, msg):构造并抛出一个新异常。ExceptionOccurred(env):返回当前挂起的异常对象。ExceptionCheck(env):高效检查是否有异常挂起(不返回异常对象,仅返回布尔值)。ExceptionClear(env):清除当前异常,使代码恢复正常状态。ExceptionDescribe(env):打印异常堆栈信息到 stderr。
4.5 数组操作
提供高效的数组读写接口。
-
GetArrayLength(env, array):获取数组大小。 -
元素访问:
Get<Type>ArrayElements:获取数组元素指针(可能产生拷贝)。Release<Type>ArrayElements:释放访问权限。Get<Type>ArrayRegion/Set<Type>ArrayRegion:直接读写数组的指定片段(最高效,适用于小范围访问)。
-
关键锁访问:
GetPrimitiveArrayCritical/ReleasePrimitiveArrayCritical:获取直接指针,在代码关键区(Critical Section)中使用。
4.6 字符串操作
NewString(env, unicodeChars, len):通过 Unicode 字符创建字符串。NewStringUTF(env, bytes):通过修改版 UTF-8 编码创建字符串。GetStringLength(env, string):获取字符串长度。GetStringUTFChars(env, string, isCopy):获取 UTF-8 字符指针。ReleaseStringUTFChars(env, string, chars):释放字符指针内存。
5 调用接口
调用接口 (Invocation API) 允许软件供应商将 Java 虚拟机 (VM) 加载到任意原生应用程序中。这使得供应商可以在无需与 Java VM 源代码进行静态链接的情况下,开发并交付支持 Java 的应用程序。
5.1 概述
调用接口允许原生应用程序通过 JNI 接口指针访问 VM 的功能。其设计理念与 Netscape 的 JRI 嵌入接口类似。
典型应用流程:
- 创建并初始化 Java VM。
- 通过
JNIEnv接口指针执行 Java 方法(如Main.test)。 - 销毁 VM。
5.2 核心 API 函数
虽然 JavaVM 结构体包含了一个接口函数表,但以下三个关键函数是"独立"的(即在创建 VM 之前即可调用,不属于 JavaVM 结构体内部):
JNI_CreateJavaVM:加载并初始化 Java VM。调用此函数的线程成为"主线程"。它会将env参数设置为该线程的 JNI 接口指针。JNI_GetCreatedJavaVMs:获取当前进程中已创建的所有 Java VM。JNI_GetDefaultJavaVMInitArgs:获取 VM 的默认配置参数。
5.3 JavaVM 接口函数表
一旦 VM 被创建,原生代码即可通过 JavaVM 指针访问以下函数:
| 函数名 | 功能描述 |
|---|---|
DestroyJavaVM |
卸载 Java VM 并回收资源。 |
AttachCurrentThread |
将当前原生线程挂载到 VM,并获取该线程的 JNIEnv。 |
DetachCurrentThread |
将当前线程从 VM 中分离。 |
GetEnv |
获取当前线程已有的 JNIEnv 指针。 |
AttachCurrentThreadAsDaemon |
挂载线程并将其标记为 Daemon(守护线程)。 |
5.4 关键概念详解
A. 线程挂载
- 重要性 :JNI 接口指针 (
JNIEnv) 是线程私有的。如果一个原生线程未挂载到 VM 而直接使用JNIEnv,会导致不可预知的错误。 - 挂载方式 :通过
AttachCurrentThread()将当前线程挂载。挂载后,该线程的行为就像运行在 Java 本地方法中的常规 Java 线程。 - 分离机制 :在线程退出前,必须 调用
DetachCurrentThread()。若栈上仍存在 Java 方法调用,则无法分离。
B. 虚拟机卸载
- 限制:VM 会等到"当前线程成为唯一的非守护用户线程"时,才会真正执行卸载。这是因为 VM 无法自动释放由原生代码持有的系统资源(如锁、文件句柄等),此责任需由程序员承担。
C. 初始化参数
这是调用接口的配置核心,包含以下参数:
-
version:必须指定 JNI 版本(如JNI_VERSION_1_6)。 -
nOptions:选项数量。 -
options:一个JavaVMOption数组,用于设置:-Djava.class.path:指定 Java 类路径。-verbose:jni:打印 JNI 相关调试信息。-Xms/-Xmx:设置初始及最大堆大小。
-
ignoreUnrecognized:若为false,遇到不认识的选项(如-X开头的非标参数)会直接报错。
5.5 库与版本管理
为了支持更复杂的动态类加载(JDK 1.2+),JNI 引入了库生命周期钩子:
-
JNI_OnLoad(JavaVM *vm, void *reserved):- 当原生库被
System.loadLibrary加载时,VM 会自动调用此函数。 - 必须 返回当前库所需的 JNI 版本号(例如
JNI_VERSION_1_4)。若版本不匹配,库将加载失败。
- 当原生库被
-
JNI_OnUnload(JavaVM *vm, void *reserved):- 当包含该库的类加载器被 GC 回收时调用。常用于执行必要的清理操作(如注销原生回调、关闭句柄)。
如果在此文章中您有所收获,请给作者一个鼓励,点个赞,谢谢支持
技术变化都很快,但基础技术、理论知识永远都是那些;作者希望在余后的生活中,对常用技术点进行基础知识分享;如果你觉得文章写的不错,请给予关注和点赞;如果文章存在错误,也请多多指教!