7.4 Java本地接口
Java虚拟机为Java开发人员屏蔽了底层的实现细节,使得开发人员不用考虑底层操作系统的差异性。不过在某些应用程序中,免不了要直接与底层操作系统上的原生代码进行交互。Java本地接口(Java Native Interface, JNI)的作用是提供一个标准的方式让Java程序通过虚拟机与原生代码进行交互。与原生代码进行交互的动机主要有下面几个。
第一个动机是从性能的角度出发。Java语言从其运行速度上来说,在大多数方面是慢于底层操作系统上原生的C和C++等语言的。这主要是由于Java虚拟机这个中间层次的存在。如果完全用Java语言实现的性能无法达到程序的预期要求,可以选择把部分重要且耗时的代码用C或C++来实现。
第二个动机是满足某些特殊的需求。Java平台提供的标准类库的功能很强大,包括了在开发中可能遇到的大部分功能。但是仍然有一些功能无法用标准API来实现,主要是一些与底层硬件平台直接交互的功能。Java虚拟机没有把这一部分功能暴露给运行在其上的程序。如果需要这方面的功能,那么只能使用原生代码来进行开发。
最后一个动机是与已有的使用原生代码编写的程序之间进行集成。如果Java程序需要与底层操作系统上由C和C++语言开发的程序进行交互,那么可以使用JNI。
7.4.1 JNI基本用法
JNI所包含的内容比较复杂,下面通过几个具体的示例来介绍JNI的使用。JNI的一个重要使用场景是提高程序的性能相关。当对程序中关键部分的性能要求比较高的时候,可以使用C和C++代码来实现。使用原生代码实现的方法被称为原生方法,由native关键词来声明。如代码清单7-13所示,sqrt方法由原生代码来实现。在NativeMath类的静态初始化块中通过System.loadLibrary方法加载名为NativeMath的原生代码库。这个原生代码库中包含了sqrt方法的实现。原生方法与Java接口中的方法或抽象类中的抽象方法一样,只包含方法声明,没有相关的实现。程序中的其他部分可以用正常的方法调用原生方法,比如参数传递和返回值使用等都与正常的方法相同。当虚拟机在执行原生方法时,会尝试在已经加载的原生代码库中查找原生方法的对应实现。在查找到对应的实现方法之后,虚拟机会负责进行参数传递、实际方法调用和返回值传递等工作。
代码清单7-13 包含原生方法的Java类
public class NativeMath{
static{
System.loadLibrary("NativeMath");
}
public native double sqrt(double value);
}
在编写了Java源代码之后,下一步要编写实现原生方法的C/C++代码。Java提供的命令行工具javah根据Java源代码生成C/C++代码所需的头文件。对于原生方法,头文件中会包含相关的方法声明与其对应。代码清单7-14给出了生成的头文件的内容。在这个头文件中,先引用JDK中include目录下的jni.h文件。这个由JDK提供的头文件中包含了实现原生方法的C/C++代码中所需的类型和方法声明。原生方法sqrt对应的C/C++方法是Javacom_java7book_chapter7_jni_NativeMath_sqrt。这个方法的名称是在“Java”的前缀后加上类名和方法名而得到的。在方法的参数方面,原生方法中声明的参数会被自动映射到用来实现的C/C++方法中。C/C++方法中最前面两个参数是标准的。第一个参数是指向JNIEnv接口的指针,通过这个指针可以访问包含一系列JNI方法的方法表。在原生方法的实现中需要利用这些JNI方法来访问和操作虚拟机中的数据结构。第二个参数取决于原生方法的类型。对于类中的实例方法,这个参数表示的是方法调用时的当前对象,相当于this关键词的含义;对于类中的静态方法来说,这个参数表示的是方法所在的Java类的对象。这两个标准参数之后是原生方法中映射过来的参数。
代码清单7-14 通过javah工具生成的JNI头文件
include<jni.h>
ifndef_Included_com_java7book_chapter7_jni_NativeMath
define_Included_com_java7book_chapter7_jni_NativeMath
ifdef__cplusplus
extern"C"{
endif
/*
*Class:com_java7book_chapter7_jni_NativeMath
*Method:sqrt
*Signature:(D)D
*/
JNIEXPORT jdouble JNICALL Java_com_java7book_chapter7_jni_NativeMath_sqrt(JNIEnv*,jobject, jdouble);
ifdef__cplusplus
}
endif
endif
Java与C/C++的语法不同,在参数的类型上需要进行一定的映射。对于Java中的基本类型,如int和float等,在原生方法的实现中,是直接映射到对应的类型上的。例如,Java中double类型的对应类型是jdouble。而Java中的引用类型则映射到一个C/C++中的指针上。这个指针所指向的内容是虚拟机内部的结构,其具体的内容对使用者来说是不透明的。原生方法的实现代码利用JNI提供的方法来对这个指针所指向的结构进行操作。
代码清单7-13中的Java源程序只用到了基本类型double,其对应的C/C++方法的实现比较简单,只需要调用C/C++中的sqrt方法即可。代码清单7-15给出了相应的C++实现。
代码清单7-15 NativeMath类的sqrt方法的C++实现
include"com_java7book_chapter7_jni_NativeMath.h"
include<math.h>
JNIEXPORT jdouble JNICALL Java_com_java7book_chapter7_jni_NativeMath_sqrt(JNIEnv*env, jobject obj, jdouble value){
return sqrt(value);
}
在编写了相应的C++程序之后,可以使用C++编译器来进行编译和链接,得到所需的原生代码库文件。在编译时需要把JDK中的include目录添加到编译器的搜索路径中,否则无法找到对应的类型声明。在Windows平台上,编译和链接的结果是动态链接库DLL文件。这个DLL文件的名称要与System.loadLibrary方法中使用的名称相同。在运行Java程序时,需要通过启动参数“-Djava.library.path”来指定DLL文件所在的目录,使System.loadLibrary方法可以找到所需的DLL文件。
在使用JNI时最容易遇到的问题是出现java.lang.UnsatisfiedLinkError错误。造成这个错误的原因通常有两个:第一个原因是找不到包含原生方法实现的代码库,比如找不到DLL文件。在出现这个错误时,通常只需要使用虚拟机启动参数“-Djava.library.path”显式指定代码库所在的目录即可。如果不显式指定,那么虚拟机会在某些预设目录中进行搜索。第二个原因是无法在代码库中找到原生方法对应的实现方法。一般来说,使用javah工具从Java源代码中生成的头文件中包含了对应方法的正确声明。只需要在C/C++源代码中复制该方法的声明即可。如果发生了无法找到对应方法的情况,那么很可能是C/C++编译器在编译和链接时改变了所生成的方法的名称。C/C++编译器的这个特性被称为名称装饰(name decoration),其主要目的是解决相同名称的编程实体的解析问题。不同的名称空间中可能包含相同名称的实体,如名称相同的方法。在进行链接的时候,链接器需要足够的信息来区分这些名称相同的实体。典型的做法是在生成的实体名称上添加一些额外的信息作为装饰来进行区分。比如,在Windows平台上使用MinGW的GCC编译器对代码清单7-15中的C++代码进行编译和链接之后,所得到的DLL文件中的Java_com_java7book_chapter7_jni_NativeMath_sqrt方法的实际名称是Java_com_java7book_chapter7_jni_NativeMath_sqrt@8。方法名称的后缀“@8”表示的是方法的所有参数所占用的字节数。方法名称的不一致造成虚拟机在运行Java程序时找不到原生方法的对应实现,因此出现UnsatisfiedLinkError错误。对于方法名称后多余的@后缀,只要在使用GCC编译器时加上参数“-Wl,—kill-at”就可以将其去掉。在产生UnsatisfiedLinkError错误时,可以从错误的详细信息中得到具体的说明。如果是由于第二个原因造成的,可以通过工具来查看代码库中方法的名称来定位问题的原因。比如在Windows平台上,可以使用DLL Export[1]Viewer来查看DLL中导出的方法的详细信息。
代码清单7-15只说明了Java中的基本类型在JNI中的使用方式。当原生方法的声明中包含了Java标准库的类或自定义的类时,利用C/C++来实现的方式会有所不同。比如,在NativeMath类中添加一个新的原生方法size,其声明如代码清单7-16所示。方法size的参数的类型是自定义的表示矩形的Rectangle类,该类中包含了用来获取矩形区域的宽度和高度的getWidth和getHeight方法。
代码清单7-16 NativeMath类中size方法的声明
public native double size(Rectangle rectangle);
再次使用javah工具来更新头文件之后,原生方法size的C++实现如代码清单7-17所示。先使用JNIEnv中的GetObjectClass方法查找到参数rectangle对象所对应的Java类,再通过GetMethodID方法找到Rectangle类中包含的getWidth和getHeight方法。最后通过CallDoubleMethod方法来调用这两个方法,可以得到进行计算所需的矩形的宽度和高度的值。
代码清单7-17 NativeMath类中size方法的实现
JNIEXPORT jdouble JNICALL Java_com_java7book_chapter7_jni_NativeMath_size(JNIEnv*env, jobject obj, jobject rectangle){
jclass cls=env->GetObjectClass(rectangle);
jmethodID getWidthMid=env->GetMethodID(cls,"getWidth","()D");
double width=env->CallDoubleMethod(rectangle, getWidthMid);
jmethodID getHeightMid=env->GetMethodID(cls,"getHeight","()D");
double height=env->CallDoubleMethod(rectangle, getHeightMid);
return width*height;
}
从代码清单7-17中可以看出,对参数对象的使用方式类似于在Java中使用反射API来操作对象。由于jobject表示的只是一个不透明的结构,因此所有的操作都需要以反射的方式来进行。在JNIEnv中,GetObjectClass方法的作用相当于Java中Object类中的getClass方法,GetMethodID方法的作用相当于Java中Class类的getMethod方法,只不过在查找时使用的方式不同。GetMethodID方法要求提供方法类型在字节代码中的表现形式作为查找的条件,例如,“()D”表示不包含参数,返回值类型为double。CallDoubleMethod方法的作用相当于Java中Method类的invoke方法。在JNIEnv中,对于Java中返回值类型、方法类型和调用方式不同的方法,都有与之对应的调用方法,比如调用Java中返回值类型为boolean的静态方法应该使用CallStaticBooleanMethod方法。
[1]DLL Export Viewer工具的网址是:http://www.nirsoft.net/utils/dll_export_viewer.html。