NDK 系列(5):JNI 从入门到实践,万字爆肝详解!(上)

简介: NDK 系列(5):JNI 从入门到实践,万字爆肝详解!

前言


  • 在 Android 生态中主要有 C/C++、Java、Kotlin 三种语言 ,它们的关系不是替换而是互补。其中,C/C++ 的语境是算法和高性能,Java 的语境是平台无关和内存管理,而 Kotlin 则融合了多种语言中的优秀特性,带来了一种更现代化的编程方式;
  • JNI 是实现 Java 代码与 C/C++ 代码交互的特性, 思考一个问题 —— Java 虚拟机是如何实现两种毫不相干的语言的交互的呢? 今天,我们来全面总结 JNI 开发知识框架,为 NDK 开发打下基础。本文部分演示代码可以从 DemoHall·HelloJni 下载查看。


JNI 学习路线图:


image.png

1. 认识 JNI


1.1 为什么要使用 JNI?


JNI(Java Native Interface,Java 本地接口)是 Java 生态的特性,它扩展了 Java 虚拟机的能力,使得 Java 代码可以与 C/C++ 代码进行交互。 通过 JNI 接口,Java 代码可以调用 C/C++ 代码,C/C++ 代码也可以调用 Java 代码。


这就引出第 1 个问题(为什么要这么做):Java 为什么要调用 C/C++ 代码,而不是直接用 Java 开发需求呢?我认为主要有 4 个原因:


  • 原因 1 - Java 天然需要 JNI 技术: 虽然 Java 是平台无关性语言,但运行 Java 语言的虚拟机是运行在具体平台上的,所以 Java 虚拟机是平台相关的。因此,对于调用平台 API 的功能(例如打开文件功能,在 Window 平台是 openFile 函数,而在 Linux 平台是 open 函数)时,虽然在 Java 语言层是平台无关的,但背后只能通过 JNI 技术在 Native 层分别调用不同平台 API。类似的,对于有操作硬件需求的程序,也只能通过 C/C++ 实现对硬件的操作,再通过 JNI 调用;
  • 原因 2 - Java 运行效率不及 C/C++: Java 代码的运行效率相对于 C/C++ 要低一些,因此,对于有密集计算(例如实时渲染、音视频处理、游戏引擎等)需求的程序,会选择用 C/C++ 实现,再通过 JNI 调用;
  • 原因 3 - Native 层代码安全性更高: 反编译 so 文件的难度比反编译 Class 文件高,一些跟密码相关的功能会选择用 C/C++ 实现,再通过 JNI 调用;
  • 原因 4 - 复用现有代码: 当 C/C++ 存在程序需要的功能时,则可以直接复用。


还有第 2 个问题(为什么可以这么做):为什么两种独立的语言可以实现交互呢?因为 Java 虚拟机本身就是 C/C++ 实现的,无论是 Java 代码还是 C/C++ 代码,最终都是由这个虚拟机支撑,共同使用一个进程空间。JNI 要做的只是在两种语言之间做桥接。


image.png

1.2 JNI 开发的基本流程


一个标准的 JNI 开发流程主要包含以下步骤:


  • 1、创建 HelloWorld.java,并声明 native 方法 sayHi();
  • 2、使用 javac 命令编译源文件,生成 HelloWorld.class 字节码文件;
  • 3、使用 javah 命令导出 HelloWorld.h 头文件(头文件中包含了本地方法的函数原型);
  • 4、在源文件 HelloWorld.cpp 中实现函数原型;
  • 5、编译本地代码,生成 Hello-World.so 动态原生库文件;
  • 6、在 Java 代码中调用 System.loadLibrary(...) 加载 so 文件;
  • 7、使用 Java 命令运行 HelloWorld 程序。


该流程用示意图表示如下:


image.png

1.3 JNI 的性能误区


