《Java 本地接口規範》- 設計概述
設計概述
本章著重討論 JNI 中的主要設計問題,其中的大部分問題都與本地方法有關。調用 API 的設計將在 第 5 章 “調用 API” 中討論。
JNI 接口函數和指針
平台相關代碼是通過調用 JNI 函數來訪問 Java 虛擬機功能的。JNI 函數可通過接口指針來獲得。接口指針是指針的指針,它指向一個指針數組,而指針數組中的每個元素又指向一個接口函數。每個接口函數都處在數組的某個預定偏移量中。圖 2-1 說明了接口指針的組織結構。
圖 2-1 接口指針
JNI 接口的組織類似於 C++ 虛擬函數表或 COM 接口。使用接口表而不使用硬性編入的函數表的好處是使 JNI 名字空間與平台相關代碼分開。虛擬機可以很容易地提供多個版本的 JNI 函數表。例如,虛擬機可支持以下兩個 JNI 函數表:
JNI 接口指針隻在當前線程中有效。因此,本地方法不能將接口指針從一個線程傳遞到另一個線程中。實現 JNI 的虛擬機可將本地線程的數據分配和儲存在 JNI 接口指針所指向的區域中。
本地方法將JNI 接口指針當作參數來接受。虛擬機在從相同的 Java 線程中對本地方法進行多次調用時,保證傳遞給該本地方法的接口指針是相同的。但是,一個本地方法可被不同的 Java 線程所調用,因此可以接受不同的 JNI 接口指針。
加載和鏈接本地方法
對本地方法的加載通過 System.loadLibrary
方法實現。下例中,類初始化方法加載了一個與平台有關的本地庫,在該本地庫中給出了本地方法f
的定義:
package pkg;
class Cls {
native double f(int i, String s);
static {
System.loadLibrary("pkg_Cls");
}
}
System.loadLibrary 的參數是程序員任意選取的庫名。係統按照標準的但與平台有關的處理方法將該庫名轉換為本地庫名。例如,Solaris 係統將名稱pkg_Cls
轉換為libpkg_Cls.so
,而 Win32 係統將相同的名稱
pkg_Cls
轉換為pkg_Cls.dll
。
程序員可用單個庫來存放任意數量的類所需的所有本地方法,隻要這些類將被相同的類加載器所加載。虛擬機在其內部為每個類加載器保護其所加載的本地庫清單。提供者應該盡量選擇能夠避免名稱衝突的本地庫名。
如果底層操作係統不支持動態鏈接,則必須事先將所有的本地方法鏈接到虛擬機上。這種情況下,虛擬機實際上不需要加載庫即可完成System.loadLibrary
調用。
程序員還可調用 JNI 函數 RegisterNatives()
來注冊與類關聯的本地方法。在與靜態鏈接的函數一起使用時,RegisterNatives()
函數將特別有用。
解析本地方法名
動態鏈接程序是根據項的名稱來解析各項的。本地方法名由以下幾部分串接而成:
虛擬機將為本地庫中的方法查找匹配的方法名。它首先查找短名(沒有參數簽名的名稱),然後再查找帶參數簽名的長名稱。隻有當某個本地方法被另一個本地方法重載時程序員才有必要使用長名。但如果本地方法的名稱與非本地方法的名稱相同,則不會有問題。因為非本地方法(Java 方法)並不放在本地庫中。
下例中,不必用長名來鏈接本地方法 g
,因為另一個方法 g
不是本地方法,因而它並不在本地庫中。
class Cls1 {
int g(int i);
native int g(double d);
}
我們采取簡單的名字攪亂方案,以保證所有的 Unicode 字符都能被轉換為有效的 C 函數名。我們用下劃線(“_”) 字符來代替全限定的類名中的斜杠(“/”)。由於名稱或類型描述符從來不會以數字打頭,我們用_0
、...、_9
來代替轉義字符序列,如表
2-1所示:
表 2-1 Unicode 字符轉換 |
|
字符“_” |
|
本地方法和接口 API 都要遵守給定平台上的庫調用標準約定。例如,UNIX 係統使用 C 調用約定,而 Win32 係統使用 __stdcall。
本地方法的參數
JNI 接口指針是本地方法的第一個參數。其類型是 JNIEnv。第二個參數隨本地方法是靜態還是非靜態而有所不同。非靜態本地方法的第二個參數是對對象的引用,而靜態本地方法的第二個參數是對其 Java 類的引用。
其餘的參數對應於通常 Java 方法的參數。本地方法調用利用返回值將結果傳回調用程序中。第 3 章 “JNI 的類型和數據結構” 將描述 Java 類型和 C 類型之間的映射。
代碼示例 2-1 說明了如何用 C 函數來實現本地方法f
。對本地方法f
的聲明如下:
package pkg;
class Cls {
native double f(int i, String s);
...
}
具有長 mangled 名稱 Java_pkg_Cls_f_ILjava_lang_String_2
的 C 函數實現本地方法f
:
代碼示例 2-1: 用 C 實現本地方法
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env, /* 接口指針 */
jobject obj, /* “this”指針 */
jint i, /* 第一個參數 */
jstring s) /* 第二個參數 */
{
/* 取得 Java 字符串的 C 版本 */
const char *str = (*env)->GetStringUTFChars(env, s, 0);
/* 處理該字符串 */
...
/* 至此完成對 str 的處理 */
(*env)->ReleaseStringUTFChars(env, s, str);
return ...
}
注意,我們總是用接口指針 env 來操作 Java 對象。可用 C++ 將此代碼寫得稍微簡潔一些,如代碼示例 2-2 所示:
代碼示例 2-2: 用 C++ 實現本地方法
extern "C" /* 指定 C 調用約定 */
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env, /* 接口指針 */
jobject obj, /* “this”指針 */
jint i, /* 第一個參數 */
jstring s) /* 第二個參數 */
{
const char *str = env->GetStringUTFChars(s, 0);
...
env->ReleaseStringUTFChars(s, str);
return ...
}
使用 C++ 後,源代碼變得更為直接,且接口指針參數消失。但是,C++ 的內在機製與 C 的完全一樣。在 C++ 中,JNI 函數被定義為內聯成員函數,它們將擴展為相應的 C 對應函數。
引用 Java 對象
基本類型(如整型、字符型等)在 Java 和平台相關代碼之間直接進行複製。而 Java 對象由引用來傳遞。虛擬機必須跟蹤傳到平台相關代碼中的對象,以使這些對象不會被垃圾收集器釋放。反之,平台相關代碼必須能用某種方式通知虛擬機它不再需要那些對象,同時,垃圾收集器必須能夠移走被平台相關代碼引用過的對象。
全局和局部引用
JNI 將平台相關代碼使用的對象引用分成兩類:局部引用和全局引用。局部引用在本地方法調用期間有效,並在本地方法返回後被自動釋放掉。全局引用將一直有效,直到被顯式釋放。
對象是被作為局部引用傳遞給本地方法的,由 JNI 函數返回的所有 Java 對象也都是局部引用。JNI 允許程序員從局部引用創建全局引用。要求 Java 對象的 JNI 函數既可接受全局引用也可接受局部引用。本地方法將局部引用或全局引用作為結果返回。
大多數情況下,程序員應該依靠虛擬機在本地方法返回後釋放所有局部引用。但是,有時程序員必須顯式釋放某個局部引用。例如,考慮以下的情形:
- 本地方法要訪問一個大型 Java 對象,於是創建了對該 Java 對象的局部引用。然後,本地方法要在返回調用程序之前執行其它計算。對這個大型 Java 對象的局部引用將防止該對象被當作垃圾收集,即使在剩餘的運算中並不再需要該對象。
- 本地方法創建了大量的局部引用,但這些局部引用並不是要同時使用。由於虛擬機需要一定的空間來跟蹤每個局部引用,創建太多的局部引用將可能使係統耗盡內存。例如,本地方法要在一個大型對象數組中循環,把取回的元素作為局部引用,並在每次迭代時對一個元素進行操作。每次迭代後,程序員不再需要對該數組元素的局部引用。
JNI 允許程序員在本地方法內的任何地方對局部引用進行手工刪除。為確保程序員可以手工釋放局部引用,JNI 函數將不能創建額外的局部引用,除非是這些 JNI 函數要作為結果返回的引用。
局部引用僅在創建它們的線程中有效。本地方法不能將局部引用從一個線程傳遞到另一個線程中。
實現局部引用
為了實現局部引用,Java 虛擬機為每個從 Java 到本地方法的控製轉換都創建了注冊服務程序。注冊服務程序將不可移動的局部引用映射為 Java 對象,並防止這些對象被當作垃圾收集。所有傳給本地方法的 Java 對象(包括那些作為 JNI 函數調用結果返回的對象)將被自動添加到注冊服務程序中。本地方法返回後,注冊服務程序將被刪除,其中的所有項都可以被當作垃圾來收集。
可用各種不同的方法來實現注冊服務程序,例如,使用表、鏈接列表或 hash 表來實現。雖然引用計數可用來避免注冊服務程序中有重複的項,但 JNI 實現不是必須檢測和消除重複的項。
注意,以保守方式掃描本地堆棧並不能如實地實現局部引用。平台相關代碼可將局部引用儲存在全局或堆數據結構中。
訪問 Java 對象
JNI 提供了一大批用來訪問全局引用和局部引用的函數。這意味著無論虛擬機在內部如何表示 Java 對象,相同的本地方法實現都能工作。這就是為什麼 JNI 可被各種各樣的虛擬機實現所支持的關鍵原因。
通過不透明的引用來使用訪問函數的開銷比直接訪問 C 數據結構的開銷來得高。我們相信,大多數情況下,Java 程序員使用本地方法是為了完成一些重要任務,此時這種接口的開銷不是首要問題。
訪問基本類型數組
對於含有大量基本數據類型(如整數數組和字符串)的 Java 對象來說,這種開銷將高得不可接受 (考慮一下用於執行矢量和矩陣運算的本地方法的情形便知)。對 Java 數組進行迭代並且要通過函數調用取回數組的每個元素,其效率是非常低的。
一個解決辦法是引入“釘住”概念,以使本地方法能夠要求虛擬機釘住數組內容。而後,該本地方法將接受指向數值元素的直接指針。但是,這種方法包含以下兩個前提:
首先,我們提供了一套函數,用於在 Java 數組的一部分和本地內存緩衝之間複製基本類型數組元素。這些函數隻有在本地方法隻需訪問大型數組中的一小部分元素時才使用。
其次,程序員可用另一套函數來取回數組元素的受約束版本。記住,這些函數可能要求 Java 虛擬機分配存儲空間和進行複製。虛擬機實現將決定這些函數是否真正複製該數組,如下所示:
最後,接口提供了一些函數,用以通知虛擬機本地方法已不再需要訪問這些數組元素。當調用這些函數時,係統或者釋放數組,或者在原始數組與其不可移動副本之間進行協調並將副本釋放。
這種處理方法具有靈活性。垃圾收集器的算法可對每個給定的數組分別作出複製或釘住的決定。例如,垃圾收集器可能複製小型對象而釘住大型對象。
JNI 實現必須確保多個線程中運行的本地方法可同時訪問同一數組。例如,JNI 可以為每個被釘住的數組保留一個內部計數器,以便某個線程不會解開同時被另一個線程釘住的數組。注意,JNI 不必將基本類型數組鎖住以專供某個本地方法訪問。同時從不同的線程對 Java 數組進行更新將導致不確定的結果。
訪問域和方法
JNI 允許本地方法訪問 Java 對象的域或調用其方法。JNI 用符號名稱和類型簽名來識別方法和域。從名稱和簽名來定位域或對象的過程可分為兩步。例如,為調用類cls 中的f
方法,平台相關代碼首先要獲得方法 ID,如下所示:
jmethodID mid =
env->GetMethodID(cls, "f", "(ILjava/lang/String;)D");
然後,平台相關代碼可重複使用該方法 ID 而無須再查找該方法,如下所示:
jdouble result = env->CallDoubleMethod(obj, mid, 10, str);
域 ID 或方法 ID 並不能防止虛擬機卸載生成該 ID 的類。該類被卸載之後,該方法 ID 或域 ID 亦變成無效。因此,如果平台相關代碼要長時間使用某個方法 ID 或域 ID,則它必須確保:
JNI 對域 ID 和方法 ID 的內部實現並不施加任何限製。
報告編程錯誤
JNI 不檢查諸如傳遞 NULL 指針或非法參數類型之類的編程錯誤。非法的參數類型包括諸如要用 Java 類對象時卻用了普通 Java 對象這樣的錯誤。JNI 不檢查這些編程錯誤的理由如下:
大多數 C 庫函數對編程錯誤不進行防範。例如,printf()
函數在接到一個無效地址時通常是引起運行錯而不是返回錯誤代碼。強迫 C 庫函數檢查所有可能的錯誤情況將有可能引起這種檢查被重複進行--先是在用戶代碼中進行,然後又在庫函數中再次進行。
程序員不得將非法指針或錯誤類型的參數傳遞給 JNI 函數。否則,可能產生意想不到的後果,包括可能使係統狀態受損或使虛擬機崩潰。
Java 異常
JNI 允許本地方法拋出任何 Java 異常。本地方法也可以處理突出的 Java 異常。未被處理的 Java 異常將被傳回虛擬機中。
異常和錯誤代碼
一些 JNI 函數使用 Java 異常機製來報告錯誤情況。大多數情況下,JNI 函數通過返回錯誤代碼並拋出 Java 異常來報告錯誤情況。錯誤代碼通常是特殊的返回值(如 NULL),這種特殊的返回值在正常返回值範圍之外。因此,程序員可以:
在以下兩種情況中,程序員需要先查出異常,然後才能檢查錯誤代碼:
-
調用 Java 方法的 JNI 函數返回該 Java 方法的結果。程序員必須調用
ExceptionOccurred()
以檢查在執行 Java 方法期間可能發生的異常。 - 某些用於訪問 JNI 數組的函數並不返回錯誤代碼,但可能會拋出
ArrayIndexOutOfBoundsException
或ArrayStoreException
。
在所有其它情況下,返回值如果不是錯誤代碼值就可確保沒有拋出異常。
異步異常
在多個線程的情況下,當前線程以外的其它線程可能會拋出異步異常。異步異常並不立即影響當前線程中平台相關代碼的執行,直到出現下列情況:
注意,隻有那些有可能拋出同步異常的 JNI 函數才檢查異步異常。
本地方法應在必要的地方(例如,在一個沒有其它異常檢查的緊密循環中)插入 ExceptionOccurred()
檢查以確保當前線程可在適當時間內對異步異常作出響應。
異常的處理
可用兩種方法來處理平台相關代碼中的異常:
拋出了某個異常之後,平台相關代碼必須先清除異常,然後才能進行其它的 JNI 調用。當有待定異常時,隻有以下這些 JNI 函數可被安全地調用:ExceptionOccurred()、ExceptionDescribe()
和ExceptionClear()
。ExceptionDescribe()
函數將打印有關待定異常的調試消息。
最後更新:2017-04-02 06:52:01