JNI编程不完全详解

686 阅读10分钟

封面9 拷贝.png

作者:老九—技术大黍

社交:知乎

公众号:老九学堂

特别声明:原创不易,未经授权不得转载或抄袭,如需转载可联系笔者授权

前言

在《The Java™ Native Interface Programmer’s Guide and Specification》一书中这样描述JNI是什么:

image-20210323134213307.png

image-20210323134304003.png

image-20210323134445424.png

计科系出身的我来参考翻译一下,请大家不要当成标准,如果有不足之处,请大家指正和补充。

当Java平台被部署到主机环境的最顶端处时,它就可以让Java应用程序与使用其他编程语言(比如C/C++)实现的本地代码协同工作。程序员已经适应了使用传统的C/C++语言与Java平台一起构建应用,因为Java应用程序已经与C/C++代码协同开发很多年了,我们Java应用可以直接使用C/C++已经固有的代码来解决Java应用中的问题,从而不需要我们Java程序再使用Java代码来重写一次。

JNI是我们可以使用的Java平台提供的高级功能,因为这个功能可以让我们使用其它编程语言来书写功能性代码,并用被Java应用程序调用。JNI作为Java虚拟实现的一个组成部分,它提供一个双向接口(two-way interface)方式允许Java应用调用本地代码,同样本地代码与可以调用Java应用的代码。图1.1描述了JNI在虚拟机中的角色:

无标题.png

JNI就是Java平台设计来给我们第三方应用开发时,当我们需要让Java应用程序与其他编程语言协同混混编的接口,它是一个双向接口:

  • Java应用通过JNI呼叫其它编程语言的代码(最常见的是C/C++,理论上可以是任何其他的编程语言)
  • 其它编程语言也可以通过JNI呼叫Java应用程序的功能

解读

使用纯Java来开发对于程序员是一件非常美好的事情。但是,Java语法始终有一个缺点就是它的运行效率相比C/C++不够高,即使Java已经更新到Java 17版本了,它还是不可能和C/C++一样快,原因很简单:Java代码必须通过Java虚拟机进程才能执行!而C/C++代码是直接让本地操作系统运行的代码!

C/C++应用程序本身就是与Java虚拟机同等的进程存在。换名话说:除非Java没有虚拟机,直接被操作系统运行,它才能和C/C++应用一样快。当然,这个假设是不成立的image-20210323145915834.png

为解决彻底解决运行效率,那么我们可让Java直接呼叫C的函数,以便解决效率,这种编程叫JNI编程。

相对于Java在沙箱(虚拟机)中运行,C/C++的代码直接OS中运行,所以叫本地码。

1996年Java第一版提供的纯Java写的字符串加密库运行效率不高,所以只有通过C的函数加密才能解决效率。

比如,我原来制作的《凤舞三国》手机游戏,我们使用C书写的哈夫曼加/解密算法,安装在Android机上就需要通过Java代码使用JNI来调用这个功能。原因两个:

  • 安全--如果我们使用Java来书写,那么谁都可以轻松的反编译.class字节码文件
  • 高效--Java的代码本身运行效率没有C代码高

另外,Java提供的网络阻塞式IO流,因为是用纯Java写的,所以在非常强调运行效率的环境下,也是达不到要求,因此,这部分功能也需要调用本地码来解决。当然JNI的缺点就是因为使用两种语言开发,那么意味着本地码必须支持不同的OS运行环境。比如,在windows运行,那么本地码必须支持win32环境;在Linux运行,那么本地码支持Linux环境;在Unix运行,那么本地码必须支持Unix环境。

因此,如果我们在win32环境开发,那么必须针对各平台进行重新调试,这是非常麻烦的事情。这也就是为什么使用Cocos2d-x的C++引擎开发跨平台(Android和iOS)手机游戏最麻烦的原因。

注意JNIJava Native Interface的缩写。

理论讲完了,我们下面来进行代码实操。

第一个JNI应用

一般我们JNI编码是呼叫C的函数,当然也可以呼叫C++的成员函数。下面我们演示Java类呼叫C函数。

Java代码

书写一个HelloNative.java源文件

/**
	功能:书写一个Java类用来演示JNI编程-呼叫C函数
	作者:技术大黍
	*/
public class HelloNative{
	public static native void greeting();
}

本地方法可以使用static来修饰,当然也可以不使用。使用static是因为不处理参数传递。

生存头文件

接着我们在命令中输入下面的两条命令

javac *.java
javah HelloNative

生成一个HelloNative.h头文件

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class HelloNative */

#ifndef _Included_HelloNative
#define _Included_HelloNative
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     HelloNative
 * Method:    greeting
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_HelloNative_greeting
  (JNIEnv *env, jclass  clazz);

#ifdef __cplusplus
}
#endif
#endif

解释

image-20210323152001937.png

C函数头文件实现

接着我们需要编写一个HelloNative.c源文件,定义一个C函数给Java虚拟机来呼叫。因为是写给虚拟机,所以要遵守以下规则:

  • 必须使用Java全方法名,如果有包,那么还加上包名。比如com.yy.HelloNative.greeting
  • 每个单词之间使用下划线分隔_,比如Java_HelloNative_greeting或者Java_com_yy_HelloNative_greeting_
  • 如果类包含ASCII码字符’_’,’$’或者数字,或者大于’\u007F’的Unicode编码值,那么使用_0xxxx十六进制表示

注意:如果有重载的本地方法,那么必须用两个下划线来表示。比如重载的Java_HelloNative_greeting__

/**
	功能:书写一个HelloNative.h的实现
	*/
#include "HelloNative.h"
#include "stdio.h"

