[JNI]使用jni实现简单的Java调用本地C语言代码
JNI的解释
Java Native Interface,即Java本地接口。
在Java官方描述中为:
The JNI is a native programming interface. It allows Java code that runs inside a Java Virtual Machine (VM) to interoperate with applications and libraries written in other programming languages, such as C, C++, and assembly.
JNI是一个本地编程接口。它允许运行在Java虚拟机(JVM)内部的Java代码与其他编程语言(如C、C++和汇编)编写的应用程序和库进行互操作。
The most important benefit of the JNI is that it imposes no restrictions on the implementation of the underlying Java VM. Therefore, Java VM vendors can add support for the JNI without affecting other parts of the VM. Programmers can write one version of a native application or library and expect it to work with all Java VMs supporting the JNI.
JNI最重要的好处是它对底层Java虚拟机的实现不施加任何限制。因此,Java虚拟机的供应商可以添加对JNI的支持而不影响VM的其他部分。程序员可以编写一个版本的本地应用程序或库,并期望它能与所有支持JNI的Java虚拟机兼容。
简而言之,jvm提供了Java和C/C++/汇编代码的桥梁,可以对Java进行扩展,这个桥梁就是JNI
介绍
我们知道,Java的主要优势之一是其可移植性——这意味着一旦我们编写并编译代码,这个过程的结果就是平台独立的字节码。
简单来说,这可以在任何能够运行Java虚拟机的机器或设备上运行,而且它的工作会像我们所期望的那样无缝进行。
然而,有时我们确实需要使用为特定架构原生编译的代码。
需要使用原生代码可能有一些原因:
- 需要处理某些硬件
- 对非常苛刻的过程进行性能提升
- 我们希望复用而不是用Java重写的现有库。
为了实现这一点,JDK引入了一个桥梁,连接了在我们的JVM中运行的字节码和原生代码(通常用C或C++编写)。
这玩意儿咋工作的
本地方法Native Methods:Java虚拟机与编译代码的交互
java 提供了 native关键字,用于指示该方法的实现将由本地代码提供,因此你是没办法在Java代码里看到这个方法的具体实现的。
比如我在研究ArrayList如何addAll数据时:
发现其中执行了一段代码:
System.arraycopy(a, 0, elementData, size, numNew);
这段代码很显然是为了实现数组的复制,因此我想看看到底是怎么copy的,点进该方法一看:
好好好,native修饰的,Java代码里看不了,这说明System.arraycopy是用的本地C/C++代码实现的
通常来说,在C语言或C++语言中创建本地可执行程序时,我们可以选择编译为静态链接库或动态链接库或者可执行文件,可执行文件是经历了链接之后的最终产物,不说了,我们:
静态链接库 :
所有库的二进制文件将在链接过程中作为我们可执行文件的一部分被包含进去。因此,我们不再需要这些库,但这会增加我们可执行文件的大小。在编译时链接,程序包含库的副本,运行时不需要库文件,但可能导致文件较大和内存使用效率低。
- 链接时机:静态链接库在编译时被链接到程序中。当编译器编译一个使用静态库的程序时,它会将程序中实际用到的库中的代码和数据复制到最终的可执行文件中。
- 文件大小:由于静态库的代码和数据被复制到每个使用它的程序中,这可能导致最终的可执行文件较大,尤其是当多个程序使用相同的静态库时。
- 运行时依赖:静态链接的程序在运行时不需要库文件,因为所有需要的代码和数据都已经包含在可执行文件中。
- 更新和维护:如果静态库更新了,所有使用它的程序都需要重新编译以包含新版本的库。
- 内存使用:由于每个程序都有自己的库副本,这可能导致内存使用效率较低,因为相同的库代码可能在多个程序中重复加载。
动态链接库:
最终的可执行文件只包含对库的引用,而不是代码本身。它要求我们运行可执行文件的环境能够访问我们程序使用的所有库文件。 在运行时链接,程序不包含库的副本,运行时需要库文件,但可以减小文件大小和提高内存使用效率。
- 链接时机:动态链接库在程序运行时被链接。编译器在编译时只记录程序使用了哪些动态库,而不将库的代码和数据复制到可执行文件中。
- 文件大小:动态链接的程序通常比静态链接的程序小,因为它们不包含库的代码和数据。
- 运行时依赖:动态链接的程序在运行时需要库文件。如果库文件不存在或版本不匹配,程序可能无法运行。
- 更新和维护:动态库的更新不需要重新编译使用它的程序。只需替换库文件,所有使用该库的程序都可以使用新版本的库。
- 内存使用:动态库在内存中只有一份副本,多个程序可以共享,这提高了内存使用效率。
后面这种动态链接的做法对JNI来说才靠谱,因为我们不能把字节码和本地编译的代码混在一个二进制文件里。
所以,我们的动态链接库会把本地代码单独编译在.so、.dll或者.dylib文件里,而不是混在类文件里。
文件类型 | 文件描述 | 对应系统 |
---|---|---|
.so | 共享对象(Shared Object) | Unix 和 类Unix操作系统(如 Linux) |
.dll | 动态链接库(Dynamic Link Library) | Windows 操作系统 |
.dylib | 动态库(Dynamic Library) | 较早的macOS 操作系统 |
正如前面所看到的那样,native
关键字修饰的方法我们称之为本地方法,由非Java代码实现。
public native void theNativeMethod();
这个代码和 public void theMethod();
的主要不同点在于:
这个方法不是由另一个Java类来实现的,而是由一个独立的本地共享库来实现,为了让我们能够从Java代码中调用这些本地方法,Java虚拟机会创建一个包含指向这些方法实现内存地址的指针表
重要组成部分
下面简单说说几个重要的部分,我们得注意一下。
-
Java代码 – 就是我们的Java类。这些类里至少会有一个标记为“本地”的方法。就像是我们写的Java程序,里面会有一些特殊的方法,我们称它们为“本地方法”,这些方法不是用Java写的,而是用其他语言写的。
-
本地代码 – 这是我们本地方法的实际执行逻辑,通常是用C语言或C++语言编写的。 这些是用C语言或C++语言写的代码,它们是那些“本地方法”的实现部分。
-
JNI头文件 – 这是一个给C/C++用的头文件(在JDK目录下的include/jni.h),里面包含了我们在本地程序中可能用到的所有JNI元素的定义。 这是一个特殊的文件,里面有很多关于如何让Java代码和本地代码相互交流的规则。
-
C/C++编译器 – 我们可以选择GCC、Clang、Visual Studio,或者任何我们喜欢的编译器,只要它能帮我们生成一个适用于我们平台的本地共享库就行。
JNI代码里的重要元素(JAVA和C/C++)
Java元素
- “native”关键词:我们之前说过,任何标记为native的方法都必须在一个本地共享库中实现。
- System.loadLibrary(String libname):一个静态方法,它可以从文件系统中加载一个共享库到内存,并让其导出的函数能够被我们的Java代码使用。
C/C++元素(很多都在jni.h中定义):
- JNIEXPORT:用来标记共享库中的函数为可导出的,这样它就会被包含在函数表中,JNI就能找到它了。
- JNICALL:和JNIEXPORT一起使用,确保我们的方法可以被JNI框架使用。
- JNIEnv:一个结构体,包含了一些方法,让我们的本地代码能够访问Java元素。
- JavaVM:一个结构体,允许我们操纵正在运行的JVM(甚至可以启动一个新的),增加线程,销毁等…
初次使用JNI
接下来,我们要学习一下如何使用jni
根据前面的内容我们知道,肯定是需要有个编译器,编译C/C++的
我这里选择的windows平台的 MinGW进行编译(MinGW安装自行百度)
搭建好编译环境后:
新建一个Java类文件
public class TestJNI{
// 静态代码块
// 为了在运行时加载本地动态链接库,windows是 TestJNI.dll,linux是 TestJNI.so
// 这个动态链接库中包含有sayHello方法的实现
static{
System.loadLibrary("TestJNI");
}
// 在Java类中声明一个实例本地方法sayHello()
private native void sayHello();
// main方法,测试,用来创建类的实例并调用本地方法
public static void main(String[] args){
new TestJNI().sayHello();
}
}
代码的解释:
静态初始化器会在类加载的时候调用System.loadLibrary()来加载名为"TestJNI"的本地库(这个库包含一个叫做sayHello()的本地方法)。在Windows系统中,它对应的是"hello.dll";在Unix或Mac OS X系统中,对应的是"libTestJNI.so"。这个库必须被包括在Java的库路径里(这个路径保存在Java系统变量java.library.path中)。你可以通过虚拟机参数-Djava.library.path=链接库路径
来把库加入Java的库路径。如果运行时找不到这个库,程序会抛出一个UnsatisfiedLinkError。(这个系统变量java.library.path会在后续代码中进行描述)
接下来,我们通过关键词native声明sayHello()方法为一个本地实例方法,这意味着这个方法是用另一种语言实现的。一个本地方法是没有方法体的。sayHello()方法应该在我们已经加载的本地库中被找到。
main()方法会创建一个TestJNI的实例,并调用本地方法sayHello()。
编译Java文件并且生成对应的C/C++头文件
进入命令行到 TestJNI.java所在文件目录下
通过命令生成对应的JNI规范.h头文件
// java8及以后
javac -h . TestJNI.java
// Java8以前
javac TestJNI.java
javah TestJNI
生成的文件如图:
我们现在点进TestJNI.h中看看里面是个啥?
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h> // 这个头文件中包含了JNI所有必要的类型定义和函数声明
/* Header for class TestJNI */
// 如果宏_Included_TestJNI没有在其他地方定义过,就将下面的内容进行定义。
//(这个宏包含的范围一直到最后一个#endif)
#ifndef _Included_TestJNI
#define _Included_TestJNI
// 告诉编译器下面的代码应该要以C语言的链接方式进行处理
// 这是为了解决c++的函数重载导致符号名称变化的问题,从而使得Java可以正确的调用本地方法
#ifdef __cplusplus
extern "C" {
#endif
// 本地方法的函数声明
/*
* Class: TestJNI 类名为 TestJNI
* Method: sayHello 方法名为 sayHello
* Signature: ()V 方法签名是()V 表示无参且返回值为void的方法
*/
JNIEXPORT void JNICALL Java_TestJNI_sayHello
(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
// 这里结束了extern "C"块,即告诉C++编译器,后续的代码将继续使用C++的链接方式。
#endif
// 结束 _Included_TestJNI 的宏定义
上述代码解释:
头文件里声明了一个C语言函数,名为Java_TestJNI_sayHello,来源于固定的命名规则,规则为Java_{包名}_{类名}_{方法名}(JNI参数)
,其中包名的点会被下划线代替
参数中包含有:
- JNIEnv*:指向JNI环境的引用,让你能够访问所有的JNI函数。
- jobject:指向"this" Java对象的引用。
我们暂时先忽略掉 JNIEXPORT
和 JNICALL
宏 定义,在零汇编的平台中,这些定义均为空,在x86或者sparc平台中会根据不同的定义可能会有实现。
在#ifdef __cplusplus
时会被c++编译器识别,也就是说extern "C"
只会被c++编译器识别出来,它告诉c++编译器下面的函数需要按照C语言的函数命名规则来进行编译,不是以C++的函数命名规则
C++编译器对于函数名有特定的处理方式,即名称修饰或者称为名称变形,或者说不以C++的函数签名的方式进行编译
这是一种在编译阶段发生的处理,用于支持C++的特性,比如函数重载。由于C++允许多个函数共享相同的名字,只要它们的参数类型不同,编译器需要一种方法来区分这些函数的符号名,因此会对函数名进行修饰,加入额外的信息,如函数的参数类型和数量。
怎么理解呢?
这就像是你有一本做饼干的食谱,但是食谱上的指令是给两种不同厨房设备(C和C++)使用的。现在,你想用C++设备来做饼干,但是这个设备有自己的一套复杂的指令(比如可以做多种口味的饼干),所以你需要告诉它:“虽然你很牛逼,但得按照普通的C设备(只做一种饼干)的方式来做这个饼干。” 这样它就不会搞错了。
#include <jni.h>
这个存在于何处呢?
以Windows Java8 为例,这个头文件在你的JAVA_HOME/include
里面可以找得到或者在其于当前系统相关的子目录中
比如我的Java环境是:C:\Program Files\Java\jdk1.8.0_261
,在其子目录include里面就能看到
对于Linux中,我的JAVA_HOME是/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.302.b08-0.el8_4.x86_64
在这个文件夹下有include
文件目录
编写C实现代码
在同一个目录下,创建一个文件名和刚刚生成的头文件名称一样的文件 “TestJNI.c”
下面编写文件中的代码
#include<stdio.h> // C语言的基本输入输出
#include<jni.h> // jni的头文件,这个是JDK里面包含的
#include "TestJNI.h" // 前面通过命令生成的头文件
// java sayHello方法的本地实现
JNIEXPORT void JNICALL Java_TestJNI_sayHello(JNIEnv *env, jobject obj)
{
printf("Hello,This is my First JNI Code!!");
return;
}
编译C程序
我这里是windows,就以windows为例
JAVA_HOME为C:\Program Files\Java\jdk1.8.0_261
使用指令
gcc -I "C:\Program Files\Java\jdk1.8.0_261\include" -I "C:\Program Files\Java\jdk1.8.0_261\include\win32" -shared -o TestJNI.dll TestJNI.c
注意你的JAVA_HOME和我的可能不同,需要自行修改
编译完成后,当前文件夹下会出现动态链接库文件 TestJNI.dll
运行Java文件
我直接执行命令:java TestJNI
一定会报错
所以需要将编译后的动态链接库传递给虚拟机执行
使用命令
java -Djava.library.path=. TestJNI