
- 什么是JNI?它主要用来干什么。 ⭐⭐⭐⭐⭐
- Java 声明的Native方法如何和Native层的Native函数进行绑定的?⭐⭐⭐⭐⭐
- JNI如何实现数据传递?⭐⭐⭐⭐
- 如何全局捕获Native发生的异常?⭐⭐⭐
- JNIEnv与JavaVM的关系⭐⭐⭐⭐
- C和C++的JNIEnv的区别 ⭐⭐⭐
- JNI项目配置和数据映射 ⭐⭐
什么是JNI、NDK
JNI
JNI(Java Native Interface)就是Java本地化接口。在Windows,Linux,MacOS等操作系统的底层驱动都是使用C/C++开发的,因此这些系统提供的API函数都是C/C++编写的。而在安卓开发中,我们使用java编程写的代码都是在Java虚拟机中,编译成虚拟机可以运行的Java字节码.class文件,再通过JIT技术即时编译成本地机器码,所以效率是比不上C/C++的。因此,很容易联想到,我们希望能有这么一个中间件,支持我们在Java代码中与本地系统的C/C++代码做个交互,这个中间件就是JNI。
Java一次编译到处执行: JVM在不同的操作系统都有实现,Java可以一次编译到处运行,字节码文件一旦编译好了,可以放在任何平台的虚拟机上运行;
Java语言执行流程:
- 编译字节码:Java编译器编译 .java源文件,获得.class 字节码文件;
- 装载类库:使用类装载器装载平台上的Java类库,并进行字节码验证;
- Java虚拟机:将字节码加入到JVM中,Java解释器和即时编译器同时处理字节码文件,将处理后的结果放入运行时系统;
- 调用JVM所在平台类库:JVM处理字节码后,转换成相应平台的操作,调用本平台底层类库进行相关处理;
1.2 JNI 与 NDK 的联系和区别
NDK(Native Development Kit),翻译过来是“本地开发工具”,是Google开发的一套开发和编译工具集,可快速生成C、C++的动态库,并自动把so和应用打包成apk,主要用于Android的JNI开发;
因此,JNI是一套编程接口,可以实现Java代码和本地C/C++代码进行交互。而NDK可以理解为Android实现JNI的一种工具,通过该工具打包C/C++动态库并自动打包进APK/AAR中。
JNI的两种注册方式
JNI有静态注册和动态注册两种方式,多用动态注册。
静态注册
静态注册的原理是:根据函数名建立Java方法和JNI函数的一一对应关系。步骤如下:
- 先声明 Java 的 native 方法;
- 使用 javah 工具生成对应的头文件,在Terminal控制台执行以下任一命令生成由包名加类名命名的 jni 层头文件:
javah packagename.classname
;javah -o my_jni.h packagename.classname
,其中 my_jni.h 为自定义的文件名;
- 实现对应的native方法,并在Java中通过System.loadLibrary()方法加载 so 库即可;
因为有Android Studio这个强大的工具,我们可以很轻松建立一个JNI项目工程。
新创建的项目就有了默认的JNI函数了,下面做简单介绍:
1 | public class MainActivity extends AppCompatActivity { |
在[注释3]先声明 native 方法,并在[注释1]加载so库,最后在[注释2]调用native函数stringFromJNI(),其对应的实现在:native-lib.cpp文件里:
1 |
|
[注释4]是两个关键词:JNIEXPORT 和 JNICALL
,这两个都是宏定义,在jni.h文件中定义:
1 | #define JNIIMPORT |
主要作用是注明该函数Java_com_example_myapplication_MainActivity_stringFromJNI()是JNI函数,那么当虚拟机加载so库时,就会将该函数链接到对应的Java层native方法,World”的字符串。
native层函数命名规则
上一小节的[注释3]stringFromJNI()和[注释5]Java_com_example_myapplication_MainActivity_stringFromJNI(JNIEnv* env,jobject)有匹配关系,其Natvie层函数命名遵循以下规则:
JNIEXPORT 返回值 JNICALL Java_全路径类名_方法名_参数签名(JNIEnv* , jclass, 其它参数);
同时还有以下几个需要注意的小点:
- 如果是c++文件,如上述例子Natvie方法放在native-lib.cpp文件里,此时需要在Native函数前面加上 extern “C”;
- 如果该函数是重载的方法,则需要加上“参数签名”,参数签名见3.3.1小节,上述例子非重载方法,因此命名上不需要加“参数签名”;
- 包名或类名或方法名中含下划线 _ 要用 _1 连接;
- 重载的Native方法命名中的“方法名”后面要用双下划线 __ 连接;
- 参数签名的斜杠 “/” 改为下划线 “_” 连接,分号 “;” 改为 “_2” 连接,左方括号 ‘[‘改为 ‘_3’ 连接 ;
- 如果Java层方法是static方法,则Native层方法的第二个形参是jclass,否则是jobject,如上述例子,Java层为非static方法,所以Native层方法的第二个参数是jobject。
静态注册的优缺点
静态注册优点就是实现简单,编写好Java方法后用javah工具就可以将Java代码中声明的native方法转换为native层的代码函数,并直接实现native层代码逻辑就行。然而缺点也比较明显:
- 每次增加新的函数或者修改函数名等,都需要手动在运行javah命令,比较麻烦。同时,生成的Native层函数名字太长了,可读性不高;
- 首次调用Native函数时,需要根据函数名在Java层和Native层直接建立函数链接,比较耗时;
因此,无论是实用性还是效率,都推荐使用动态注册。
动态注册
动态注册的原理是通过使用 JNINativeMethod 结构来保存Java层声明的native方法和Native层函数的关联关系,直接告Java层声明的native方法其在Native层中对应函数的指针。该结构体的定义和动态注册需要用到的关键函数也在jni.h文件中定义:
1 | //JNINativeMethod结构体 |
动态注册的步骤:
- 先声明 Java层 的 native 方法;
- 同步实现Native层函数的实现,函数名可以任意取!
- 利用结构体 JNINativeMethod 保存Java层native方法和 Native层的JNI函数的对应关系;
- 利用registerNatives(JNIEnv* env)注册类的所有本地方法;
- 在 JNI_OnLoad() 方法中调用步骤4的注册方法;
- 在Java中通过System.loadLibrary加载完JNI动态库之后,会调用JNI_OnLoad()方法,完成动态注册;
代码实例如下:
1 | // Java层的MainActivity.java文件: |
在[注释7]加载好库后,会自动调用[注释10]JNI_OnLoad()方法并最终执行[注释11]registerMethods()方法,对[注释12]定义的结构体进行注册。这样Java声明的native函数就可以使用啦。
JNI语法
JNI项目配置
使用Android Studio新创建的JNI工程可以快速的实现Java层和Native层函数的交互,这是因为Android Studio已经自动的帮我们完成了项目配置,主要有以下几点:
build.gradle
build.gradle 文件配置:注意两个externalNativeBuild {}的配置
1 | android { |
3.1.2 CMakeLists.txt文件
CMakeLists.txt文件 :上面[注释13]配置的CMake文件CMakeLists.txt是Android Studio自动生成的CMake脚本配置文件,以下是Android Studio首次自动生成还未进行自定义修改的CMakeLists.txt文件:
1 | # For more information about using CMake with Android Studio, read the |
那如果需要加上自定义的修改,如添加新的c/c++文件,添加新的动态库,或者修改动态库的默认名字,需要如何修改呢?
- 在add_library()这个方法里可以在[注释#2]修改动态库名字,在[注释#4]继续添加新的c/c++文件;
- 当然也可以另写一个add_library()方法添加新的动态库,然后记得在[注释#7]下面也加上新的动态库的名字;
- 添加文件夹:上面native-lib.cpp文件路径是src/main/cpp/native-lib.cpp,假如我们需要添加一个src/main/cpp/include文件夹来包含项目需求用到的其他.h头文件,则需要加上:
include_directories(${CMAKE_SOURCE_DIR}/include)
将指定目录添加到编译器的头文件搜索路径之下。
至于其他的语法请自行百度“Cmake命令“。
数据映射
基本数据类型映射
Java层基本数据类型和Native层数据类型会有如下映射关系,这些都是在jni.h里面有定义的:
Java类型 本地类型 描述
boolean | jboolean | C/C++无符号8位整型(unsigned char) |
byte | jbyte | C/C++带符号8位整型(char) |
char | jchar | C/C++无符号16位整型(unsigned short) |
short | jshort | C/C++带符号16位整型(short) |
int | jint | C/C++带符号32位整型(int) |
long | jlong | C/C++带符号64位整型(long) |
float | jfloat | C/C++32位浮点型(float) |
double | jdouble | C/C++64位浮点型(double) |
引用数据类型映射
Java引用数据类型和Native层的映射如下:
Java类型 本地类型 描述
Object | jobject | 任何Java对象 |
Class | jclass | Java中的类对象 |
String | jstring | 字符串对象 |
Object[] | jobjectArray | 任何Java对象的数组 |
boolean[] | jbooleanArray | 布尔型数组 |
byte[] | jbyteArray | 比特型数组 |
char[] | jcharArray | 字符型数组 |
short[] | jshortArray | 短整型数组 |
int[] | jintArray | 整型数组 |
long[] | jlongArray | 长整型数组 |
float[] | jfloatArray | 浮点型数组 |
double[] | jdoubleArray | 双浮点型数组 |
不过需要注意,引用类型是不能直接在Native层使用的,需要根据JNI函数进行类型转换后才可以使用。举个例子,Java层声明了public native int getLength(int[] arr);
则对应的Native层函数应该对int[]进行如下转换:
1 |
|
方法和变量ID
Java层的方法Natvie层也不能直接使用,需要通过JNI提供的函数获取方法的ID,再根据这个ID通过JNI提供的函数获取到对应的方法,经过这几个步骤才可以在Native层调用Java层的方法,变量也是一样的道理。ID的结构体定义也在jni.h文件里:
1 | struct _jfieldID; /* opaque structure */ |
举个例子,Java层的Person类有如下代码:
1 | // 定义 |
对应的Native层代码如下:
1 | extern "C" |
最終的打印結果:
1 | MainActivity: 调用前:getAge() = 10 |
JNI 描述符
域描述符
基础类型描述符
基础类型描述符常用于上述注册JNI函数时用到的函数签名,参数签名,除了boolean的描述符是Z,long类型的描述符是J之外,其他基础类型的描述符都是其类型名称的大写首字母:
Java基础类型 域描述符
boolean | Z |
long | J |
int | I |
char | C |
short | S |
float | F |
double | D |
void | V |
byte | B |
3.3.1.2 引用类型描述符
引用类型的描述符需要重点注意,类描述符前面需要加上’L’,后面需要加上’ ; ‘
1 | L + 引用类描述符 + ; |
比如String的域描述符就是Ljava/lang/String;
数组的域描述符也比较特殊,如果是n级数组,则域描述符有n个’ [ ‘,同时:数组类型为:
- 基本数据类型:域描述符不加分号
- Class类:域描述符以分号结尾
例如:
1 | int[] 描述符为 [I |
类描述符
类描述符是类的完整名称:L+包名+类名+;,java 中包名用’ . ‘分割,jni 中改为用’ / ‘分割。如com.android.example包下面的User类的类描述符就是:Lcom/android/example/User;
在Native层获取Java类对象可以通过FindClass()方法,如
1 | jclass jclazz = env->FindClass("Lcom/android/example/User;"); |
方法描述符
方法描述符规则是:
1 | (参数……)返回类型 |
如果没有参数,可以不需要括号。例如:
1 | Java 层方法 ——> JNI 函数签名 |
JNIEnV分析
1 | Java_com_example_myapplication_MainActivity_getLength((JNIEnv *env, jobject jobj, jintArray arr_) {} |
在JNI函数中,第一个形参就是JNIEnv,JNIEnv是jni.h文件最重要的部分,定义如下
1 | struct _JNIEnv; |
其中JNINativeInterface的注释”Table of interface function pointers”翻译过来就是 是接口函数表指针,因此无论是上述C++还是C针对JNIEnv的定义,都代表JNIEnv是指向JNINativeInterface的指针,即是指向函数表指针的指针
。很明显上面的JavaVM也是指向函数表指针的指针
1 | /* |
C和C++的JNIEnv的区别
我们具体分析下C和C++的JNIEnv的区别,先看C,对应上述源码[注释15]:
1 | // c的定义 |
也就是说此时的env实际是一个二级指针,因此要获取到JNINativeInterface这个“接口函数表指针”的,需要执行*env
,此时*env
才可以去调用接口函数表里面的函数:例如:
1 | (*env)->NewStringUTF(env, "hello") |
而C++的JNIEnv对应[注释14]:typedef _JNIEnv JNIEnv;
其中_JNIEnv 的定义对应[注释16],结构体定义了JNINativeInterface* functions
,即定义了 JNINativeInterface 的结构体指针,因此
1 | // c++的定义 |
如果需要调用[注释17]的NewStringUTF()方法,则只需要执行:
1 | // env不需要加' * ' 号,也不需要作为参数传入NewStringUTF()方法 |
JNIEnv的特点
- JNIEnv是一个指针,指向一组JNI函数,如上面[注释17]的NewStringUTF()方法,通过这些方法实现了Java层和Native层的交互;
- 在c文件的JNI函数,JNIEnv需要作为第一个函数参数,而在C++则不需要作为函数参数;
JavaEnv 和 JavaVM 的关系
看[注释15]处,有:
1 | typedef const struct JNINativeInterface* JNIEnv; |
JNIEnv和JavaVM定义完成一样,那么这两者有什么联系和区别:
- Android每个进程只有一个JavaVM虚拟机对象,但可以有多个线程,当新线程第一次调用JNI函数时,虚拟机会给该线程生成一个JNIEnv指针。所以JNIEnv是线程局部存储,保证多线程之间的JNI通讯都是独立的。因此一个进程只有一个 JavaVM,但可能有多个 JNIEnv;
- JNIEnv 内部的函数执行环境来源于 Dalvik 虚拟机;
- 当本地的 C/C++ 代码想要获得当前线程所想要使用的 JNIEnv 时,可以使用 Dalvik VM 对象的 JavaVM* jvm->GetEnv()方法,该方法会返回当前线程所在的 JNIEnv*;
- Java 的 dex 字节码和 C/C++ 的 .so 同时运行 Dalvik VM 之内,共同使用一个进程空间;