JNI 学习笔记(一)

JNI层面的知识是Android进阶必备的知识之一,下面我们就来看一下这里面都有些什么吧

1. 简介

JNI (Java Native Interface),简单翻译就是Java本地接口,作为程序员,对于接口还是比较敏感的,这可能代表会有一些功能提供给我们调用,至少大概的印象是这样的

根据官方给出的解释,JNI 算得上是一种编程框架,能够使得JVM去调用本地的应用或者库,或者被其他程序调用,其中,这些本地代码通过其他语言书写,最常见的是C/C++

至此,我们可以大概梳理一下 JNI 在我们脑海里模糊的影像

image-20230617000246710

似乎就像是一个桥梁,建立了 Java 代码(或者说是 JVM )与本地应用程序(或者通俗些,C/C++ 代码)之间的联系,你可以调我,反过来,我也可以调你

2. JNI 的用途

简单了解了 JNI 是什么之后,我们又不禁想要知道为什么我们需要 JNI ? 技术的产生总是带有目的性的,那么,JNI 是用于解决什么问题的呢?

  • 操作硬件相关内容(操作系统API往往基于C/C++)
  • 提升处理性能(音视频、渲染、大量计算操作等)
  • 提升安全性(Java的字节码文件更容易被反编译)

3. 简单例子

看概念类的东西很花时间,也容易遗忘,因此还是自己动手,通过实际编写代码来感悟,能更好加深我们的印象

首先,新建一个类,定义了一个native方法

1
2
3
4
5
6
7
8
public class JNITest {

static {
System.loadLibrary("test"); // 加载库
}

public native void test(); // native方法
}

使用的时候,可以直接实例化,调用定义的public方法,不过目前还用不了

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {

JNITest test = new JNITest(); // 实例化并进行方法调用
test.test();
}
}

接下来,我们使用javac 命令,生成对应于 JNITest 类的 C/C++ 头文件

1
javac -h . JNITest.java

image-20230617102904225

现在目录下会多出两个文件,其中.class后缀的是源文件对应的字节码文件,而另一个.h后缀的便是我们需要的头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class jni_JNITest */

#ifndef _Included_jni_JNITest
#define _Included_jni_JNITest
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: jni_JNITest
* Method: test
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_jni_JNITest_test
(JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

其中,Java_jni_JNITest_test()对应的便是我们先前定义的test()

简单了解一下声明的Java_jni_JNITest_test()当中的参数:

JNIEnv *明显是一个指针类型JNIEnv本身内部提供了很多函数,从名字也能够看出,它代表的应该是一个“环境”

在 JDK 的 include/ 目录下可以找到 jni.h头文件

image-20230617105352482

JNIEnv指向JNINativeInterface_结构体,而这个结构体定义许多环境相关的函数

image-20230617105704082 image-20230617105830606

环境的建立是后续各种操作的前提,因此JNIEnv实例作为第一个参数传递给所有的函数调用方

接下来再看,jobject是指向this的 Java 对象,代表自身的实例,如果将native方法改为static类型,对应的将是jclass类型参数,代表 Java 的类

extern "C"告诉编译器以 C 的方式编译函数,以便于其他 C 程序链接和访问

与 C++ 不同,C 的函数直接依据函数名识别,而 C++ 中由于涉及到了函数重载,因此需要结合函数名、参数列表、返回值三者共同组合进行识别,最终编译而成的目标代码在命名协议上会存在差异,这也是容易引起问题的地方

剩下来JNICALLJNIEXPORT,这是两个,在Linux平台的定义如下:

1
2
3
4
// 保证在本动态库中声明的方法 , 能够在其他项目中可以被调用 
#define JNIEXPORT __attribute__ ((visibility ("default")))
// 没有进行定义 , 直接置空,那么应该可以不用写
#define JNICALL

接下来,在有了头文件的基础上,进一步去实现头文件中声明的 JNICALL Java_jni_JNITest_test函数

image-20230617111030285

我们添加一个.c的C语言源文件,进行函数实现

1
2
3
4
5
6
7
8
#include "jni_JNITest.h"
#include <stdio.h>

JNIEXPORT void JNICALL Java_jni_JNITest_test(JNIEnv *env, jobject obj)
{
printf("执行JNI调用"); // 为了看效果
return (*env)->NewStringUTF(env, "Hello World!");
}

然后进行代码的编译、执行

image-20230617163139561
1
gcc -I"C:\Program Files\Java\jdk-17\include" -I"C:\Program Files\Java\jdk-17\include\win32" -shared -o test.dll JNITest.c  
image-20230617170620312

由于用的是Windows系统,因此对应生成的是.dll的动态链接库

image-20230617170749726

.dll文件拷贝到 JDK 的/bin目录下,然后运行代码

image-20230617170855719

可以看到我们实现的 C 代码被执行了,那么这种方式是所谓静态注册

大概整理一下先前我们都做了些什么:

  1. Java 代码加载库,定义native方法,以及对应的方法调用
  2. 包含native方法的 Java 类生成对应的 C/C++ 头文件
  3. 对应 C/C++ 头文件中声明的方法进行方法的实现
  4. 链接生成库,Windows下是.dll,Linux下是.so

3. 动态注册

除了静态注册,JNI 还有一种称为动态注册的使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DynamicJNITest {

static {
System.loadLibrary("test");
}

public native void test();

public native void test(int num);

public native String test(String name);

public native void hey();
}

这次我们添加了几个重载方法,显得内容更加丰富,其他好像没有太大区别

不过,这次我们不用生成头文件,而是直接编写 C 的源代码文件

image-20230618160049337

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <jni.h>
#include <stdio.h>


// 方法实现
#ifdef __cplusplus
extern "C" {
#endif

JNIEXPORT void JNICALL c_test1
(JNIEnv *env, jobject obj)
{
printf("test()");
}


JNIEXPORT void JNICALL c_test2
(JNIEnv *env, jobject obj, jint i)
{
printf("test(int)");
}

JNIEXPORT jstring JNICALL c_test3
(JNIEnv *env, jobject obj, jstring str)
{
printf("string test(string)");
return (*env)->NewStringUTF(env, "Hello World!");
}

JNIEXPORT void JNICALL c_hey
(JNIEnv *env, jobject obj)
{
printf("hey()");
}

#ifdef __cplusplus
}
#endif


// 方法名映射
static JNINativeMethod methods[] = {
{"test", "()V", (void*)c_test1},
{"test", "(I)V", (void*)c_test2},
{"test", "(Ljava/lang/String;)Ljava/lang/String;", (void*)c_test3},
{"hey", "()V", (void*)c_hey},
};

// 关联Java类
JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env = NULL;
jint result = -1;

// 获取JNI env变量
if ((*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_6) != JNI_OK) {
// 失败返回-1
return result;
}

// 获取native方法所在类
const char* className = "djni/DynamicJNITest";
jclass clazz = (*env)->FindClass(env, className);
if (clazz == NULL) {
return result;
}

// 动态注册native方法
if ((*env)->RegisterNatives(env, clazz, methods, sizeof(methods) / sizeof(methods[0])) < 0) {
return result;
}

// 返回成功
result = JNI_VERSION_1_6;
return result;
}

