Java跨平臺的特性使Java越來越受開發人員的歡迎,但也往往會聽到不少的抱怨:用Java
開發的圖形用戶窗口界面每次在啟動的時候都會跳出一個控制臺窗口,這個控制臺窗口讓
本來非常棒的界面失色不少。怎么能夠讓通過Java開發的GUI程序不彈出Java的控制臺窗口
呢?其實現在很多流行的開發環境例如JBuilder、Eclipse都是使用純Java開發的集成環境
。這些集成環境啟動的時候并不會打開一個命令窗口,因為它使用了JNI(Java Native I
nterface)的技術。通過這種技術,開發人員不一定要用命令行來啟動Java程序,可以通
過編寫一個本地GUI程序直接啟動Java程序,這樣就可避免另外打開一個命令窗口,讓開發
的Java程序更加專業。
JNI允許運行在虛擬機的Java程序能夠與其它語言(例如C和C++)編寫的程序或者類庫進行
相互間的調用。同時JNI提供的一整套的API,允許將Java虛擬機直接嵌入到本地的應用程
序中。圖1是Sun站點上對JNI的基本結構的描述。
圖1 JNI基本結構描述圖
本文將介紹如何在C/C++中調用Java方法,并結合可能涉及到的問題介紹整個開發的步驟及
可能遇到的難題和解決方法。本文所采用的工具是Sun公司創建的 Java Development Kit
(JDK) 版本 1.3.1,以及微軟公司的Visual C++ 6開發環境。
環境搭建
為了讓本文以下部分的代碼能夠正常工作,我們必須建立一個完整的開發環境。首先需要
下載并安裝JDK 1.3.1,其下載地址為“http://java.sun.com”。假設安裝路徑為C:\JD
K。下一步就是設置集成開發環境,通過Visual C++ 6的菜單Tools→Options打開選項對話
框如圖2。
圖2 設置集成開發環境圖
將目錄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旅程能夠順利進行,還必須先準備一個Ja
va類。在這個類中將用到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(":" + 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。通過這些A
PI可以很方便地將虛擬機加載到內存中。創建虛擬機可以用函數 jint JNI_CreateJavaVM
(JavaVM **pvm, void **penv, void *args)。對于這個函數有一點需要注意的是,在JDK
1.1中第三個參數總是指向一個結構JDK1_ 1InitArgs, 這個結構無法完全在所有版本的
虛擬機中進行無縫移植。在JDK 1.2中已經使用了一個標準的初始化結構JavaVMInitArgs來
替代JDK1_1InitArgs。下面我們分別給出兩種不同版本的示例代碼。
在JDK 1.1初始化虛擬機:
#include
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
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就是初始化虛擬機得到的J
NI環境;第二個參數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的使用幫助)。下面這段代碼演示如何訪問jn
i.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。從前面的內容我們知道要獲取方法的定義首先要知道方法的名稱
,但是構造函數的名稱怎么來填寫呢?其實訪問構造函數與訪問一個普通的類方法大體上
是一樣的,惟一不同的只是方法名稱不同及方法調用時不同而已。訪問類的構造函數時方
法名必須填寫“”(注, 我使用的時候發現應該是設置為NULL)。下面的代碼演示如何構造一個Demo類的實例:
jclass cls = (*env)->FindClass(env, "jni/test/Demo");
/**
首先通過類的名稱獲取類的定義,相當于Java中的Class.forName方法
*/
if (cls == 0)
jmethodID mid = (*env)->GetMethodID(env,cls,"","(Ljava/lang/String;)V ");
if(mid == 0)
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編碼格式字符串
//關于函數WideCharToMultiByte的使用請參考MSDN
len = WideCharToMultiByte(CP_ACP,0,w_buffer,1024,desc,desc_len,NULL,NULL);
//len = wcslen(w_buffer);
if(len>0 && 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的toS
tring
或者getMessage來獲取異常信息的內容。訪問這兩個方法同前面講到的如何訪問類的方法
是相同的。
*/
}
線程和同步訪問
有些時候需要使用多線程的方式來訪問Java的方法。我們知道一個Java虛擬機是非常消耗
系統的內存資源,差不多每個虛擬機需要內存大約在20MB左右。為了節省資源要求每個線
程使用的是同一個虛擬機,這樣在整個的JNI程序中只需要初始化一個虛擬機就可以了。所
有人都是這樣想的,但是一旦子線程訪問主線程創建的虛擬機環境變量,系統就會出現錯
誤對話框,然后整個程序終止。
其實這里面涉及到兩個概念,它們分別是虛擬機(JavaVM *jvm)和虛擬機環境(JNIEnv *e
nv)。真正消耗大量系統資源的是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,但是其中有很大一部分文件并不需要,可以根據實際的應用情
況進行刪除。例如程序如果沒有用到Java Swing,就可以把涉及到Swing的文件都刪除后重
新打包。