Android JNI 學習(二):JNI 設計概述
本章我們重點說明以下JNI設計的問題,本章中提到的大多數設計問題都與native方法有關。至於呼叫相關的API的設計,我們會在後面進行介紹。
一、JNI介面函式和指標
native 程式碼通過呼叫JNI函式來訪問Java VM功能。JNI函式可通過介面指標獲得。介面指標是指向指標的指標。該指標指向一個指標陣列,每個指標指向一個介面函式。每個介面函式都在陣列內的預定義偏移處。下圖說明了介面指標的組織。
介面指標
JNI介面的組織方式類似於C ++虛擬函式表或COM介面。使用介面表而不是硬連線函式條目的優點是JNI名稱空間與native程式碼分離。VM可以輕鬆提供多個版本的JNI功能表。
- 一個用於平臺執行徹底的非法引數檢查,適合除錯;
- 另一個執行JNI規範所需的最小量檢查,因此更有效。
JNI介面指標僅在當前執行緒中有效。因此,native方法不能將介面指標從一個執行緒傳遞到另一個執行緒。實現JNI的VM可以在JNI介面指標指向的區域中分配和儲存執行緒本地資料。
Native方法接收JNI介面指標作為引數。當VM從同一Java執行緒多次呼叫native方法時,保證將VM傳遞給native方法。但是,可以從不同的Java執行緒呼叫native方法,因此可以接收不同的JNI介面指標。
二、編譯,載入和連結native方法
由於Java VM是多執行緒的,因此native庫也應該與多執行緒感知的native編譯器一起編譯和連結。例如,該-mt
標誌應該用於使用Sun Studio編譯器編譯的C ++程式碼。對於符合GNU gcc編譯器的程式碼,應使用標誌-D_REENTRANT
或-D_POSIX_C_SOURCE
。有關更多資訊,請參閱native編譯器文件。
使用System.loadLibrary
方法載入native方法。在以下示例中,類初始化方法載入特定於平臺的native庫,其中f
定義了native方法:
package pkg; class Cls {native double f(int i, String s); static { System.loadLibrary(“pkg_Cls”); } }
引數System.loadLibrary
是由程式設計師任意選擇的庫名。系統遵循標準但特定於平臺的方法將庫名稱轉換為native庫名稱。例如,Solaris系統將名稱轉換pkg_Cls
為libpkg_Cls.so
,而Win32系統將同名轉換pkg_Cls
為pkg_Cls.dll
。
程式設計師可以使用單個庫來儲存任意數量的類所需的所有native方法,只要這些類要使用相同的類載入器載入即可。VM在內部維護每個類載入器的載入native庫列表。供應商應選擇本地庫名稱,以儘量減少名稱衝突的可能性。
如果底層作業系統不支援動態連結,則必須將所有native方法與VM預先連結。在這種情況下,VM完成System.loadLibrary
呼叫而不實際載入庫。
程式設計師還可以呼叫JNI函式RegisterNatives()
來註冊與類關聯的native方法。該RegisterNatives()
功能對於靜態連結功能特別有用。
三、解析native方法名稱
動態連結器根據其名稱解析條目。native方法名稱由以下元件連線:
- 字首
Java_
- 一個錯位的完全限定的類名
- 下劃線(“_”)分隔符
- 方法名稱
- 對於過載的native方法,兩個下劃線(“__”)後跟引數簽名
VM檢查駐留在native庫中的方法的方法名稱匹配。VM首先查詢短名稱; 也就是說,沒有引數簽名的名稱。然後它查詢長名稱,這是帶有引數簽名的名稱。只有當native方法使用另一個native方法過載時,程式設計師才需要使用長名稱。但是,如果native方法與非native方法具有相同的名稱,則這不是問題。非native方法(Java方法)不駐留在native庫中。
在以下示例中,g
不必使用長名稱連結方法g,因為另一種方法不是native方法,因此不在native庫中。
class Cls1 { int g(int i); native int g(double d); }
我們採用了一種簡單的名稱修改方案,以確保所有Unicode字元都轉換為有效的C函式名稱。
我們使用下劃線(“_”)字元代替完全限定類名中的斜槓(“/”)。由於名稱或型別描述符從不以數字開頭,因此我們可以使用_0
...,_9
表示轉義序列。
native方法和介面API都遵循給定平臺上的標準庫呼叫約定。例如,UNIX系統使用C呼叫約定,而Win32系統使用__stdcall。
四、native方法引數
JNI介面指標是native方法的第一個引數。JNI介面指標的型別為JNIEnv。第二個引數根據native方法是靜態方法還是非靜態方法而有所不同。非靜態native方法的第二個引數是對該物件的引用。靜態native方法的第二個引數是對其Java類的引用。
其餘引數對應於常規Java方法引數。native方法呼叫通過返回值將其結果傳遞迴呼叫例程。下面的一篇文章,我們會介紹Java和C型別之間的對映。
下面的程式碼聲明瞭C函式來實現native方法f
。native方法f
宣告如下:
package pkg; class Cls { native double f(int i, String s); ... }
具有長錯位名稱的C函式Java_pkg_Cls_f_ILjava_lang_String_2
實現native方法f
:
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 ( JNIEnv *env, /* interface pointer */ jobject obj, /* "this" pointer */ jint i, /* argument #1 */ jstring s) /* argument #2 */ { /* Obtain a C-copy of the Java string */ const char *str = (*env)->GetStringUTFChars(env, s, 0); /* process the string */ ... /* Now we are done with str */ (*env)->ReleaseStringUTFChars(env, s, str); return ... }
請注意,我們總是使用介面指標env操作Java物件。使用C++的話,編寫的程式碼如下:
extern "C" /* specify the C calling convention */ jdouble Java_pkg_Cls_f__ILjava_lang_String_2 ( JNIEnv *env, /* interface pointer */ jobject obj, /* "this" pointer */ jint i, /* argument #1 */ jstring s) /* argument #2 */ { const char *str = env->GetStringUTFChars(s, 0); ... env->ReleaseStringUTFChars(s, str); return ... }
使用C ++,額外的間接級別和介面指標引數從原始碼中消失。但是,底層機制與C完全相同。在C ++中,JNI函式被定義為內聯成員函式,它們擴充套件為C對應函式。
五、引用Java物件
原始型別(如整數,字元等)在Java和native程式碼之間複製。另一方面,任意Java物件都通過引用傳遞。VM必須跟蹤已傳遞給native程式碼的所有物件,以便垃圾收集器不會釋放這些物件。反過來,native程式碼必須有一種方法來通知VM它不再需要這些物件。此外,垃圾收集器必須能夠移動native程式碼引用的物件。
全域性引用和本地引用
JNI將native程式碼使用的物件引用分為兩類:本地引用和全域性引用。native引用在native方法呼叫的持續時間內有效,並在native方法返回後自動釋放。全域性引用在顯式釋放之前仍然有效。
物件作為native引用傳遞給native方法。JNI函式返回的所有Java物件都是本地引用。JNI允許程式設計師從本地引用建立全域性引用。JNI函式,期望Java物件接受全域性和本地引用。native方法可以返回對VM的本地或全域性引用作為其結果。
在大多數情況下,程式設計師應該依賴VM在native方法返回後釋放所有本地引用。但是,有時程式設計師應該明確地釋放本地引用。例如,考慮以下情況:
- native方法訪問大型Java物件,從而建立對Java物件的本地引用。然後,native方法在返回呼叫方之前執行其他計算。對大型Java物件的本地引用將阻止物件被垃圾回收,即使該物件不再用於計算的其餘部分。
- native方法會建立大量本地引用,但並非所有引用都同時使用。由於VM需要一定的空間來跟蹤本地引用,因此建立太多本地引用可能會導致系統記憶體不足。例如,native方法迴圈遍歷大量物件,將元素作為本地引用檢索,並在每次迭代時對一個元素進行操作。在每次迭代之後,程式設計師不再需要對陣列元素的本地引用。
JNI允許程式設計師在native方法中的任何點手動刪除本地引用。為了確保程式設計師可以手動釋放本地引用,不允許JNI函式建立額外的本地引用,除了它們作為結果返回的引用。
本地引用僅在建立它們的執行緒中有效。native程式碼不能將本地引用從一個執行緒傳遞到另一個執行緒。
實現本地引用
為了實現本地引用,Java VM為從Java到native方法的每次控制轉換建立了一個登錄檔。登錄檔將不可移動的本地引用對映到Java物件,並防止物件被垃圾回收。傳遞給native方法的所有Java物件(包括那些作為JNI函式呼叫結果返回的物件)都會自動新增到登錄檔中。在native方法返回後刪除登錄檔,允許其所有條目被垃圾回收。
有不同的方法來實現登錄檔,例如使用表,連結串列或雜湊表。雖然引用計數可用於避免登錄檔中的重複條目,但JNI實現沒有義務檢測和摺疊重複條目。
注意:通過遍歷native堆疊無法實現本地引用。因為native程式碼可以將本地引用儲存到全域性或堆資料結構中。
六、訪問Java物件
JNI在全域性和本地引用上提供了豐富的訪問器函式。這意味著無論VM如何在內部表示Java物件,相同的native方法實現都會起作用。這是JNI可以被各種VM實現支援的關鍵原因。
通過不透明引用使用訪問器函式的開銷高於直接訪問C資料結構的開銷。我們相信,在大多數情況下,Java程式設計師使用native方法來執行非常重要的任務,這些任務會掩蓋此介面的開銷。
訪問原始陣列
對於包含許多基本資料型別的大型Java物件(例如整數陣列和字串),此開銷是不可接受的。迭代Java陣列並使用函式呼叫檢索每個元素是非常低效的。
為解決此問題我們引入了“固定”的概念,以便native方法可以要求VM確定陣列的內容。然後,native方法接收指向元素的直接指標。然而,這種方法有兩個含義:
- 垃圾收集器必須支援固定。
- VM必須在記憶體中連續佈局原始陣列。
為了克服上述兩個問題,我們採取以下方案:
首先,我們提供了一組函式來複制Java陣列的一段和本機記憶體緩衝區之間的原始陣列元素。如果native方法只需要訪問大型陣列中的少量元素,請使用這些函式。
其次,程式設計師可以使用另一組函式來檢索陣列元素的固定版本。請記住,這些功能可能需要Java VM執行儲存分配和複製。這些函式實際上是否複製陣列取決於VM實現,如下所示:
- 如果垃圾收集器支援固定,並且陣列的佈局與native方法的預期相同,則不需要複製。
- 否則,將陣列複製到不可移動的記憶體塊(例如,在C堆中)並執行必要的格式轉換。返回指向副本的指標。
最後,該介面提供了通知VM native程式碼不再需要訪問陣列元素的功能。當您呼叫這些函式時,系統會取消陣列,或者將原始陣列與其不可移動的副本進行協調並釋放副本。
我們的方法提供靈活性 垃圾收集器演算法可以針對每個給定陣列單獨決定複製或固定。例如,垃圾收集器可以複製小物件,但可以固定較大的物件。
JNI實現必須確保在多個執行緒中執行的native方法可以同時訪問同一個陣列。例如,JNI可以為每個固定陣列保留一個內部計數器,這樣一個執行緒就不會取消固定另一個執行緒固定的陣列。請注意,JNI不需要鎖定原始陣列以供native方法獨佔訪問。同時從不同的執行緒更新Java陣列會導致不確定的結果。
訪問欄位和方法
JNI允許native程式碼訪問欄位並呼叫Java物件的方法。JNI通過符號名稱和型別簽名來標識方法和欄位。兩步過程會從欄位名稱和簽名中分析出定位欄位或方法的成本。例如,要f
在類cls中呼叫該方法,native程式碼首先獲取方法ID,如下所示:
jmethodID mid = env-> GetMethodID(cls,“f”,“(ILjava / lang / String;)D”);
然後,native程式碼可以重複使用方法ID,而無需查詢方法,如下所示:
jdouble result = env-> CallDoubleMethod(obj,mid,10,str);
欄位或方法ID不會阻止VM解除安裝已從中派生ID的類。解除安裝類後,方法或欄位ID將變為無效。因此,native程式碼必須確保:
- 保持對基礎類的實時引用
- 重新計算方法或欄位ID
如果它打算長時間使用方法或欄位ID。
JNI不對內部如何實現欄位和方法ID施加任何限制。
七、報告程式設計錯誤
JNI不檢查程式設計錯誤,例如傳入NULL指標或非法引數型別。非法引數型別包括使用普通Java物件而不是Java類物件。由於以下原因,JNI不檢查這些程式設計錯誤:
- 強制JNI函式檢查所有可能的錯誤條件會降低正常(正確)native方法的效能。
- 在許多情況下,沒有足夠的執行時型別資訊來執行此類檢查。
大多數C庫函式都不能防止程式設計錯誤。printf()
例如,該函式在收到無效地址時通常會導致執行時錯誤,而不是返回錯誤程式碼。強制C庫函式檢查所有可能的錯誤條件可能會導致重複此類檢查 - 一次在使用者程式碼中,然後再次在庫中。
程式設計師不得將非法指標或錯誤型別的引數傳遞給JNI函式。這樣做可能會導致任意後果,包括系統狀態損壞或VM崩潰。
Java異常
JNI允許native方法引發任意Java異常。native程式碼也可以處理未完成的Java異常。未處理的Java異常會傳播回VM。
例外和錯誤程式碼
某些JNI函式使用Java異常機制來報告錯誤情況。在大多數情況下,JNI函式通過返回錯誤程式碼並丟擲Java異常來報告錯誤情況。錯誤程式碼通常是一個特殊的返回值(如NULL),它超出了正常返回值的範圍。因此,程式設計師可以:
- 快速檢查上次JNI呼叫的返回值,以確定是否發生了錯誤,並且
- 呼叫函式,
ExceptionOccurred()
以獲取包含錯誤條件的更詳細描述的異常物件。
在兩種情況下,程式設計師需要檢查異常而無法首先檢查錯誤程式碼:
- 呼叫Java方法的JNI函式返回Java方法的結果。程式設計師必須呼叫
ExceptionOccurred()
以檢查在執行Java方法期間可能發生的異常。
- 某些JNI陣列訪問函式不返回錯誤程式碼,但可能會丟擲一個
ArrayIndexOutOfBoundsException
或ArrayStoreException
。
在所有其他情況下,非錯誤返回值可確保不會丟擲任何異常。
非同步異常
在多執行緒的情況下,除當前執行緒之外的執行緒可能釋出非同步異常。非同步異常不會立即影響當前執行緒中native程式碼的執行,直到:
- native程式碼呼叫可能引發同步異常的JNI函式之一,或
- native程式碼用於
ExceptionOccurred()
顯式檢查同步和非同步異常。
請注意,只有那些可能引發同步異常的JNI函式才會檢查非同步異常。
native方法應ExceptionOccurred()
在必要的位置插入檢查(例如在沒有其他異常檢查的緊密迴圈中),以確保當前執行緒在合理的時間內響應非同步異常。
異常處理
有兩種方法可以處理native程式碼中的異常:
- native方法可以選擇立即返回,從而導致在啟動native方法呼叫的Java程式碼中丟擲異常。
- native程式碼可以通過呼叫清除異常
ExceptionClear()
,然後執行自己的異常處理程式碼。
引發異常後,native程式碼必須首先清除異常,然後再進行其他JNI呼叫。當存在掛起的異常時,可以安全呼叫的JNI函式是:
ExceptionOccurred() ExceptionDescribe() ExceptionClear() ExceptionCheck() ReleaseStringChars() ReleaseStringUTFChars() ReleaseStringCritical() Release<Type>ArrayElements() ReleasePrimitiveArrayCritical() DeleteLocalRef() DeleteGlobalRef() DeleteWeakGlobalRef() MonitorExit() PushLocalFrame() PopLocalFrame()
八、總結
本文是譯文,原文地址為:https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/design.html。
同時也是本人整理的JNI教程的第二篇,可能部分內容語法有點不通順,但是看完了也能基本瞭解JNI的設計思路。後續我們會進一步對JNI相關的知識做更進一步的整理。