这一段的内容比较长,不过我们可以将其主要分为3个部分

首先看第一部分的主要代码(模板就省去了,像#includeextern等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
JNIEXPORT void JNICALL c_test1     // test()
(JNIEnv *env, jobject obj)
{
printf("test()");
}


JNIEXPORT void JNICALL c_test2 // test(int)
(JNIEnv *env, jobject obj, jint i)
{
printf("test(int)");
}

JNIEXPORT jstring JNICALL c_test3 // String test(String)
(JNIEnv *env, jobject obj, jstring str)
{
printf("string test(string)");
return (*env)->NewStringUTF(env, "Hello World!");
}

JNIEXPORT void JNICALL c_hey // hey()
(JNIEnv *env, jobject obj)
{
printf("hey()");
}

这边的几个函数对应的便是在 Java 当中定义的几个native方法,而参数中的jintjstring代表的是 C 当中对应 Java 类型的表示,在函数的实现逻辑当中进行一些函数签名的输出,方便校验,这就算是进行了简单的函数实现

接下来,需要对于函数进行关联,用到的是JNINativeMethod数组,将函数间的对应关系进行绑定

1
2
3
4
5
6
7
// 方法名映射
static JNINativeMethod methods[] = {
{"test", "()V", (void*)c_test1},
{"test", "(I)V", (void*)c_test2},
{"test", "(Ljava/lang/String;)Ljava/lang/String;", (void*)c_test3},
{"hey", "()V", (void*)c_hey},
};

比如第一个元素,“test"是 Java 中native方法的函数名,”()V"表示它不接受入参,返回类型为void,这种表示方法可以参考下表,是对于函数签名的表示,将函数的入参、出参及对应类型简化为标识

类型标识 对应 Java 类型
Z boolean
B byte
C char
S short
I int
J long
F float
D double
L包名/类名; 各种引用类型
V void

右边则相对统一,(void*)c_test1其实就是将我们在 C 文件中定义的名称与 Java 中对应的方法建立关联关系,像c_test1这种方法名是我们自己写的,并非系统生成

最后,则是添加一个JNI_OnLoad()函数,光看参数名称也可以知道这与 Java 虚拟机密切相关

1
2
3
4
5
 // 获取JNI env变量
if ((*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_6) != JNI_OK) {
// 失败返回-1
return result;
}

这一步,通过GetEnv()获取JNIEnv,之前也说了,这是 JNI 方法调用的前提,环境OK才能进行下面的工作

1
2
3
4
5
6
// 获取native方法所在类
const char* className = "djni/DynamicJNITest";
jclass clazz = (*env)->FindClass(env, className);
if (clazz == NULL) {
return result;
}

再接下来,是根据 Java 的全类名,通过FindClass()获取包含native方法的类对象,clazz表示对应的类

1
2
3
4
5
6
7
8
// 动态注册native方法
if ((*env)->RegisterNatives(env, clazz, methods, sizeof(methods) / sizeof(methods[0])) < 0) {
return result;
}

// 返回成功
result = JNI_VERSION_1_6;
return result;

最后,将类信息、方法信息、方法数量信息等内容传给RegisterNatives()函数,完成最终注册

1
gcc -I"C:\Program Files\Java\jdk-17\include" -I"C:\Program Files\Java\jdk-17\include\win32" -shared -o test.dll DynamicJNITest.c  

执行gcc命令生成.dll,打包替换原来的test.dll

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {

DynamicJNITest test = new DynamicJNITest();
test.test();
test.test(1);
test.test("apple");
test.hey();
}
}

image-20230618165312049

执行测试用例,各个native方法都依次成功调用了

回顾一下,动态注册我们主要做了以下几件事:

  1. Java 类中声明并调用native方法
  2. 编写 C 代码,包括方法实现映射关系建立类方法信息注册(没有编译生成头文件)
  3. 生成.dll文件

其实这么看,好像静态、动态各有优势,应当如何选择呢?随着往后的学习我们来解答

(31条消息) 【Android NDK 开发】JNI 方法解析 ( JNIEXPORT 与 JNICALL 宏定义作用 )_韩曙亮的博客-CSDN博客

JNI 编程上手指南之 HelloWorld 实战 - 掘金 (juejin.cn)

JNI简介 - 知乎 (zhihu.com)

JNI_百度百科 (baidu.com)