1. 程式人生 > >Android JNI 學習(二):JNI 設計概述

Android JNI 學習(二):JNI 設計概述

本章我們重點說明以下JNI設計的問題,本章中提到的大多數設計問題都與native方法有關。至於呼叫相關的API的設計,我們會在後面進行介紹。

一、JNI介面函式和指標

native 程式碼通過呼叫JNI函式來訪問Java VM功能。JNI函式可通過介面指標獲得介面指標是指向指標的指標。該指標指向一個指標陣列,每個指標指向一個介面函式。每個介面函式都在陣列內的預定義偏移處。下圖說明了介面指標的組織。

 

介面指標

JNI介面的組織方式類似於C ++虛擬函式表或COM介面。使用介面表而不是硬連線函式條目的優點是JNI名稱空間與native程式碼分離。VM可以輕鬆提供多個版本的JNI功能表。

例如,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_Clslibpkg_Cls.so,而Win32系統將同名轉換pkg_Clspkg_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方法fnative方法f宣告如下:

package pkg;  
class Cls { 
     native double f(int i, String s); 
     ... 
}  

具有長錯位名稱的C函式Java_pkg_Cls_f_ILjava_lang_String_2實現native方法f

下面程式碼使用C來實現native方法
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陣列訪問函式不返回錯誤程式碼,但可能會丟擲一個ArrayIndexOutOfBoundsExceptionArrayStoreException

在所有其他情況下,非錯誤返回值可確保不會丟擲任何異常。

非同步異常

在多執行緒的情況下,除當前執行緒之外的執行緒可能釋出非同步異常。非同步異常不會立即影響當前執行緒中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相關的知識做更進一步的整理。