开始之前,可以先提几个问题
- JNI的本质是什么,java和native如何实现互操作?
JNI静态注册与动态注册可以混用吗?
首先是理论部分,先弄清楚NDK与JNI的概念。
NDK与JNI
NDK全称为Native Development Kit,是一套允许使用 C 和 C++ 等语言,以原生代码实现应用的工具集,包括但不限于编译工具、公共库等。
在Android NDK开发中,应用层就是借助JNI以实现对本地代码的调用。不只是应用层,JNI技术也广泛应用在Framework层中,主要源码在 framework/base/
目录下
下图简单地从另一个角度看Android的系统架构,其中,JNI接口的实现在Android虚拟机,宿主环境包括Linux kernel。
JNI技术
JNI是什么
JNI,全称Java Native Interface,是一种编程框架,使得Java虚拟机中的Java程序与本地应用/或库可以相互调用。本地程序一般是用其它语言(C、C++或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序。为了通俗地介绍JNI的作用,摘抄网上的一个比喻,就是中国讲普通话,日本讲日语,我们无法交流,如果此时我们都学会了英语,就可以用英语交流了。JNI就是java和本地语言之间交互的媒介。
上图,我们可以看到JNI接口的具体实现是在虚拟机。历史上存在过不同本地方法接口的实现,使得开发者不得不编写和维护多种不同的版本。JNI的目标就是提供一套统一的,考虑充分的标准接口,使得开发者可以只写一个版本的本地代码,在不同的Java虚拟机上运行,也就是实现虚拟机无关性。
应用场景
- 实现Java库无法提供的基于平台系统相关特性的功能
- 直接复用现有的本地开源库
- 使用C/C++开发对时间和性能要求很高的逻辑,如音视频,图片处理,游戏逻辑等
- 保护关键代码,增大反编译难度
- ...
优势与劣势
如上的应用场景就是使用JNI的优势,当然,任何技术都会在解决一个问题的同时带来其他新的问题,使用JNI也会有劣势:
- 使用JNI编程时,容易操作不当会引起崩溃。相比Java崩溃,Native崩溃难以捕获与定位
- 可能引起内存泄漏等
- JNI上下文环境切换开销
其他方案对比
有些替代方案可以允许Java与其他语言编写的代码进行互操作,例如:
- Java应用程序可以通过TCP/IP连接或者其他IPC通信机制与本地应用进行通信
- 可以利用分布式对象技术,如Java IDL API ...
但是,以上方案的共性是将Java应用程序和原生代码隔离在不同的进程空间中。而JNI技术是在相同的进程空间。使用其他替代方案的问题在于,进程间操作麻烦且低效,复制和传输数据会增加额外开销。
通过上文对JNI技术有了比较清晰的认识之后,我们再看看实战部分。
NDK/JNI开发流程
在Android Studio中,我们可以直接新建一个Native C++工程,用kotlin实现一个最简单的JNI调用。
- Java层声明Native方法
package com.devnan.ndk
class MainActivity : AppCompatActivity() {
external fun stringFromJNI(): String
...
}
- JNI层实现Java层声明的方法, 可以调用底层库或者回调Java层方法
//native-lib.cpp
extern "C" JNIEXPORT jstring JNICALLJava_com_devnan_ndk_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
- Java层加载编译后生成的动态库
// MainActivity
companion object {
init {
System.loadLibrary("native-lib")
}
}
- 定义cmake脚本,编译C/C++源码生成动态库so文件
cmake_minimum_required(VERSION 3.4.1)
add_library(native-lib SHARED native-lib.cpp)
虽然IDE帮我们做了大部分事情,但是还是要明白整个过程的本质。Android NDK开发中,在编译阶段,cmake会根据CMakeLists定义的规则,借助NDK工具包的交叉编译链将C/C++代码编译生成动态库so文件(当然也可以是静态库),最后gradle会通过package task将so一起打包到APK中,这是IDE以及gradle帮我们做的。另外,在安装阶段,Android系统安装apk时会通过PackageManagerService 将apk lib 目录下的 so 文件会被解压到对应apk目录,一般在 /data/app/package-name-xxx/lib 目录;在运行阶段,调用 System.loadLibrary会首先在对应apk目录下查找so,找不到才在系统目录下查找。这里涉及到so的拷贝和加载策略,详细分析会放到后续文章。
JNI数据类型
由于Java语言与C/C++语言数据类型的不匹配,需要单独定义一系列的数据类型转换关系来完成两者之间的映射。
基本数据类型:在NDK包的jni.h文件可以看到定义
//$NDK/sysroot/usr/include/jni.h
/* Primitive types that match up with Java equivalents. */
typedef uint8_t jboolean; /* unsigned 8 bits */
typedef int8_t jbyte; /* signed 8 bits */
typedef uint16_t jchar; /* unsigned 16 bits */
typedef int16_t jshort; /* signed 16 bits */
typedef int32_t jint; /* signed 32 bits */
typedef int64_t jlong; /* signed 64 bits */
typedef float jfloat; /* 32-bit IEEE 754 */
typedef double jdouble; /* 64-bit IEEE 754 */
引用数据类型,详细可以查看jni.h
JNI签名规则
由于Java支持方法重载,在JNI访问Java层方法时仅靠函数名无法唯一确定一个方法,因此JNI提供了一套签名规则,以使用一个字符串来唯一确定一个方法。其规则:(参数1类型签名参数2类型签名…)返回值类型签名,比如:
native方法: external fun stringFromJNI(bool:Boolean, str: String): String
类型签名: (ZLjava/lang/String;)Ljava/lang/String;
建议对class文件使用javap -s -p
拿到签名信息,毕竟手写容易出错。
JavaVM与JNIEnv
JNI技术中,native层怎么调用到java层呢?就是利用JavaVM 和 JNIEnv 这两个结构体。可以说,他们就是native通往java世界大门的钥匙。
JavaVM是进程虚拟机环境,每个进程有且只有一个JavaVM实例。Android应用进程启动时在AndroidRuntime.cpp的start()方法完成JavaVM的启动。
JNIEnv是线程上下文环境,每个线程有且只有一个JNIEnv实例,可以看到每个JNI方法的第一个参数就是JNIEnv。
同样,来看一下JavaVM在jni.h的定义
#if defined(__cplusplus)
typedef _JNIEnv JNIEnv;
typedef _JavaVM JavaVM;
#else
typedef const struct JNINativeInterface* JNIEnv;
typedef const struct JNIInvokeInterface* JavaVM;
#endif
struct _JavaVM {
const struct JNIInvokeInterface* functions;
#if defined(__cplusplus)
jint DestroyJavaVM(){ return functions->DestroyJavaVM(this); }
jint AttachCurrentThread(JNIEnv** p_env, void* thr_args){ return functions->AttachCurrentThread(this, p_env, thr_args); }
jint DetachCurrentThread(){ return functions->DetachCurrentThread(this); }
jint GetEnv(void** env, jint version){ return functions->GetEnv(this, env, version); }
jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args){ return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); }
#endif /*__cplusplus*/
};
以上可以看到,JavaVM的所有操作都是由结构体 JNIInvokeInterface 实现的。
struct JNIInvokeInterface {
void* reserved0;
void* reserved1;
void* reserved2;
jint (*DestroyJavaVM)(JavaVM*);
jint (*AttachCurrentThread)(JavaVM*, JNIEnv**, void*);
jint (*DetachCurrentThread)(JavaVM*);
jint (*GetEnv)(JavaVM*, void**, jint);
jint (*AttachCurrentThreadAsDaemon)(JavaVM*, JNIEnv**, void*);
};
前三个指针作为保留使用,后面为函数指针。根据函数名可以推测大概的用法,其中,AttachCurrentThread 函数可以把当前线程附着到虚拟机,getEnv 函数用来获取 JNIEnv 指针。JavaVM的开发使用场景是,若无法获取到线程的JNIEnv,比如C/C++函数回调到Java层,可以全局保存JavaVM,通过AttachCurrentThread将当前线程附着到 JavaVM 以拿到 JNIEnv,最后记得调用 DetachCurrentThread 函数,否则会发生内存泄露。
同理,也可以定位到JNIEnv最后由结构体JNINativeInterface实现。
struct JNINativeInterface {
...
jint (*GetVersion)(JNIEnv *);
jclass (*DefineClass)(JNIEnv*, const char*, jobject, const jbyte*,
jsize);
jclass (*FindClass)(JNIEnv*, const char*);
...
};
可以看到,JNINativeInterface包含了JNI编程中经常用到的JNI函数,可以说JNIEnv就是我们操作 Java 层的入口。篇幅有限,由于JNIEnv相关函数过多,不在此处详细描述。如果对Android ART虚拟机中JNI接口的实现感兴趣,可以看看 platform/art/runtime/jni
目录,JNIEnv函数实现应该在jni_internal.cc
文件。
JNI静态注册与动态注册
JNI技术中,java层怎么找到native方法呢?就是利用了JNI静态注册或动态注册。
顾名思义,静态注册,是根据函数名来建立Java方法和JNI方法间的对应关系。上文例子中,Java层 com.devnan.ndk.MainActivity
的native方法 stringFromJNI ,对应的JNI方法是JNIEXPORT jstring JNICALL Java_com_devnan_ndk_MainActivity_stringFromJNI
,即JNI规则是Java_包名_类名_方法名。Android应用层一般采用这种方式进行静态注册。其中,JNIEXPORT和JNICALL是两个宏定义。default表示外部可见,类似public,JNICALL在Linux平台只是一个空定义。
#define JNIEXPORT __attribute__ ((visibility ("default")))
#define JNICALL
而动态注册原理是,在 Java 层调用 System.loadLibrary
时,底层最后会调用 JNI_OnLoad ()
函数。在这个函数中通过JNIEnv 提供的 RegisterNatives()
方法,我们可以传递 JNINativeMethod 数组主动建立native方法和JNI方法的映射关系。JNINativeMethod在jni.h的定义如下:typedef struct { const char* name; //native方法的方法名 const char* signature; //native方法的签名 void* fnPtr; //JNI层对应实现的方法指针 } JNINativeMethod;
对应上文例子就是
//native-lib.h
const char *className = "com/devnan/ndk/MainActivity";
const JNINativeMethod gMethods[] = {
/* name, signature, funcPtr */
{"stringFromJNI", "()Ljava/lang/String;", (void *) stringFromJNI},
};
//native-lib.cpp
```C++
/**
* JNI方法随意起名为"stringFromJNI"
*/
JNIEXPORT jstring JNICALL stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello JNI!";
return env->NewStringUTF(hello.c_str());
}
/**
* System.loadLibrary()最后会调用到JNI_OnLoad
*/
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env = NULL;
if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
assert(env != NULL);
if (!registerNatives(env)) {
return JNI_ERR;
}
return JNI_VERSION_1_6;
}
/**
* 调用JNIEnv函数RegisterNatives进行注册
*
* jint RegisterNatives(jclass clazz, const JNINativeMethod* methods, jint nMethods)
* clazz: java类名,通过FindClass得到
* methods: JNINativeMethod结构体指针
* nMethods: 方法个数
*/
static int registerNatives(JNIEnv *env) {
jclass clazz;
clazz = env->FindClass(className);
if (clazz == NULL) {
return JNI_FALSE;
}
if (env->RegisterNatives(clazz, gMethods, NELEM(gMethods)) < 0) {
return JNI_FALSE;
}
return JNI_TRUE;
}
可以看到,JNI动态注册比静态注册要麻烦,但是,动态注册效率更快。Android Framework层几乎都是动态注册,Android系统在启动时会执行一些系统函数的JNI注册,从而把映射关系注册到了虚拟机中。init进程启动zygote进程后, 在AndroidRuntime中会先启动虚拟机,然后开始注册JNI方法。简要流程如下:
//frameworks/base/cmds/app_process/app_main.cpp
- zygote进程启动(App_main.cpp执行main函数)
- AndroidRuntime.start
- startVm //启动虚拟机
- startReg //注册JNI函数
- register_jni_procs //遍历RegJNI数组进行注册
小结:
对比动态注册,静态注册的优点是使用简单明了,但是缺点是首次调用时需要Java虚拟机搜索JNI方法以建立映射关系,第一次的运行效率低。另外,静态注册和动态注册可以混用,也就是一部分native方法用静态注册,另一部分用动态注册。思考一下,如果一个native方法同时做了静态注册和动态注册会发生什么情况?那么最后调用的是动态注册的JNI方法的实现,因为 System.loadLibrary("native-lib")
是一般放在static代码块,也就是在类初始化阶段中,通过动态注册已经在虚拟机保存了JNI的映射关系(kotlin的写法本质也一样),而静态注册是第一次调用native方法时才建立这种映射。
总结
本文主要介绍了JNI技术的背景、应用场景和优缺点等,并结合 Android 介绍了JNI的开发流程,JNI数据类型和签名规则,JavaVM与JNIEnv的概念,以及JNI静态注册和动态注册的用法和区别。代码已放到 https://github.com/devnan/ndk-practices
您的鼓励就是我最大的动力,欢迎交流,欢迎点个"在看"