Java跨平台的特性使Java越来越受开发人员的欢迎,但也往往会听到不少的抱怨:用Java开发的图形用户窗口界面每次在启动的时候都会跳出一个控制台窗口,这个控制台窗口让本来非常棒的界面失色不少。怎么能够让通过Java开发的GUI程序不弹出Java的控制台窗口呢?
其实现在很多流行的开发环境例如JBuilder、Eclipse都是使用纯Java开发的集成环境。这些集成环境启动的时候并不会打开一个命令窗口,因为它使用了JNI(Java Native Interface)的技术。
通过这种技术,开发人员不一定要用命令行来启动Java程序,可以通过编写一个本地GUI程序直接启动Java程序,这样就可避免另外打开一个命令窗口,让开发的Java程序更加专业。
JNI允许运行在虚拟机的Java程序能够与其它语言(例如C和C++)编写的程序或者类库进行相互间的调用。同时JNI提供的一整套的API,允许将Java虚拟机直接嵌入到本地的应用程序中。
本文将介绍如何在C/C++中调用Java方法,并结合可能涉及到的问题介绍整个开发的步骤及可能遇到的难题和解决方法。本文所采用的工具是Sun公司创建的 Java Development Kit (JDK) 版本 1.3.1,以及微软公司的Visual C++ 6开发环境。
环境搭建
为了让本文以下部分的代码能够正常工作,我们必须建立一个完整的开发环境。首先需要下载并安装JDK 1.3.1,其下载地址为“http://java.sun.com”。假设安装路径为C:\JDK。下一步就是设置集成开发环境,通过Visual C++ 6的菜单Tools→Options打开选项对话框。
将目录C:\JDK\include和C:\JDK\include\win32加入到开发环境的Include Files目录中,同时将C:\JDK\lib目录添加到开发环境的Library Files目录中。这三个目录是JNI定义的一些常量、结构及方法的头文件和库文件。集成开发环境已经设置完毕,同时为了执行程序需要把Java虚拟机所用到的动态链接库所在的目录C:\JDK \jre\bin\classic设置到系统的Path环境变量中。
这里需要提出的是,某些开发人员为了方便直接将JRE所用到的DLL文件直接拷贝到系统目录下。这样做是不行的,将导致初始化Java虚拟机环境失败(返回值-1),原因是Java虚拟机是以相对路径来寻找所用到的库文件和其它一些相关文件的。
至此整个JNI的开发环境设置完毕,为了让此次JNI旅程能够顺利进行,还必须先准备一个Java类。在这个类中将用到Java中几乎所有有代表性的属性及方法,如静态方法与属性、数组、异常抛出与捕捉等。我们定义的Java程序(Demo.java)如下,本文中所有的代码演示都将基于该Java程序,代码如下:
package jni.test;
/**
* 该类是为了演示JNI如何访问各种对象属性等
* @author liudong
*/
public class Demo {
//用于演示如何访问静态的基本类型属性
public static int COUNT = 8;
//演示对象型属性
public String msg;
private int[] counts;
public Demo() {
this("缺省构造函数");
}
/**
* 演示如何访问构造器
*/
public Demo(String msg) {
System.out.println("<init>:" + msg);
this.msg = msg;
this.counts = null;
}
/**
* 该方法演示如何访问一个访问以及中文字符的处理
*/
public String getMessage() {
return msg;
}
/**
* 演示数组对象的访问
*/
public int[] getCounts(){
return counts;
}
/**
* 演示如何构造一个数组对象
*/
public void setCounts(int[] counts){
this.counts = counts;
}
/**
* 演示异常的捕捉
*/
public void throwExcp()throws IllegalAccessException{
throw new IllegalAccessException("exception occur.");
}
}
初始化虚拟机
本地代码在调用Java方法之前必须先加载Java虚拟机,而后所有的Java程序都在虚拟机中执行。为了初始化Java虚拟机,JNI提供了一系列的接口函数Invocation API。通过这些API可以很方便地将虚拟机加载到内存中。
创建虚拟机可以用函数
jint JNI_CreateJavaVM (JavaVM **pvm, void **penv, void *args)
对于这个函数有一点需要注意的是,在JDK 1.1中第三个参数总是指向一个结构JDK1_ 1InitArgs, 这个结构无法完全在所有版本的虚拟机中进行无缝移植。在JDK 1.2中已经使用了一个标准的初始化结构JavaVMInitArgs来替代JDK1_1InitArgs。下面我们分别给出两种不同版本的示例代码。
在JDK 1.1初始化虚拟机:
#include <jni.h>
int main() {
JNIEnv *env;
JavaVM *jvm;
JDK1_1InitArgs vm_args;
jint res;
/* IMPORTANT: 版本号设置一定不能漏 */
vm_args.version = 0x00010001;
/*获取缺省的虚拟机初始化参数*/
JNI_GetDefaultJavaVMInitArgs(&vm_args);
/* 添加自定义的类路径 */
sprintf(classpath,"%s%c%s",vm_args.classpath,PATH_SEPARATOR, USER_CLASSPATH);
vm_args.classpath = classpath;
/*设置一些其他的初始化参数*/
/* 创建虚拟机 */
res = JNI_CreateJavaVM(&jvm,&env,&vm_args);
if (res < 0) {
fprintf(stderr, "Can't create Java VM\n");
exit(1);
}
/*释放虚拟机资源*/
(*jvm)->DestroyJavaVM(jvm);
}
JDK 1.2初始化虚拟机:
/* invoke2.c */
#include <jni.h>
int main() {
int res;
JavaVM *jvm;
JNIEnv *env;
JavaVMInitArgs vm_args;
JavaVMOption options[3];
vm_args.version=JNI_VERSION_1_2;
//这个字段必须设置为该值
/*设置初始化参数*/
options[0].optionString = "-Djava.compiler=NONE";
options[1].optionString = "-Djava.class.path=.";
options[2].optionString = "-verbose:jni";
//用于跟踪运行时的信息
/*版本号设置不能漏*/
vm_args.version = JNI_VERSION_1_2;
vm_args.nOptions = 3;
vm_args.options = options;
vm_args.ignoreUnrecognized = JNI_TRUE;
res = JNI_CreateJavaVM(&jvm,(void**)&env, &vm_args);
if (res < 0){
fprintf(stderr,"Can't create Java VM\n");
exit(1);
}
(*jvm)->DestroyJavaVM(jvm);
fprintf(stdout,"Java VM destory.\n");
}
为了保证JNI代码的可移植性,建议使用JDK 1.2的方法来创建虚拟机。JNI_CreateJavaVM函数的第二个参数JNIEnv *env,就是贯穿整个JNI始末的一个参数,因为几乎所有的函数都要求一个参数就是JNIEnv *env。
访问类方法
初始化了Java虚拟机后,就可以开始调用Java的方法。要调用一个Java对象的方法必须经过几个步骤:
1.获取指定对象的类定义(jclass)
有两种途径来获取对象的类定义:第一种是在已知类名的情况下使用FindClass来查找对应的类。但是要注意类名并不同于平时写的Java代码,例如要得到类jni.test.Demo的定义必须调用如下代码:
jclass cls = (*env)->FindClass(env,"jni/test/Demo");
//把点号换成斜杠
然后通过对象直接得到其所对应的类定义:
jclass cls = (*env)-> GetObjectClass(env, obj);
//其中obj是要引用的对象,类型是jobject
2.读取要调用方法的定义(jmethodID)
我们先来看看JNI中获取方法定义的函数:
jmethodID (JNICALL *GetMethodID)
(JNIEnv *env, jclass clazz, const char *name,const char *sig);
jmethodID (JNICALL *GetStaticMethodID)
(JNIEnv *env,jclass class, const char *name, const char *sig);
这两个函数的区别在于GetStaticMethodID是用来获取静态方法的定义,GetMethodID则是获取非静态的方法定义。这两个函数都需要提供四个参数:env就是初始化虚拟机得到的JNI环境;第二个参数class是对象的类定义,也就是第一步得到的obj;第三个参数是方法名称;最重要的是第四个参数,这个参数是方法的定义。
因为我们知道Java中允许方法的多态,仅仅是通过方法名并没有办法定位到一个具体的方法,因此需要第四个参数来指定方法的具体定义。但是怎么利用一个字符串来表示方法的具体定义呢?JDK中已经准备好一个反编译工具javap,通过这个工具就可以得到类中每个属性、方法的定义。下面就来看看jni.test.Demo的定义:
打开命令行窗口并运行 javap -s -p jni.test.Demo 得到运行结果如下:
Compiled from Demo.java
public class jni.test.Demo extends java.lang.Object{
public static int COUNT;
/*I*/
public java.lang.String msg;
/*Ljava/lang/String; */
private int counts[];
/*[I*/
public jni.test.Demo();
/*()V*/
public jni.test.Demo(java.lang.String);
/*(Ljava/lang/String;)V*/
public java.lang.String getMessage();
/*()Ljava/lang/String;*/
public int getCounts()[];
/*()[I*/
public void setCounts(int[]);
/*([I)V*/
public void throwExcp() throws java.lang.IllegalAccessException;
/*()V*/
static {};
/*()V*/
}
我们看到类中每个属性和方法下面都有一段注释。注释中不包含空格的内容就是第四个参数要填的内容(关于javap具体参数请查询JDK的使用帮助)。下面这段代码演示如何访问jni.test.Demo的getMessage方法:
/*假设我们已经有一个jni.test.Demo的实例obj*/
jmethodID mid;
jclass cls = (*env)-> GetObjectClass (env, obj);
//获取实例的类定义
mid=(*env)->GetMethodID(env,cls,"getMessage","()Ljava/lang/String;");
/*如果mid为0表示获取方法定义失败*/
jstring msg = (*env)-> CallObjectMethod(env, obj, mid);
/*如果该方法是静态的方法那只需要将最后一句代码改为以下写法即可:
jstring msg = (*env)-> CallStaticObjectMethod(env, cls, mid);
*/
3.调用方法
为了调用对象的某个方法,可以使用函数CallMethod或者CallStaticMethod(访问类的静态方法),根据不同的返回类型而定。这些方法都是使用可变参数的定义,如果访问某个方法需要参数时,只需要把所有参数按照顺序填写到方法中就可以。在讲到构造函数的访问时,将演示如何访问带参数的构造函数。
访问类属性
访问类的属性与访问类的方法大体上是一致的,只不过是把方法变成属性而已。
1.获取指定对象的类(jclass)
这一步与访问类方法的第一步完全相同,具体使用参看访问类方法的第一步。
2.读取类属性的定义(jfieldID)
在JNI中是这样定义获取类属性的方法的:
jfieldID (JNICALL *GetFieldID)
(JNIEnv *env, jclass clazz,const char *name, const char *sig);
jfieldID (JNICALL *GetStaticFieldID)
(JNIEnv *env, jclass clazz,const char *name, const char *sig);
这两个函数中第一个参数为JNI环境;clazz为类的定义;name为属性名称;第四个参数同样是为了表达属性的类型。前面我们使用javap工具获取类的详细定义的时候有这样两行:
public java.lang.String msg;
/*Ljava/lang/String;*/
其中第二行注释的内容就是第四个参数要填的信息,这跟访问类方法时是相同的。
3.读取和设置属性值
有了属性的定义要访问属性值就很容易了。有几个方法用来读取和设置类的属性,它们是:GetField、SetField、GetStaticField、SetStaticField。比如读取Demo类的msg属性就可以用GetObjectField,而访问COUNT用GetStaticIntField,相关代码如下:
jfieldID field = (*env)->GetFieldID(env,obj,"msg"," Ljava/lang/String;");
jstring msg = (*env)-> GetObjectField(env, cls, field);
//msg就是对应Demo的msg
jfieldID field2 = (*env)->GetStaticFieldID(env,obj,"COUNT","I");
jint count = (*env)->GetStaticIntField(env,cls,field2);
访问构造函数
很多人刚刚接触JNI的时候往往会在这一节遇到问题,查遍了整个jni.h看到这样一个函数NewObject,它应该是可以用来访问类的构造函数。但是该函数需要提供构造函数的方法定义,其类型是jmethodID。从前面的内容我们知道要获取方法的定义首先要知道方法的名称,但是构造函数的名称怎么来填写呢?其实访问构造函数与访问一个普通的类方法大体上是一样的,惟一不同的只是方法名称不同及方法调用时不同而已。访问类的构造函数时方法名必须填写“”。下面的代码演示如何构造一个Demo类的实例:
jclass cls = (*env)->FindClass(env, "jni/test/Demo");
/**
首先通过类的名称获取类的定义,
相当于Java中的Class.forName方法
*/
if (cls == 0)
<error handler>
jmethodID mid = (*env)->GetMethodID(env,cls,"<init>","(Ljava/lang/String;)V ");
if(mid == 0)
<error handler>
jobject demo = jenv->NewObject(cls,mid,0);
/**
访问构造函数必须使用NewObject的函数来调用前面获取的构造函数的定义上面的代码我们构造了一个
Demo的实例并传一个空串null
*/
数组处理
创建一个新数组
要创建一个数组,我们首先应该知道数组元素的类型及数组长度。JNI定义了一批数组的类型jArray及数组操作的函数NewArray,其中就是数组中元素的类型。例如,要创建一个大小为10并且每个位置值分别为1-10的整数数组,编写代码如下:
int i = 1;
jintArray array;
//定义数组对象
(*env)-> NewIntArray(env, 10);
for(; i<= 10; i++)
(*env)->SetIntArrayRegion(env, array, i-1, 1, &i);
访问数组中的数据
访问数组首先应该知道数组的长度及元素的类型。现在我们把创建的数组中的每个元素值打印出来,代码如下:
int i;
/* 获取数组对象的元素个数 */
int len = (*env)->GetArrayLength(env, array);
/* 获取数组中的所有元素 */
jint* elems = (*env)-> GetIntArrayElements(env, array, 0);
for(i=0; i< len; i++)
printf("ELEMENT %d IS %d\n",i, elems[i]);
中文处理
中文字符的处理往往是让人比较头疼的事情,特别是使用Java语言开发的软件,在JNI这个问题更加突出。由于Java中所有的字符都是Unicode编码,但是在本地方法中,例如用VC编写的程序,如果没有特殊的定义一般都没有使用Unicode的编码方式。为了让本地方法能够访问Java中定义的中文字符及Java访问本地方法产生的中文字符串,我定义了两个方法用来做相互转换。
方法一,将Java中文字符串转为本地字符串
/**
第一个参数是虚拟机的环境指针
第二个参数为待转换的Java字符串定义
第三个参数是本地存储转换后字符串的内存块
第三个参数是内存块的大小
*/
int JStringToChar(JNIEnv *env, jstring str,LPTSTR desc, int desc_len){
int len = 0;
if(desc==NULL||str==NULL)return -1;
//在VC中wchar_t是用来存储宽字节字符(UNICODE)的数据类型
wchar_t *w_buffer = new wchar_t[1024];
ZeroMemory(w_buffer,1024*sizeof(wchar_t));
//使用GetStringChars而不是GetStringUTFChars
wcscpy(w_buffer,env->GetStringChars(str,0));
env->ReleaseStringChars(str,w_buffer);
ZeroMemory(desc,desc_len);
//调用字符编码转换函数(Win32 API)将UNICODE转为ASCII编码格式字符串
len = WideCharToMultiByte(CP_ACP,0,w_buffer,1024,desc,desc_len,NULL,NULL);
//len = wcslen(w_buffer);
if(len>0 && len<desc_len) desc[len]=0;
delete[] w_buffer;
return strlen(desc);
}
方法二,将C的字符串转为Java能识别的Unicode字符串
jstring NewJString(JNIEnv* env,LPCTSTR str){
if(!env || !str) return 0;
int slen = strlen(str);
jchar* buffer = new jchar[slen];
int len = MultiByteToWideChar(CP_ACP,0,str,strlen(str),buffer,slen);
if(len>0 && len < slen) buffer[len]=0;
jstring js = env->NewString(buffer,len);
delete [] buffer;
return js;
}
异常
由于调用了Java的方法,因此难免产生操作的异常信息。这些异常没有办法通过C++本身的异常处理机制来捕捉到,但JNI可以通过一些函数来获取Java中抛出的异常信息。之前我们在Demo类中定义了一个方法throwExcp,下面将访问该方法并捕捉其抛出来的异常信息,代码如下:
/**
假设我们已经构造了一个Demo的实例obj,其类定义为cls
*/
jthrowable excp = 0;
/* 异常信息定义 */
jmethodID mid=(*env)->GetMethodID(env,cls,"throwExcp","()V");
/*如果mid为0表示获取方法定义失败*/
jstring msg = (*env)-> CallVoidMethod(env, obj, mid);
/* 在调用该方法后会有一个IllegalAccessException的异常抛出 */
excp = (*env)->ExceptionOccurred(env);
if(excp){
(*env)->ExceptionClear(env);
//通过访问excp来获取具体异常信息
/*
在Java中,大部分的异常信息都是扩展类java.lang.Exception,
因此可以访问excp的toString或者getMessage来获取异常信息的内容。
访问这两个方法同前面讲到的如何访问类的方法是相同的。
*/
}
线程和同步访问
有些时候需要使用多线程的方式来访问Java的方法。我们知道一个Java虚拟机是非常消耗系统的内存资源,差不多每个虚拟机需要内存大约在20MB左右。为了节省资源要求每个线程使用的是同一个虚拟机,这样在整个的JNI程序中只需要初始化一个虚拟机就可以了。所有人都是这样想的,但是一旦子线程访问主线程创建的虚拟机环境变量,系统就会出现错误对话框,然后整个程序终止。
其实这里面涉及到两个概念,它们分别是虚拟机(JavaVM *jvm)和虚拟机环境(JNIEnv *env)。真正消耗大量系统资源的是jvm而不是env,jvm是允许多个线程访问的,但是env只能被创建它本身的线程所访问,而且每个线程必须创建自己的虚拟机环境env。
这时候会有人提出疑问,主线程在初始化虚拟机的时候就创建了虚拟机环境env。为了让子线程能够创建自己的env,JNI提供了两个函数:AttachCurrentThread和DetachCurrentThread。下面代码就是子线程访问Java方法的框架:
DWORD WINAPI ThreadProc(PVOID dwParam)
{
JavaVM jvm = (JavaVM*)dwParam;
/* 将虚拟机通过参数传入 */
JNIEnv* env;
(*jvm)-> AttachCurrentThread(jvm, (void**)&env, NULL);
.........
(*jvm)-> DetachCurrentThread(jvm);
}
时间
关于时间的话题是我在实际开发中遇到的一个问题。当要发布使用了JNI的程序时,并不一定要求客户要安装一个Java运行环境,因为可以在安装程序中打包这个运行环境。为了让打包程序利于下载,这个包要比较小,因此要去除JRE(Java运行环境)中一些不必要的文件。
但是如果程序中用到Java中的日历类型,例如java.util.Calendar等,那么有个文件一定不能去掉,这个文件就是[JRE]\lib\tzmappings。它是一个时区映射文件,一旦没有该文件就会发现时间操作上经常出现与正确时间相差几个小时的情况。下面是打包JRE中必不可少的文件列表(以Windows环境为例),其中[JRE]为运行环境的目录,同时这些文件之间的相对路径不能变。
文件名 目录
hpi.dll [JRE]\bin
ioser12.dll [JRE]\bin
java.dll [JRE]\bin
net.dll [JRE]\bin
verify.dll [JRE]\bin
zip.dll [JRE]\bin
jvm.dll [JRE]\bin\classic
rt.jar [JRE]\lib
tzmappings [JRE]\lib
由于rt.jar有差不多10MB,但是其中有很大一部分文件并不需要,可以根据实际的应用情况进行删除。例如程序a如果没有用到Java Swing,就可以把涉及到Swing的文件都删除后重新打包。