JNIEXPORT void JNICALL Java_HelloNative_greeting
(JNIEnv *env, jclass clazz){
	printf("Hello Native World!\n");
}

C++头文件实现

如果是C++那么,书写一个HelloNative.cpp文件如下

/*
	功能:书写一个C++的实现
	*/
#include "HelloNative.h"
#include <iostream>
using namespace std;
extern "C"
JNIEXPORT void JNICALL Java_HelloNative_greeting
(JNIEnv *env, jclass clazz){
	cout << "Hello Native World!" << endl;
}

编译动态链接库

编译有两种方式:

GCC编译

在控制台中输入下面的命令

gcc -Wl,--add-stdcall-alias -I"%JAVA_HOME%\include" -I"%JAVA_HOME%\include\win32" -shared -o hello.dll HelloNative.c 

如果一切正常,那么会生成一个HelloNative.dll文件

CL编程

先全文搜索到cl.exe编译器命令位置,然后把它添加到PATH中去。

在控制台中输入下面的命令

cl -I "%JAVA_HOME%\include" -I "%JAVA_HOME%\include\win32" -LD HelloNative.c -FeHelloNative.dll

如果我们的操作系统是64位话,那么Java调用时链接不成功的,因为它是一个32位编译命令。会报下面的错误信息:

image-20210323153731470.png

解决编译过程问题

要解决32位与64位不兼容的问题,我们有两种方式:

  • 安装64位gcc编译器
  • 调试VS2019环境 ,让它生成64位dll文件

书写一个HelloNativeTest.java源文件

/**
	功能:书写一个程序入口用来测试JNI编译效果
	作者:技术大黍
	*/
public class HelloNativeTest{
	public static void main(String[] args){
		HelloNative.greeting();
	}
	//静态语句块保证在创建类对象之前加载dll文件
	static{
		System.loadLibrary("Hello");
	}
}

如果一切正常,那么我们会看到下面的这个效果

image-20210323154548089.png

掌握JNI编程

类型转换

当C和Java之间传递函数的参数和返回值时,我们Java实际上是没有原始数值类型的。因此,在进行参数转义说明。

image-20210323154635188.png

在jni.h头文件定义了这些类型转换,并且该头文件定义JNI _FALSE=0和JNI_TRUE=1两个常量。

在Java中字符串是一个UTF-16编码序列,也就等于C中非空结束的字符序列,因此两种语言的字符完成不一样。

JNI提供两组函数转换字符串转换问题:

  • 一个是UTF-8的字符串序列
  • 一个是UTF-16值数组,也就是jchar数组

Java的String对应JNI的jstring类型。例如

image-20210323154835393.png

如果JNI文件的C++代码,那么JNIEvn类有一个内联函数NewStringUTF可以直接呼叫:

image-20210323154905016.png

该函数返回一个新的字符串jstring对象,它使用函数GetStringUTFChars来实现。 我们知道Java虚拟机要垃圾叫回收对象,因此,我们的字符串使用完成之后,我们必须呼叫函数ReleaseStringUTFChars来对字符对象进行垃圾回收。

image-20210323154947229.png

访问对象属性

前面我们访问类的静态方法,以及参数和返回值。接下来我们讨论访问Java对象属性。

image-20210323155125801.png

接着需要在cpp中这样实现

image-20210323155219847.png

编码签名

C/C++访问java对象的属性和方法时,必须有识别码,也就是编码签名。比如上面“D”就是double的意思

image-20210323155300201.png

  • 如果是访问java的数组,比如字符串数组
  • 如果访问float[][]二维数组
  • 如果访问一个方法带两个int参数 ,并且返回一个int
  • 如果访问一个方法带一个字符串参数,返回void

注意:表达式以分号结尾,参数之间没有间隔。

如果构造方法,比如 Employee(java.lang.String,java.util.Date)

image-20210323155412799.png

注意:D和Ljava/util/Date;之间没有间隔。在编码签名 ,包名的.都被/替换掉。

V的意思是返回类型为void,虽然Java和构造方法没有返回值,但是编码签名要求使用V给虚拟机一个数字签名来识别。

使用javap命令和参加-s来生成Java类的编码签名,javap –s –private Employee

image-20210323155502784.png

image-20210323155519503.png

如果一切正常,那么显示如下效果

image-20210323155550284.png

访问静态属性

访问静态属性有点类似访问非静态属性,我们同样使用GetStaticFieldID等函数。

  • 因为没有对象,所以使用FindClass来类的引用
  • 使用类而不是使用对象实例来访问静态属性

比如,我们访问System.out静态属性

image-20210323155638712.png

总结

JNI编程一般的教课书都不会讲,因为它是需要与其它编程语言一起混合编程,这对于初级的Java程序员来说是一个灾难:说好的我们Java程序是“software craftsman”(纯软件程序员)的嘛?怎么又要咱们学习C/C++啊!。这不是骗人吗?image-20210323160037003.png

但是,如果我们要成为高级Java程序员,那么肯定是要学会与其它语言一起混合编程的。因为一种编程语言不可能解决所有的应用问题,这个在实际开发中是非常见的事情。

我们希望通过这篇文章可以让大家快速理解Java平台的JNI功能,以及了解怎样进行JNI开发,以及时和快速解决工作中与C/C++程序员一起协同编程的问题。这样也不会被C/C++程序员瞧不起,找到我们Java程序员应有的尊严。

我们可大喊一声:为了德玛!

image-20210323160723050.png

最后

记得给大黍❤️关注+点赞+收藏+评论+转发❤️

作者:老九学堂—技术大黍

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。