JNI 本身本身并不能解决性能问题,错误地使用 JNI 反而可能引入新的性能问题,这些问题都是要注意的:


  • 问题 1 - 跨越 JNI 边界的调用: 从 Java 调用 Native 或从 Native 调用 Java 的成本很高,使用 JNI 时要限制跨越 JNI 边界的调用次数;
  • 问题 2 - 引用类型数据的回收: 由于引用类型数据(例如字符串、数组)传递到 JNI 层的只是一个指针,为避免该对象被垃圾回收虚拟机会固定住(pin)对象,在 JNI 方法返回前会阻止其垃圾回收。因此,要尽量缩短 JNI 调用的执行时间,它能够缩短对象被固定的时间(关于引用类型数据的处理,在下文会说到)。


1.4 注册 JNI 函数的方式


Java 的 native 方法和 JNI 函数是一一对应的映射关系,建立这种映射关系的注册方式有 2 种:


  • 方式 1 - 静态注册: 基于命名约定建立映射关系;
  • 方式 2 - 动态注册: 通过 JNINativeMethod 结构体建立映射关系。

关于注册 JNI 函数的更多原理分析,见 注册 JNI 函数


1.5 加载 so 库的时机


so 库需要在运行时调用 System.loadLibrary(…) 加载,一般有 2 种调用时机:

  • 1、在类静态初始化中: 如果只在一个类或者很少类中使用到该 so 库,则最常见的方式是在类的静态初始化块中调用;
  • 2、在 Application 初始化时调用: 如果有很多类需要使用到该 so 库,则可以考虑在 Application 初始化等场景中提前加载。


关于加载 so 库的更多原理分析,见 so 文件加载过程分析


2. JNI 模板代码


本节我们通过一个简单的 HelloWorld 程序来帮助你熟悉 JNI 的模板代码。

JNI Demo


JNIEXPORT void JNICALL Java_com_xurui_hellojni_HelloWorld_sayHi (JNIEnv *, jobject);
复制代码


2.1 JNI 函数名


为什么 JNI 函数名要采用  Java_com_xurui_HelloWorld_sayHi 的命名方式呢?—— 这是 JNI 函数静态注册约定的函数命名规则。Java 的 native 方法和 JNI 函数是一一对应的映射关系,而建立这种映射关系的注册方式有 2 种:静态注册 + 动态注册。

其中,静态注册是基于命名约定建立的映射关系,一个 Java 的 native 方法对应的 JNI 函数会采用约定的函数名,即 Java_[类的全限定名 (带下划线)]_[方法名] 。JNI 调用 sayHi() 方法时,就会从 JNI 函数库中寻找函数 Java_com_xurui_HelloWorld_sayHi(),更多内容见 注册 JNI 函数


2.2 关键词 JNIEXPORT


JNIEXPORT 是宏定义,表示一个函数需要暴露给共享库外部使用时。JNIEXPORT 在 Window 和 Linux 上有不同的定义:


jni.h


// Windows 平台 :
#define JNIEXPORT __declspec(dllexport)
#define JNIIMPORT __declspec(dllimport)
// Linux 平台:
#define JNIIMPORT
#define JNIEXPORT  __attribute__ ((visibility ("default")))
复制代码


2.3 关键词 JNICALL


JNICALL 是宏定义,表示一个函数是 JNI 函数。JNICALL 在 Window 和 Linux 上有不同的定义:


jni.h


// Windows 平台 :
#define JNICALL __stdcall // __stdcall 是一种函数调用参数的约定 ,表示函数的调用参数是从右往左。
// Linux 平台:
#define JNICALL
复制代码


2.4 参数 jobject


jobject 类型是 JNI 层对于 Java 层应用类型对象的表示。每一个从 Java 调用的 native 方法,在 JNI 函数中都会传递一个当前对象的引用。区分 2 种情况:


  • 1、静态 native 方法: 第二个参数为 jclass 类型,指向 native 方法所在类的 Class 对象;
  • 2、实例 native 方法: 第二个参数为 jobject 类型,指向调用 native 方法的对象。


2.5 JavaVM 和 JNIEnv 的作用


JavaVMJNIEnv 是定义在 jni.h 头文件中最关键的两个数据结构:


  • JavaVM: 代表 Java 虚拟机,每个 Java 进程有且仅有一个全局的 JavaVM 对象,JavaVM 可以跨线程共享;
  • JNIEnv: 代表 Java 运行环境,每个 Java 线程都有各自独立的 JNIEnv 对象,JNIEnv 不可以跨线程共享。


