目錄
第一章 介紹
第二章 設計機制
第三章 JNI類型和數據結構
第四章 JNI函數(1)
第四章 JNI函數(2)
第四章 JNI函數(3)
第四章 JNI函數(4)
第五章 Invocation API
第二章 設計機制
本章主要描述JNI的主要設計問題。很多本章討論的設計問題和本地方法相關。Invocation API的相關設計問題在第五章中進行描述。
2.1 JNI接口函數和指針
本地代碼訪問Java虛擬機的特性是靠調用JNI函數來實現的。JNI函數可以通過一個接口指針(interface pointer)來訪問到。一個接口指針是指向指針的指針,它指向的指針(pre-thread JNI data stucture)又指向一個指針的列表,其中每個指針都指向一個接口方法(interface function)。
JNI interface pointer -> Pointer -> Array of pointers to JNI functions -> interface function
JNI Interface的組織形式類似于c++ 虛方法table或一個COM接口。使用interface table,而不使用硬連接到函數實體的好處是,隔離了JNI名稱空間和本地代碼。一個虛擬機可以簡單的提供多個版本的JNI函數表。例如一個虛擬機可以提供兩個函數表:
一個執行嚴格非法參數檢查,并且便于測試。
另外一個執行JNI標準要求的最小的檢查,使得更加高效。
JNI interface pointer只在當前線程有效,本地方法絕對不能將JNI interface pointer傳遞給其他線程。Java虛擬機對于JNI的實現可能使用了Thread-Local數據,所以不能跨線程。
本地方法以參數的形式接收到JNI interface pointer。Java虛擬機確保了同一個線程多次調用本地方法的時候,這些本地方法都接受到同一個JNI interface pointer對象。但在多線程環境下,一個本地方法可能被多個線程調用,那么傳入的JNI interface pointers就不一定是同一個對象了。
2.2 編譯,載入和鏈接本地方法
因為Java虛擬機是多線程的,因此本地庫應該使用支持多線程的編譯器來編譯和鏈接。例如使用SUN studio編譯器時應該使用 -mt
flag來編譯C++代碼。使用GNU gcc編譯器的時候應該使用 D_REENTRANT
或 ``D_POSIX_C_SOURCE` flag來編譯。更多信息請查閱編譯器文檔。
本地方法使用 System.loadLibrary
方法來載入到Java虛擬機。例如下面的例子,在類加載的時候載入一個本地庫,其中擁有一個本地方法:
package pkg;
class Cls {
static {
System.loadLibrary("pkg_Cls");
}
public native double nativeFunction(int i, String s);
}
System.loadLibaray
方法的參數為任意本地庫的名字。系統允許使用標準,但符合系統標準,來將庫的名字轉換成本地庫文件的名字。例如在Solaris系統上,會將 pkg_Cls
名字轉換成 pkg_Cls.so
, 而在windows系統上,會將其轉換為 pkg_Cls.dll
。
開發者可以使用一個本地庫來放置所有的本地方法,只要是同一個class loader載入的類都可以訪問到這些本地的方法。Java虛擬機內部為每個class Loader都維護一組本地庫。開發商應該謹慎選擇庫的名稱來避免名字沖突。
如果當前操作系統不支持動態鏈接(dynamic linking), 所有本地方法必須預鏈接(prelinked)到虛擬機。這種情況下,虛擬機在執行 System.loadLibrary
的時候不會真正的載入本地庫。
開發者也可以使用JNI函數 RegisterNatives()
來注冊將一個本地方法注冊到指定的類上面去。這個函數在靜態鏈接函數(statically linked functions)的時候會特別有用。
據說RegisterNatives的方法會比傳統的的方法效率更好,需要研究一下。
2.3 本地方法的命名規范
動態鏈接定位函數靠的是它們的函數名。 一個本地方法的函數名分為如下幾個部分:
-
Java_
的前綴 - 用
_
為分隔符的類名全稱,例如com_package_SomeClass
- 一個
_
分隔符 - 方法名
- 對于重載方法(overload),后面還有跟兩個下劃線,以及參數簽名。(因為C的函數簽名只有函數名,而Java的方法簽名除了方法名,還有參數,避免沖突所以重載方法需要加上后綴避免沖突)
Java虛擬機會在本地庫里面尋找函數名,虛擬機會首先查找短名稱,即不包括參數的方法簽名(僅方法名)。然后才會去找長名稱,即包括參數的方法簽名(方法名+參數列表)。開發者應該只在存在兩個本地方法被重載的時候才使用長名稱。如果一個是本地方法,而另一個重載方式是Java方法,則不會有問題,因為Java方法不在本地庫中。
<u>以下為個人理解部分:</u>
對于長名字,即重載的情況解釋比較難理解,這里我來舉一個例子來看,例如有幾個本地方法和Java方法存在重載情況:(這個例子不是官方文檔里面的)
public native String echo(String arg0);
public native String echo(String arg0, String arg1);
// Java方法和Native方法的重載不會互相影響
public String echo(String arg0, String arg1, String arg2) {
return null;
}
這個例子里面一共有三個重載方法,其中兩個本地方法,一個Java方法。
首先Java方法和本地方法之間的重載不會影響JNI方法的簽名寫法,但兩個本地方法之間重載,因為方法名相同,會造成函數名沖突,所以必須使用增加了參數后綴的長方法名。例如:
/*
* Class: test_lds_com_androidndktest_NdkTestJava
* Method: echo
* Signature: (Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_test_lds_com_androidndktest_NdkTestJava_echo__Ljava_lang_String_2
(JNIEnv *, jobject, jstring);
/*
* Class: test_lds_com_androidndktest_NdkTestJava
* Method: echo
* Signature: (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_test_lds_com_androidndktest_NdkTestJava_echo__Ljava_lang_String_2Ljava_lang_String_2
(JNIEnv *, jobject, jstring, jstring);
<u>以上為個人理解部分。</u>
對于一些特殊字符,使用轉義字符來代替,例如作為分隔符的下劃線如果在方法名中,則會被替換成 _1
轉義字符 | 含義 |
---|---|
_0XXXX | Unicode字符 |
_1 | 下劃線 _
|
_2 | 分號 ;
|
_3 | 中括號 [
|
對于本地方法和interface api都遵循當前系統平臺的標準函數調用約定(calling convention)。例如 UNIX 系統上使用C語言的調用約定,而在window系統上,使用個__stdcall
約定。
2.4 本地方法參數
所有本地方法的第一個參數都是JNI接口指針(JNI interface pointer),它的類型是 JNIEnv
。第二個參數根據本地方法是否為靜態的而不一樣。如果不是靜態的本地方法,第二個參數則是一個對象的引用,如果是靜態的本地方法,第二個參數則是一個Java類的引用。
剩下的參數就是一一對應Java方法的參數列表。本地方法也可以通過返回值來返回其結果。
例如Java的本地方法定義如下:
package Pkg;
class Cls {
native double f(int i, String s);
}
則C函數的長名字則為 Java_pkg_Cls_f__ILjava_Lang_String2
, 它的實現則為:
jdouble Java_Pkg_Cls_f__ILJava_Lang_String2(
JNIEnv *env, /* JNI Interface Pointer */
jobject obj, /* "this" 指針 */
jint i, /* java方法 arg0 */
jstring s) /* java方法 arg1 */
{
// 轉換一個Java String對象到C風格的字符串數組
const char *str = (*env)->GetStrinUTFChars(env, s, 0);
// 釋放str
(*env)->ReleaseStrinUTFChars(env, s, str);
return 0;
}
使用C++來實現,則可以寫稍后簡潔一點的代碼:
extern "C"
jdouble Java_Pkg_Cls_f__ILJava_Lang_String2 (
JNIEnv *env, /* JNI Interface Pointer */
jobject obj, /* "this" 指針 */
jint i, /* java方法 arg0 */
jstring s) /* java方法 arg1 */
{
const char *str = env->GetStringUTFChars(s, 0);
env->ReleaseStringUTFChars(s, str);
return 0;
}
2.5 引用傳遞Java對象
基本類型,例如int,char等這種個,是在Java方法和本地方法之間互相拷貝,即值傳遞。而對于Java對象,則不太一樣,它們是引用傳遞。
Java虛擬機必須時刻跟蹤傳遞給本地代碼的所有Java對象,這樣這些對象才不會被垃圾收集器釋放掉。因此本地方法有一種途徑來告訴虛擬機有些對象不再被使用了,可以被回收了。此外,本地方法還應該能讓垃圾回收器移動對象的引用。(the garbage collector must be able to move an object referred to by the native code)
2.6 全局和本地引用
JNI將本地代碼使用對象引用分為兩種:
- 本地引用:本地引用的生命周期只在本地方法被調用的有效,退出方法的時候自動釋放掉。
- 全局引用:全局引用的生命周期會一直持續到明確的釋放它的時候。
這里可以理解,在Java層新建對象分配內存,有GC來收集,而由本地方法新建的對象,則無法由GC來管理,因此必須自己來管理它們。
而對于本地引用,有點類似Java棧里面的局部變量,進入方法時棧幀里分配內存,退出方法棧幀的時候自動釋放內存,因此這部分的內存其實是不需要GC來管理。
而對于全部引用,應該遵循誰分配誰釋放的約定,由本地方法分配內存,并由本地方法釋放內存。
Java對象是作為局部引用來傳遞給本地方法的。所有JNI函數返回的Java對象也是局部引用。JNI允許開發者基于局部引用來創建全局引用。JNI方法期望Java對象接受全局或局部引用。一個本地方法可能返回給虛擬機一個局部引用或全局引用的結果。
一般情況下,開發者應該主要依靠虛擬機在本地方法退出的時候自動釋放局部引用。但也有幾種情況下開發者應該明確的釋放局部引用。例如:
- 一個本地方法訪問一個超大的Java對象,因此會為這個Java對象創建一個局部引用。本地方法還需要在返回之前做一些其他計算,而這種因為有一個局部引用指向這個Java對象,因此垃圾回收器不能回收掉這個大的對象。而這個對象已經沒有用了,因此需要本地方法明確的去釋放它,來使得這個java對象可以被釋放掉。
- 一個本地方法創建了大量的局部引用,但不是同時都需要它們。因此虛擬機需要大量空間來持續跟蹤這些局部引用,所以創建大量的局部引用可能會造成系統內部不足。例如一個本地放在在for循環一個大的數組,數組里的元素以局部引用的形式引用,在遍歷的時候一次操作一個元素,而在遍歷結束以后,開發者已經不再需要這個數組里面的元素的局部引用了。
JNI允許開發者在任何時間使用本地方法來刪除局部引用。為了確保開發者可以手動釋放這些布局引用,JNI函數不允許創建額外的局部引用,除了作為結果返回的引用。
局部引用只在它們被創建的線程中有效,本地代碼絕對將一個局部引用從一個線程傳給另一個線程。
2.7 局部引用的實現
為了實現局部變量,Java虛擬機創建了一個注冊表來記錄從Java到本地方法傳遞。一個注冊表映射了一個不可移動的布局變量到一個Java對象,并防止這些Java對象被垃圾回收器回收。所有被傳遞給本地方法的Java對象(包括JNI函數返回的那些對象)都會被自動加入注冊表。當本地方法退出的時候自動將它們從注冊表中刪除,來使其可以被垃圾回收器收回。
對于注冊表,有不同的方式來實現,例如使用table, 鏈表,hash table等。盡管計算引用計數可能對于避免重復講同一個對象添加注冊表,但JNI實現沒有一路去檢測和避免重復對象。
注意,局部引用不能通過保守掃描native stack來完全實現,因為本地代碼可能將局部引用存儲到全局或堆數據結構里面。
2.8 訪問Java對象
JNI 提供豐富的訪問函數來訪問全局和局部引用。這意味著無論Java虛擬機在內部如何描述一個Java對象,本地方法的實現都會其作用,也就是為什么JNI可以被大量不同的虛擬機支持的重要原因。
通過模糊的引用來使用訪問器函數肯定會直接使用C數據結構體的開銷要大。但我們相信,在大多數情況下,Java開發者使用本地方法是用來處理不容易的任務,因此可能忽略接口造成的開銷。
2.9 訪問基本類型數組
對于包括很多基本數據類型的大Java對象,例如integer array或strings,其造成的額外開銷是不可接受的。遍歷一個Java數組并處理其中每個元素是非常低效的。
解決這個問題的辦法是引入一個叫做 pinning
的概念,使得本地方法可以向Java虛擬機要求將數組的內容pin down出來。本地方法可以直接接收到這些元素的直接指針(direct pointer),為了實現這個目的,因此有兩個影響:
- 垃圾收集器必須支持
pinning
- 虛擬機必須用連續的內存空間來放置基本類型的數組。
因此我們可以采取妥協的方法來解決上面的兩個問題:
首先,我們提供一組函數用來在Java數組和本地方法buffer之間復制基本類型數組的元素。如果本地方法只需要訪問一個大數組中的一小部分元素則可以使用這些函數。
然后,開發者可以使用另外一組函數來獲取 pinned-down
版本的數組元素,記住這些函數可能需要JAVA虛擬機來分配和復制數組。至于是否需要復制數組則取決于虛擬機的實現:
- 如果垃圾收集器支持
pinning
,則不需要復制數組。 - 另一種情況下數組則會被復制到不可移動的內存空間(例如C heap),返回一份復制后的數組指針。
最后,接口提供函數來通知虛擬機,本地代碼不再需要這些數組里的元素了。當你調用了這些函數,系統要么 unpins
數組,或者釋放復制的數組,并引用到之前的數組(即逆操作)。
我們的目標是提供靈活性。垃圾回收器的算法可以根據不同的情況決定是 pinning
還是 copying
數組。因此,它可以在小對象數組的時候選擇copy,而在大對象的時候選擇pin。
JNI的實現必須確保在不同線程的原生方法可以同時訪問到同一個數組。例如JNI必須在內部保留一份計數器來計算每一個 pinned
的數組,從而使得一個線程不會 unpin
一個被另一個線程 pinned
的數組。記住JNI不需要使用本地方法來鎖住基本類型數組,同時從不同的線程來修改數組可能造成不確定的結果。
2.10 訪問域和方法
JNI允許本地方法來訪問Java對象的成員域和成員方法。JNI以符號名和類型簽名來ID成員域和成員方法。例如定位到一個Java方法:
例如Java類為:
class Cls {
public double f(String input);
}
則原生代碼可先定位到該Java方法(根據其名字和簽名):
jmethodID method_id = env->GetMethodID(cls, "f", "(ILjava/lang/String;)D");
然后原生代碼即可使用method id來調用該方法:
jdouble result = env->CallDoubleMethod(obj, method_id, 10, str);
除了
CallDoubleMethod
之外還是有一系列的同類函數來調用不同類型返回值的Java方法。
2.11 report程序錯誤
JNI并不會檢查程序錯誤,例如空指針或者不合法的參數類型。JNI不檢查這些程序錯誤的原因是:
- 強制JNI函數去檢查所有可能的錯誤可能會導致正常運行的本地方法性能下降。
- 在很多情況下,沒有足夠的運行時類型信息來執行這些檢查。
很多C語言的庫并不會防范程序錯誤,例如printf函數,它通常在接收到一個非法的地址時,不會返回一個錯誤碼,而是直接拋出運行時異常。強制要求C庫的函數去檢查所有可能的異常情況可能會導致這種檢查代碼的重復--一份在用戶的代碼里,一份在庫的代碼里。
開發者絕對不能傳入非法指針或錯誤參數給JNI函數。這樣做可能會導致不確定的結果,例如系統狀態或虛擬機崩潰。
2.12 Java異常
JNI允許本地方法去拋出普通的Java異常。本地代碼也可以處理Java方法拋出的異常。如果Java異常沒有被捕捉則會重新傳回給虛擬機。
2.13 異常和錯誤碼
某些JNI函數會使用Java異常機制來報告異常情況。 在大多數情況下,JNI函數通過返回一個錯誤碼并且拋出一個Java異常來報告異常情況。這個錯誤碼通過是超出正常返回的值(例如NULL)。因此,開發者可以:
- 快速的檢查最后JNI調用的返回值來確定錯誤的發生情況。
- 通過調用
ExceptionOccurred()
來獲取關于錯誤更多的異常詳情。
有兩種情況下,開發者需要檢查異常詳情,而不是快速的檢查返回的錯誤碼:
- JNI函數調用一個Java方法來返回一個結果。則開發者必須調用
ExceptionOccurred()
來檢查是否在Java方法執行的過程中出現異常情況。 - 有些JNI數組訪問函數沒有返回錯誤碼,但可能會拋出
ArrayIndexOutOfBoundsException
或ArrayStoreException
其他的所有情況下,當沒有返回一個錯誤碼的時候,則確保了不會有被異常拋出。
2.14 異步異常
在多線程環境下,線程可能拋出一個異步的異常。一個異步的異常并不會馬上影響到當前線程中正在執行的本地代碼,直到:
- 本地方法調用一個JNI函數可能拋出一個同步的異常。
- 本地方法主動調用
ExceptionOccurred
來明確的檢查同步和異常異常。
本機方法應該將 ExceptionOccurred()
檢查插入到必要的位置(例如,在沒有其他異常檢查的循環中),以確保當前線程在合理的時間內響應異步異常。
2.15 異常捕捉
有兩種方法在本地代碼中捕捉異常:
- 本地方法可以選擇立即返回,促使異常拋會給Java方法,讓其自己處理該異常。
- 本地方法可以調用
ExceptionClear
來清除異常,然后執行自己的異常處理代碼。
在一個異常被拋出時,本地代碼首先必須在其他JNI調用前清除異常,當有一個待處理的異常時,JNI函數可以安全的調用如下方法:
ExceptionOccurred()
ExceptionDescribe()
ExceptionClear()
ExceptionCheck()
ReleaseStringChars()
ReleaseStringUTFChars()
ReleaseStringCritical()
Release<Type>ArrayElements()
ReleasePrimitiveArrayCritical()
DeleteLocalRef()
DeleteGlobalRef()
DeleteWeakGlobalRef()
MonitorExit()
PushLocalFrame()
PopLocalFrame()