本文是在针对 Android
平台中使用 JNI
的分析。
基础
JNI:Java Native Interface
是 Java
本地开发接口,JNI
是一个协议,这个协议用来沟通 Java
代码和外部的本地代码 C/C++
,确保 Java
和 C/C++
代码之间能相互调用。
类型与数据结构
基本数据类型
Java
基本数据类型在 JNI
中的对应关系:
Java 类型 | Native 类型 | 描述 |
---|---|---|
boolean | jboolean | unsigned 8 bits |
byte | jbyte | signed 8 bits |
char | jchar | unsigned 16 bits |
short | jshort | signed 16 bits |
int | jint | signed 32 bits |
long | jlong | signed 64 bits |
float | jfloat | 32 bits |
double | jdouble | 64 bits |
void | void | not applicable |
jni.h
中基本类型对应源码:
1 | // jni.h |
引用类型
Java
中的引用类型 Object
对应 JNI
中的 jobject
,代表一切引用类型。在 C++
中通过类继承的方式体现层级,在 C
中都是无类型指针 void*
;先看对应关系:
Java 类型 | Native 类型 | 描述 |
---|---|---|
Object | jobject | 所有的 Java 对象 |
Class | jclass | Class 对象 |
Throwable | jthrowable | 可抛出类型对象 |
String | jstring | 字符串 |
Object[] | jarray | 数组 |
boolean[] | jbooleanArray | 布尔数组 |
byte[] | jbyteArray | 字节数组 |
char[] | jcharArray | 字符数组 |
short[] | jshortArray | 短整型数组 |
int[] | jintArray | 整形数组 |
long[] | jlongArray | 长整型数组 |
float[] | jfloatArray | 浮点数组 |
double[] | jdoubleArray | 双浮点数组 |
jni.h
中引用类型对应的源码:
1 |
|
属性和方法
Java
中的属性和方法,在反射时会用到,在 JNI
中由对应类型:
1 | struct _jfieldID; /* opaque structure */ |
这里并没有给出 _jfieldID, _jmethodID
两个结构体的详细定义,但是可以用 jfieldID, jmethodID
两个结构体指针来表示 Java
类的属性和方法。
可变参数列表
Java
方法中的可变参数列表比如 void set(String... params)
,不定长度的参数可以看做一个数组;在 JNI
中使用 jvalue
来表示,它是一个联合体,通常在 C/C++
调用 Java
时会用到:
1 | typedef union jvalue { |
类型签名 Type Signatures
JNI
中使用 Java VM
虚拟机的类型签名格式来表示:
类型签名 | Java 类型 |
---|---|
Z | boolean |
B | byte |
C | char |
S | short |
I | int |
J | long |
F | float |
D | double |
L fully-qualified-class ; | fully-qualified-class |
[ type | type[] |
( arg-types ) ret-type | method type |
这个表格包含如下几个部分:
- 基本类型
基本类型都使用单个字符来表示签名,如int a
,签名为I
。 - 类全限定名
类都使用全限定名,并以L
开头,;
分号结尾,比如String
的全限定名为:Ljava/lang/String;
。 - 数组
数组必须以[
开头,比如int[]
表示为:[I
。 - 方法描述符
先写参数再写返回值,比如方法long f (int n, String s, int[] arr);
表示为(ILjava/lang/String;[I)J
。
字符串编码
Unicode
编码
字符串常见的编码有 ASCII
和 Unicode
;其中 ASCII
只能表示 128 种符号,对于英文完全够用了,但是对于中文、俄文、阿拉伯文等远远不够,于是新定义了通用型 Unicode
方案,它使用 21 位来编码,表示范围为 U+0000 ~ U+10FFFF
接近几百万个字符,其中 U+D800 ~ U+DFFF
之间为保留码位。ASCII
只需要 8 位就能全部表示完,但是 Unicode
编码方案,并没有规定如何存储,因此实现 Unicode
的存储出现了不同的 UTF: Unicode Transformation Format
编码方案:UTF-8, UTF-16, UTF-32
。其中 UTF-8, UTF-16
是变长编码,而 UTF-32
是 32 bit
定长编码。
UTF-8
UTF-8: 8-bit Unicode Transformation Format
变长编码,每个 Unicode
字符被编码为 1-4 个字节,对应关系如下:
Unicode 编码 | UTF-8 编码 |
---|---|
U+0000 ~ U+007F | 0xxxxxxx |
U+0080 ~ U+07FF | 110xxxxx 10xxxxxx |
U+0800 ~ U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 ~ U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
也就是最高字节有几个 1 即表示当前 Unicode
字符占几个字节;所以英文字符只需要 1 个字节,绝大部分中文需要 3 个字节,少数可能会使用 4 个字节。
Unicode 码 | 对应字符 | UTF-8 编码 |
---|---|---|
U+0041 | A | 0x41 |
U+7834 | 破 | 0xE7 0xA0 0xB4 |
U+6653 | 晓 | 0xE6 0x99 0x93 |
U+2A6A5 | 𪚥(四个龍) | 0xF0 0xAA 0x9A 0xA5 |
从实际的结果上看,UTF-8
编码只有在单字节内的 Unicode
码和对应的 UTF-8
编码相同;多字节的 UTF-8
编码已经看不出对应的 Unicode
码了。
UTF-16
UTF-16: 16-bit Unicode Transformation Format
变长编码,每个 Unicode
字符被编码为 2 个或者 4 个字节;即码位范围 U+0000 ~ U+FFFF
使用 2 字节,而 U+10000 ~U+10FFFF
使用 4 字节。
码位范围 U+0000 ~ U+FFFF
,以中文为例,相当于能表示 65536 个常用汉字,因为使用的是 2 字节,所以这个范围内的 UTF-16
编码和 Unicode
码完全一样:
Unicode | 字符 | UTF-16 码元 | UTF-16 LE 小端 | UTF-16 BE 大端 |
---|---|---|---|---|
U+0041 | A | 0x0041 | 0x41 0x00 | 0x00 0x41 |
U+7834 | 破 | 0x7834 | 0x34 0x78 | 0x78 0x34 |
U+6653 | 晓 | 0x6653 | 0x53 0x66 | 0x66 0x53 |
码位范围 U+10000 ~U+10FFFF
,并不是直接使用 4 字节存储的,而是使用的代理码,即先将码表减去 0x10000
再针对高低 10 位分别与前导码做或运算后再存储的,这里不做详细分析。
修改后的 UTF-8
编码
JVM
中字符串使用的是修改后的 UTF-8
编码方案,即 Modified UTF-8
,它实际是 UTF-8
和 UTF-16
的一个混合体:
Unicode 编码 | Modified UTF-8 编码 |
---|---|
U+0000 ~ U+007F | 0xxxxxxx |
U+0080 ~ U+07FF | 110xxxxx 10xxxxxx |
U+0800 ~ U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 ~ U+10FFFF | 11101101 1010vvvv 10wwwwww 11101101 1011yyyy 10zzzzzz |
修改后的 UTF-8
只使用 1 字节、 2 字节、 3 字节和 6 字节四种方式来存储,其中码位范围 U+10000 ~U+10FFFF
的第 1 和 4 字节都是固定的 11101101
,另外 4 个字节使用的是 UTF-16
的代理码,高 10 位放入第 2 和第 3 字节,低 10 位放入第 5 和第 6 字节。JNI
中的字符串也采用的是这种修改后的 UTF-8
编码。
JNI
接口指针
基本概念
JNI
的设计架构如下图所示:
JNI
接口指针指向函数表,包含当前线程的所有信息,而这个接口指针在 JNI
中,使用 JNIEnv
来表示;每个线程对应一个 JNIEnv
结构,它们保存在线程本地存储 TLS
中;因此不同的线程的 JNIEnv
是不同,也不能相互共享使用。JavaVM
是虚拟机在 JNI
层的代表,一个进程只有一个 JavaVM
,所有的线程共用一个 JavaVM
,所以一个 JavaVM
中可以有多个 JNIEnv
。
1 | // jni.h |
JavaVM
源码
JavaVM
代表的是虚拟机相关信息,源码中 JavaVM
实际就是 JNIInvokeInterface
指针,在 C++
中,_JavaVM
是 JNIInvokeInterface
的包装类。
1 | // jni.h |
JNIEnv
源码
JNIEnv
代表了当前线程的函数表,几乎包含了所有的 JNI
函数,它是线程隔离 TLS
的,所以不能在线程间共享。JNIEnv
主要用于操作 Java
对象的类、方法、属性等,可以理解为反射的 C/C++
实现。JNIEnv
实际是 JNINativeInterface
结构指针,在 C++
中,_JNIEnv
是 JNINativeInterface
的包装类。所以它们的调用有两种风格:
C
风格:(*env)->NewStringUTF(env, “Hellow World!”);
C++
风格:env->NewStringUTF(“Hellow World!”);
1 | struct JNINativeInterface { |
JNI
引用
JNI
支持将类实例和数组类型(如 jobject, jclass, jstring, jarray
)作为不透明的引用,通过使用 JNI
方法来得到指向该数据结构的指针的引用,也就是说 JNI
操作的内容都是引用。JNI
规范中定义了三种引用:
Local Reference
局部引用Global Reference
全局引用Weak Global Reference
弱全局引用
这三种引用的作用域和生命周期都不一样:
- 局部引用在方法返回时会被自动释放,当局部引用在使用过程中,
GC
不会回收该对象 - 全局引用和弱全局引用,一直保存直到手动释放
- 全局引用在释放前不会被
GC
回收;弱全局引用GC
时会被回收
局部引用
- 通过
NewLocalRef
;或者大部分创建接口FindClass, NewObject, GetObjectClass, NewCharArray
等都会生成局部引用 - 虽然局部引用在方法返回时会自动释放,但是通常如果后续并不使用该引用了,应该主动释放
DeleteLocalRef
- 局部引用是线程相关的,不在跨函数使用,不能跨线前使用
全局引用
- 全局引用必须主动创建
NewGlobalRef
;通过DeleteGlobalRef
主动释放 - 全局引用可以跨方法使用,可以在多线程中使用
- 全局引用在使用中,引用的对象不会被
GC
回收,需要特别注意
弱全局引用
- 弱全局引用必须主动创建
NewWeakGlobalRef
;通过DeleteGlobalWeakRef
主动释放 - 弱全局引用可以跨方法使用,可以在多线程中使用
- 弱全局引用在使用中,引用的对象可能会被
GC
回收,使用时需要做空判断
引用比较
使用 IsSameObject
比较两个引用是否指向同一个对象:jboolean (IsSameObject)(JNIEnv, jobject, jobject);
;返回 JNI_TRUE
表示指向的是同一个。
API
基本功能
JNIEnv
主要是操作 Java
中的代码,类似反射,所以主要根据 Java
特性来介绍例子。先说明 JNI
中几个重要的类型:
jclass
:表示Java
中的类jfieldID
:表示Java
中的属性字段ID
jmethodID
:表示Java
中的方法ID
jobject
:表示Java
中的一切对象
动态注册方法
Java
中 native
的方法可以通过静态注册和动态注册的方式和 C/C++
中的代码绑定:
- 静态注册
必须满足命名格式:Java_packageName_className_nativeMethodName
,都是以下划线_
拼接的。如果满足这个格式,Java
和C/C++
会自动关联。 - 动态注册
不需要满足命名格式,在so
文件加载时,通过代码动态注册来关联。
动态注册的入口函数为 JNI_OnLoad
,并通过 JNINativeMethod
结构体提供映射表,示例代码 Java
中的 dynamicRegistration
动态注册匹配 nativeDynamicRegistration
方法:
1 | void nativeDynamicRegistration(){ |
这段代码中:
JNI_OnLoad
是入口函数,在so
文件加载时调用,可以在该函数中使用全局变量保存vm
变量的值JNINativeMethod
定义映射数组时,每个映射方法必须按照:名称、方法签名、函数指针;格式来匹配env->RegisterNatives
注册方法映射数组
字符串操作
从字符串编码那一节我们可以看到,Java, JNI, C/C++
对字符串编码都不一样,所以 JNI
中的字符串操作需要先进行转码;可以简单的理解为:
jstring
:Java
和JNI
中可以相互传递和使用char *
:C/C++
和JNI
中可以相互传递和使用jchar*
:JNI
中特有的(本文暂时没找到怎么用)
因此我们重点需要关注的就是 jstring, char*
之间的转换了;相关 API
:
1 | // jchar* 和 jstring 相互转换 |
示例代码,Java
传入的字符包含中英文,从 JNI
中返回的字符串也包含中英文:
1 | String input = "abc你好"; |
JNI
对字符串的处理:
1 | JNIEXPORT jstring JNICALL |
输出结果:
1 | 11:07:52.786 E/JniClient-jni: sampleString, inputStr = abc你好 |
数组
JNI
中数组操作重要 API
:
GetIntArrayElements...
获取数组的元素,返回一个指针类型,表示指向一个数组GetArrayLength
获取数组长度- 使用完毕后,需要使用
ReleaseIntArrayElements
释放内存 NewCharArray
创建数组SetCharArrayRegion
拷贝内容到数组
先看 Java
端调用的代码:
1 | public native char[] samplesArray(int[] array); |
JNI
中的实现,先获取数组指针,再获取数组长度,遍历打印数组,最后释放内存:
1 | JNIEXPORT jcharArray JNICALL |
创建 Java
对象
JNI
中创建 Java
对象步骤:
- 根据
package/Classname
类名来查找对应类 - 查找构造方法
ID
,构造方法默认名称为<init>
- 根据构造方法
ID
和类来创建对象 - 对象使用完毕后,需要删除释放
查找类时,传递的参数不是类的全限定名,只需要包名和类名即可!
1 | // Constructor |
获取 Java
对象的属性和方法
和反射一样,public, private
等访问控制符并不生效,JNI
中可以获取 Java
对象所有的属性和方法,包含 private
修饰的。重要 API
:
GetFieldID
:获取属性ID
;参数需要使用类型签名GetMethodID
:获取方法ID
;参数需要使用方法的类型签名GetObjectField/GetIntField...
:获取属性对应的值,有多个基本类型变种CallVoidMethod/CallBooleanMethod...
:调用某个方法,根据返回值有多个基本类型变种
1 | // 获取和调用 public 的属性和方法 |
获取 Java
中 static
静态属性和方法
static
修饰的属性和方法是属于类的,所以在调用时,并不需要指定对象 jobject
;重要 API
:
GetStaticFieldID
:获取静态static
属性ID
;参数需要使用类型签名GetStaticMethodID
:获取静态static
方法ID
;参数需要使用方法的类型签名GetStaticObjectField/GetStaticIntField...
:获取静态属性的值,有多个基本类型的变种CallStaticVoidMethod/CallStaticIntMethod...
:调用静态方法,根据返回值有多个基本类型的变种
1 | // static field and method |
注意事项:
- 这里在获取字符串时,
jobject
可以直接强制转换为jstring
jstring
打印时,需要转换为char*
指针
JNI
中对 Java
多态的处理
Java
多态中的重载,可以通过方法签名(参数不一样)来区分;重写,通常情况下并不需要通过子类调用父类的重写方法,但是 JNI
中提供了该功能;重要 API
:
CallNonvirtualVoidMethod/CallNonvirtualIntMethod...
:在重写时,直接调用父类的方法
Java
示例代码,father.show
因为重写,输出结果为 Son, show:
:
1 | public class JniPolymorphic { |
JNI
中,可以通过 CallNonvirtualVoidMethod
来直接调用,输出 Father, show:
:
1 | // 构造 JniPolymorphic 对象 |
注意事项:
- 内部类的类名拼接时,需要使用美元符
$
Android NDK
概念
Android NDK
是一套允许使用C/C++
等语言,以原生代码实现部分应用的工具集,Google NDK 官网CMake
一款外部构建工具,可与Gradle
搭配使用来构建原生库;Android Studio
中已经使用Cmake
替换原来的ndk-build
LLDB
一种调试程序,Android Studio
使用它来调试原生代码
CPU
架构及 ABI
不同的 CPU
架构:ARMv5, ARMv7, x86, MIPS, ARMv8, MIPS64, x86_64
都关联着一个对应的 ABI
。ABI(Application Binary Interface)
决定了二进制文件如何与系统进行交互。CPU
对应的 ABI
有 armeabi, armeabiv7a, arm64v8a, x86, x86_64, mips, mips64
。Android NDK
支持的 ABI
,查看官网
armeabi
将创建以基于ARM v5
的设备为目标的库。使用软件浮点运算,可以在所有ARM
设备上运行;NDK
中已经弃用。armeabi-v7a
创建支持基于ARM v7
的设备的库,并将使用硬件FPU
指令,支持硬件浮点运算及高级扩展功能。与ARMv5、v6
设备不兼容。mips
是世界上很流行的一种RISC
处理器,其机制是尽量利用软件办法避免流水线中的数据相关问题;NDK
已弃用。x86
支持基于硬件的浮点运算的IA-32
指令集。x86
是可以兼容armeabi
平台运行的,无论是armeabi-v7a
还是armeabi
,同时带来的也是性能上的损耗。
当前现状,几个大厂的 APK
(微信,美团等) 都只使用了 armeabi-v7a
,可能是 Android
手机碎片化严重,为了能支持更多的设备。Gradle
中如果不指定,默认会生成所有架构对应的 so
,导致 apk
很大。这里可以参考大厂,只指定 armeabi-v7a
!在 defaultConfig
中增加如下代码:
1 | ndk{ |
Android Studio
设置快速生成 h 文件
这个方法是使用命令对 Java
中的 native
方法,手动生成对应头文件,非常方便。
对应填写的参数如下:
1 | $JDKPath$\bin\javah.exe |
搭建 JNI
编译环境
在 Android Studio
中搭建好 JNI
编译环境后,所有的 native
代码会自动生成对应的 JNI
代码。
- 新建
jni
目录Folder -> JNI Folder
,并创建JNI
对应的cpp
文件 - 新建
CMakeLists.txt
文件,可以拷贝一份模板并将cpp
文件和生成库的名称替换下 build.gradle
中添加CMakeLists.txt
路径,并重新sync
工程确保能正确编译Java
文件中添加native
方法,测试是否能自动生成对应的JNI
如果是新建Native
工程,则会自动添加;但是手动新建的,native
方法总是生成对应的.c
文件,需要拷贝到.cpp
中;如果使用了动态注册,则Android Studio
中对native
方法不再出现小红灯提示,这个时候只能手动生成头文件了。
C/C++
中的 LOG
打印
Cmake中
增加log
模块1
2target_link_libraries(hello-jni
log)C/C++
包含头文件#include <android/log.h>
自定义
LOG
输出1
2
((void)__android_log_print(ANDROID_LOG_INFO, "MyTag::", __VA_ARGS__))C/C++
代码中使用LOG
打印LOGI("sum %d", sum);
调试
JNI
代码出错后,会导致 crash
,根据 logcat
中 backtrace
输出的出错地址,来找到原始代码出错行。工具主要是 ndk
提供的 addr2line
和 ndk-stack
。
示例
出错的源码:
1 | Java_com_***_jni_JniClient_sampleReflectJNI( |
出错原因是 env->FindClass
时,找不到指定类 Lcom/***/jni/JniObject;
(FindClass
方法参数中类名不需要添加 L
和 ;
,直接使用 package/Classname
),在 logcat
中输出了如下错误信息:
1 | --------- beginning of crash |
也可以将 tombstone
取出来获取更详细的信息。
addr2line
工具
addr2line
工具在 ndk
的交叉编译工具链中,根据当前调试手机平台选择具体的交叉编译工具,示例代码使用的是 armeabi
:ndk-bundle\toolchains\arm-linux-androideabi-4.9\prebuilt\windows-x86_64\bin
,找到 arm-linux-androideabi-addr2line.exe
文件,它就是 addr2line
工具。
命令格式: addr2line -e so文件 出错地址
;上面示例中,so
文件为 libjni-sample.so
,这个库文件在 Android Studio
的 app\build\intermediates\cmake\debug\obj\armeabi-v7a\
目录下;错误地址是 0000812d
,完整命令如下:
1 | C:\**\ndk-bundle\toolchains\arm-linux-androideabi-4.9\prebuilt\windows-x86_64\bin>arm-linux-androideabi-addr2line.exe -e E:\***\app\build\intermediates\cmake\debug\obj\armeabi-v7a\libjni-sample.so 0x0000812d |
得到的解析结果为源码 JniClient.cpp
的第 26 行错误。
ndk-stack
工具
ndk-stack
工具在 ndk
的当前目录 $NDK_HOME
下,是一个脚本文件 ndk-stack.cmd
,不需要指定交叉编译版本,取出 tombstone
文件后,使用 ndk-stack
自动解析出错误行。
命令格式: ndk-stack -sym so所在文件目录 -dump tobstone > 1.txt
;上面示例中,不需要指定 so
文件名,只需要支持所在目录就行;完整命令如下:
1 | C:\***\ndk-bundle\ndk-stack.cmd -sym E:\***\app\build\intermediates\cmake\debug\obj\armeabi-v7a\ -dump tombstone_05 > 1.txt |
我们打开 1.txt
文件,可以查看到解析的结果:
1 | ********** Crash dump: ********** |
得到了同样的解析结果: JniClient.cpp
的第 26 行错误。
其他
JNI
默认对应的是C
代码,所以在C++
代码中需要做如下声明:extern "C"
Android
中jni.h
定义的函数,都是在art/runtime/jni_internal.cc
中实现的
参考文档
- Oracle: Java 8 JNI 官网
- Goolge: Android JNI 官网
- Google: NDK 官网
- 隔壁老李头:Android JNI
- Unicode 与 utf8 utf16 utf32的关系
- 字符、编码和Java中的编码
- C–中文汉字占用字节长度
- JVM Utf8
- JNI中引用类型
- 在JNI中使用引用
- JavaVM和 JNIEnv 动态注册本地方法
- NewString 与 NewStringUtf 解析
- NI数据类型与方法属性访问
- Android Studio 中添加 C/C++ 代码
- Android Studio NDK快速生成.h头文件
- 小楠总: Android NDK开发之旅