JavaVM 和 JNIEnv 的类型定义在 C 和 C++ 中略有不同,但本质上是相同的,内部由一系列指向虚拟机内部的函数指针组成。 类似于 Java 中的 Interface 概念,不同的虚拟机实现会从它们派生出不同的实现类,而向 JNI 层屏蔽了虚拟机内部实现(例如在 Android ART 虚拟机中,它们的实现分别是 JavaVMExt 和 JNIEnvExt)。

image.png


jni.h

struct _JNIEnv;
struct _JavaVM;
#if defined(__cplusplus)
// 如果定义了 __cplusplus 宏,则按照 C++ 编译
typedef _JNIEnv JNIEnv;
typedef _JavaVM JavaVM;
#else
// 按照 C 编译
typedef const struct JNINativeInterface* JNIEnv;
typedef const struct JNIInvokeInterface* JavaVM;
#endif
/*
 * C++ 版本的 _JavaVM,内部是对 JNIInvokeInterface* 的包装
 */
struct _JavaVM {
    // 相当于 C 版本中的 JNIEnv
    const struct JNIInvokeInterface* functions;
    // 转发给 functions 代理
    jint DestroyJavaVM()
    { return functions->DestroyJavaVM(this); }
    ...
};
/*
 * C++ 版本的 JNIEnv,内部是对 JNINativeInterface* 的包装
 */
struct _JNIEnv {
    // 相当于 C 版本的 JavaVM
    const struct JNINativeInterface* functions;
    // 转发给 functions 代理
    jint GetVersion()
    { return functions->GetVersion(this); }
    ...
};
复制代码


可以看到,不管是在 C 语言中还是在 C++ 中,JNINativeInterface*JNINativeInterface* 这两个结构体指针才是 JavaVM 和 JNIEnv 的实体。不过 C++ 中加了一层包装,在语法上更简洁,例如:

示例程序


// 在 C 语言中,要使用 (*env)->
// 注意看这一句:typedef const struct JNINativeInterface* JNIEnv;
(*env)->FindClass(env, "java/lang/String");
// 在 C++ 中,要使用 env->
// 注意看这一句:jclass FindClass(const char* name)
//{ return functions->FindClass(this, name); }
env->FindClass("java/lang/String");
复制代码


后文提到的大量 JNI 函数,其实都是定义在 JNINativeInterface 和 JNINativeInterface 内部的函数指针。

jni.h


/*
 * JavaVM
 */
struct JNIInvokeInterface {
    // 一系列函数指针
    jint        (*DestroyJavaVM)(JavaVM*);
    jint        (*AttachCurrentThread)(JavaVM*, JNIEnv**, void*);
    jint        (*DetachCurrentThread)(JavaVM*);
    jint        (*GetEnv)(JavaVM*, void**, jint);
    jint        (*AttachCurrentThreadAsDaemon)(JavaVM*, JNIEnv**, void*);
};
/*
 * JNIEnv
 */
struct JNINativeInterface {
    // 一系列函数指针
    jint        (*GetVersion)(JNIEnv *);
    jclass      (*DefineClass)(JNIEnv*, const char*, jobject, const jbyte*, jsize);
    jclass      (*FindClass)(JNIEnv*, const char*);
    ...
};
复制代码



3. 数据类型转换


这一节我们来讨论 Java 层与 Native 层之间的数据类型转换。


3.1 Java 类型映射(重点理解)


JNI 对于 Java 的基础数据类型(int 等)和引用数据类型(Object、Class、数组等)的处理方式不同。这个原理非常重要,理解这个原理才能理解后面所有 JNI 函数的设计思路:


  • 基础数据类型: 会直接转换为 C/C++ 的基础数据类型,例如 int 类型映射为 jint 类型。由于 jint 是 C/C++ 类型,所以可以直接当作普通 C/C++ 变量使用,而不需要依赖 JNIEnv 环境对象;
  • 引用数据类型: 对象只会转换为一个 C/C++ 指针,例如 Object 类型映射为 jobject 类型。由于指针指向 Java 虚拟机内部的数据结构,所以不可能直接在 C/C++ 代码中操作对象,而是需要依赖 JNIEnv 环境对象。另外,为了避免对象在使用时突然被回收,在本地方法返回前,虚拟机会固定(pin)对象,阻止其 GC。


另外需要特别注意一点,基础数据类型在映射时是直接映射,而不会发生数据格式转换。例如,Java char 类型在映射为 jchar 后旧是保持 Java 层的样子,数据长度依旧是 2 个字节,而字符编码依旧是 UNT-16 编码。


具体映射关系都定义在 jni.h 头文件中,文件摘要如下:

jni.h


typedef uint8_t  jboolean; /* unsigned 8 bits */
typedef int8_t   jbyte;    /* signed 8 bits */
typedef uint16_t jchar;    /* unsigned 16 bits */ /* 注意:jchar 是 2 个字节 */
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 */
typedef jint     jsize;
#ifdef __cplusplus
// 内部的数据结构由虚拟机实现,只能从虚拟机源码看
class _jobject {};
class _jclass : public _jobject {};
class _jstring : public _jobject {};
class _jarray : public _jobject {};
class _jobjectArray : public _jarray {};
class _jbooleanArray : public _jarray {};
...
// 说明我们接触到到 jobject、jclass 其实是一个指针
typedef _jobject*       jobject;
typedef _jclass*        jclass;
typedef _jstring*       jstring;
typedef _jarray*        jarray;
typedef _jobjectArray*  jobjectArray;
typedef _jbooleanArray* jbooleanArray;
...
#else /* not __cplusplus */
...
#endif /* not __cplusplus */
复制代码


我将所有 Java 类型与 JNI 类型的映射关系总结为下表:


Java 类型 JNI 类型 描述 长度(字节)
boolean jboolean unsigned char 1
byte jbyte signed char 1
char jchar unsigned short 2
short jshort signed short 2
int jint、jsize signed int 4
long jlong signed long 8
float jfloat signed float 4
double jdouble signed double 8
Class jclass Class 类对象 1
String jstrting 字符串对象 /
Object jobject 对象 /
Throwable jthrowable 异常对象 /
boolean[] jbooleanArray 布尔数组 /
byte[] jbyteArray byte 数组 /
char[] jcharArray char 数组 /
short[] jshortArray short 数组 /
int[] jinitArray int 数组 /
long[] jlongArray long 数组 /
float[] jfloatArray float 数组 /
double[] jdoubleArray double 数组 /

3.2 字符串类型操作


上面提到 Java 对象会映射为一个 jobject 指针,那么 Java 中的 java.lang.String 字符串类型也会映射为一个 jobject 指针。可能是因为字符串的使用频率实在是太高了,所以 JNI 规范还专门定义了一个 jobject 的派生类 jstring 来表示 Java String 类型,这个相对特殊。


jni.h


// 内部的数据结构还是看不到,由虚拟机实现
class _jstring : public _jobject {};
typedef _jstring*       jstring;
struct JNINativeInterface {
    // String 转换为 UTF-8 字符串
    const char* (*GetStringUTFChars)(JNIEnv*, jstring, jboolean*);
    // 释放 GetStringUTFChars 生成的 UTF-8 字符串
    void        (*ReleaseStringUTFChars)(JNIEnv*, jstring, const char*);
    // 构造新的 String 字符串
    jstring     (*NewStringUTF)(JNIEnv*, const char*);
    // 获取 String 字符串的长度
    jsize       (*GetStringUTFLength)(JNIEnv*, jstring);
    // 将 String 复制到预分配的 char* 数组中
    void        (*GetStringUTFRegion)(JNIEnv*, jstring, jsize, jsize, char*);
};
复制代码


由于 Java 与 C/C++ 默认使用不同的字符编码,因此在操作字符数据时,需要特别注意在 UTF-16 和 UTF-8 两种编码之间转换。关于字符编码,我们在 Unicode 和 UTF-8是什么关系?  这篇文章里讨论过,这里就简单回顾一下:


  • Unicode: 统一化字符编码标准,为全世界所有字符定义统一的码点,例如 U+0011;
  • UTF-8: Unicode 标准的实现编码之一,使用 1~4 字节的变长编码。UTF-8 编码中的一字节编码与 ASCII 编码兼容。
  • UTF-16: Unicode 标准的实现编码之一,使用 2 / 4 字节的变长编码。UTF-16 是 Java String 使用的字符编码;
  • UTF-32: Unicode 标准的实现编码之一,使用 4 字节定长编码。

以下为 2 种较为常见的转换场景:

  • 1、Java String 对象转换为 C/C++ 字符串: 调用 GetStringUTFChars 函数将一个 jstring 指针转换为一个 UTF-8 的 C/C++ 字符串,并在不再使用时调用 ReleaseStringChars 函数释放内存;
  • 2、构造 Java String 对象: 调用 NewStringUTF 函数构造一个新的 Java String 字符串对象。


我们直接看一段示例程序:


示例程序

// 示例 1:将 Java String 转换为 C/C++ 字符串
jstring jStr = ...; // Java 层传递过来的 String
const char *str = env->GetStringUTFChars(jStr, JNI_FALSE);
if(!str) {
    // OutOfMemoryError
    return;
}
// 释放 GetStringUTFChars 生成的 UTF-8 字符串
env->ReleaseStringUTFChars(jStr, str);
// 示例 2:构造 Java String 对象(将 C/C++ 字符串转换为 Java String)
jstring newStr = env->NewStringUTF("在 Native 层构造 Java String");
if (newStr) {
    // 通过 JNIEnv 方法将 jstring 调用 Java 方法(jstring 本身就是 Java String 的映射,可以直接传递到 Java 层)
    ...
}
复制代码


此处对 GetStringUTFChars 函数的第 3 个参数 isCopy 做解释:它是一个布尔值参数,将决定使用拷贝模式还是复用模式:


  • 1、JNI_TRUE: 使用拷贝模式,JVM 将拷贝一份原始数据来生成 UTF-8 字符串;
  • 2、JNI_FALSE: 使用复用模式,JVM 将复用同一份原始数据来生成 UTF-8 字符串。复用模式绝不能修改字符串内容,否则 JVM 中的原始字符串也会被修改,打破 String 不可变性。


另外还有一个基于范围的转换函数:GetStringUTFRegion:预分配一块字符数组缓冲区,然后将 String 数据复制到这块缓冲区中。由于这个函数本身不会做任何内存分配,所以不需要调用对应的释放资源函数,也不会抛出 OutOfMemoryError。另外,GetStringUTFRegion 这个函数会做越界检查并抛出 StringIndexOutOfBoundsException 异常。

示例程序


jstring jStr = ...; // Java 层传递过来的 String
char outbuf[128];
int len = env->GetStringLength(jStr);
env->GetStringUTFRegion(jStr, 0, len, outbuf);
复制代码


3.3 数组类型操作


与 jstring 的处理方式类似,JNI 规范将 Java 数组定义为 jobject 的派生类 jarray


  • 基础类型数组:定义为 jbooleanArrayjintArray 等;
  • 引用类型数组:定义为 jobjectArray

下面区分基础类型数组和引用类型数组两种情况:


操作基础类型数组(以 jintArray 为例):


  • 1、Java 基本类型数组转换为 C/C++ 数组: 调用 GetIntArrayElements 函数将一个 jintArray 指针转换为 C/C++ int 数组;
  • 2、修改 Java 基本类型数组: 调用 ReleaseIntArrayElements 函数并使用模式 0;
  • 3、构造 Java 基本类型数组: 调用 NewIntArray 函数构造 Java int 数组。

我们直接看一段示例程序:


示例程序


extern "C"
JNIEXPORT jintArray JNICALL
Java_com_xurui_hellojni_HelloWorld_generateIntArray(JNIEnv *env, jobject thiz, jint size) {
    // 新建 Java int[]
    jintArray jarr = env->NewIntArray(size);
    // 转换为 C/C ++ int[]
    int *carr = env->GetIntArrayElements(jarr, JNI_FALSE);
    // 赋值
    for (int i = 0; i < size; i++) {
        carr[i] = i;
    }
    // 释放资源并回写
    env->ReleaseIntArrayElements(jarr, carr, 0);
    // 返回数组
    return jarr;
}
复制代码


此处重点对 ReleaseIntArrayElements 函数的第 3 个参数 mode 做解释:它是一个模式参数:


参数 mode 描述
0 将 C/C++ 数组的数据回写到 Java 数组,并释放 C/C++ 数组
JNI_COMMIT 将 C/C++ 数组的数据回写到 Java 数组,并不释放 C/C++ 数组
JNI_ABORT 不回写数据,但释放 C/C++ 数组


另外 JNI 还提供了基于范围函数:GetIntArrayRegionSetIntArrayRegion,使用方法和注意事项和 GetStringUTFRegion 也是类似的,也是基于一块预分配的数组缓冲区。


操作引用类型数组(jobjectArray):


  • 1、将 Java 引用类型数组转换为 C/C++ 数组: 不支持!与基本类型数组不同,引用类型数组的元素 jobject 是一个指针,不存在转换为 C/C++ 数组的概念;
  • 2、修改 Java 引用类型数组: 调用 SetObjectArrayElement 函数修改指定下标元素;
  • 3、构造 Java 引用类型数组: 先调用 FindClass 函数获取 Class 对象,再调用 NewObjectArray 函数构造对象数组。


我们直接看一段示例程序:

示例程序


extern "C"
JNIEXPORT jobjectArray JNICALL
Java_com_xurui_hellojni_HelloWorld_generateStringArray(JNIEnv *env, jobject thiz, jint size) {
    // 获取 String Class
    jclass jStringClazz = env->FindClass("java/lang/String");
    // 初始值(可为空)
    jstring initialStr = env->NewStringUTF("初始值");
    // 创建 Java String[]
    jobjectArray jarr = env->NewObjectArray(size, jStringClazz, initialStr);
    // 赋值
    for (int i = 0; i < size; i++) {
        char str[5];
        sprintf(str, "%d", i);
        jstring jStr = env->NewStringUTF(str);
        env->SetObjectArrayElement(jarr, i, jStr);
    }
    // 返回数组
    return jarr;
}



目录
相关文章
|
3月前
|
Java Android开发 C++
🚀Android NDK开发实战!Java与C++混合编程,打造极致性能体验!📊
在Android应用开发中,追求卓越性能是不变的主题。本文介绍如何利用Android NDK(Native Development Kit)结合Java与C++进行混合编程,提升应用性能。从环境搭建到JNI接口设计,再到实战示例,全面展示NDK的优势与应用技巧,助你打造高性能应用。通过具体案例,如计算斐波那契数列,详细讲解Java与C++的协作流程,帮助开发者掌握NDK开发精髓,实现高效计算与硬件交互。
166 1
|
Linux API C++
[笔记]c/cpp跨平台开发 知识点
[笔记]c/cpp跨平台开发 知识点
|
自然语言处理 Java 编译器
史上最详细的JNI入门教程HelloNative
史上最详细的JNI入门教程HelloNative
239 1
|
缓存 Java 编译器
JNI基础简介
JNI系列入门连载,开启JNI学习之旅吧。
111 0
|
安全 物联网 Unix
[HarmonyOS][鸿蒙专栏开篇]快速入门OpenHarmony的LiteOS微内核
[HarmonyOS][鸿蒙专栏开篇]快速入门OpenHarmony的LiteOS微内核
576 0
[HarmonyOS][鸿蒙专栏开篇]快速入门OpenHarmony的LiteOS微内核
|
存储 缓存 安全
NDK 系列(5):JNI 从入门到实践,万字爆肝详解!(下)
NDK 系列(5):JNI 从入门到实践,万字爆肝详解!(下)
271 0
NDK 系列(5):JNI 从入门到实践,万字爆肝详解!(下)
|
存储 缓存 Java
NDK | C 语言复习笔记
NDK | C 语言复习笔记
91 0
NDK | C 语言复习笔记
|
编解码 Java Android开发
so库你应该知道的基础知识
Android系统目前支持以下七种不同的CPU架构:ARMv5,ARMv7 (从2010年起),x86 (从2011年起),MIPS (从2012年起),ARMv8,MIPS64和x86_64 (从2014年起),每一种都关联着一个相应的ABI。
383 0
|
Java 编译器 C语言
NDK | C++ 复习笔记
NDK | C++ 复习笔记
104 0
下一篇
DataWorks