JNI 設計概述 (第二章) 此章涉及JNI開發中應該注意到的方方面面,望各位可以配合着官方文檔一起看

JNI 設計概述 (第二章)

翻譯約定

  1. 源文檔中多次提到JNI functions,我們將其理解我JNI方法或者JNI功能又或者JNI接口函數,注意區分這幾個名字和Native方法,不要搞混(已經混掉了可以看評論)。
  2. Native方法定義指在Java文件中定義的 native int compute(); 這種方法。
  3. 本機方法指JVM所運行的系統支持的原生代碼(比如Linux原生支持C/C++,Win支持C#/C/C++等,當然還支持彙編)。
  4. Native方法實現指動態庫中Native方法的實現。
  5. 翻譯完發現JNI文檔也有好幾個版本,雖然大體內容差不多,但是最新版本的比之前版本還是有所擴充的,不如下面的VM鏈接靜態庫的地方(上方鏈接已鏈接到最新版本)。

一、第二章概要

本章主要聚焦JNI的設計原則。本章中的大部分設計原則都和Native 方法有關。關於 Invocation API將在第五章進行介紹(Invacation API是在本地代碼中對JVM進行利用的一些編程接口,比如創建JVM、attach 原生線程到JVM等)。

二、JNI接口函數 和 JNI接口指針

Native代碼通過調用JNI接口函數和JVM進行通信(使用JVM平臺中的一些特性,比如使用其中的很多類庫、功能方法等)。JNI接口函數可以通過JNI接口指針獲得。JNI接口指針是一個二級指針。這個二級指針指向的是一個指針列表,指針列表中的每一個指針都指向一個JNI接口函數。每一個JNI接口函數的入口都通過該指針列表給出。圖2-1 展示了JNI接口指針的組織形式。
圖2-1 JNI接口指針

JNI接口採用類似C++中虛函數表或者COM接口的組織形式(之所以說是像,因爲定義JNI接口是通過struct定義的,即C中的結構體,並不是純粹的C++虛函數)。只想目標機器(使用JNI接口函數的機器)提供接口函數表而不是直接提供硬編碼的代碼去和JVM通信,是爲了更好的將JNI接口函數功能從本機代碼中分離,而且VM可以輕鬆的提供多個版本的JNI功能列表。例如,VM可能支持如下兩個JNI函數表:

總結起來就是說,這樣只定義了JNI接口出來,接口的實現交於各個版本的JVM去實現,隨便JVM實現多少個版本怎麼實現,在目標機器上(調用JNI接口函數的機器)並不關心,我只要能使用JNI接口中的方法就可以。怎麼使用呢?需要通過一種方式讓JVM提供出來我們上面提到的JNI接口指針,通過指針去訪問。JVM怎麼將JNI接口指針提供出來呢?有兩種方式,一種是從Java調用本地方法時會有JNI接口指針傳入本地方法中,另一種是本地代碼需要用到JVM的特性,這個時候需要從主動創建或者其他方式得到的JVM中獲取到JNI接口指針。

  • 實現一個嚴格的參數檢查的JNI接口版本,主要用於從測試。
  • 實現一個JNI規範所需的儘量少的檢查,可以提高運行效率。
    一個JNI接口指針只會在當前線程有效。所以,一個native方法不可以將JNI接口指針從一個線程傳遞到另一個線程。實現JNI編程接口的VM很可能會在JNI接口指針指向的區域中去分配以及存儲當前線程數據(包括Java、Native數據)。

Native方法中肯定會JNI接口指針這個參數。VM會保證在同一個Java線程中多次調用Native方法時傳入的JNI接口指針都是同一個。但是同一個Native方法可以被多個Java線程調用,所以可能會收到不同的JNI接口指針。

三、編譯,加載和鏈接Native方法

因爲Java VM是支持多線程的,所以Native方法的編譯器編譯的時候也需要添加多線程支持。例如,使用Sun Studio編譯器編譯C++代碼的時候需要加上 -mt 標誌(編譯時候的配置參數,類似於java -g -d C:/Test.java 這樣)。對於使用GNU gcc編譯器的代碼,需要加上這個配置參數 -D_REENTRANT or -D_POSIX_C_SOURCE。關於本機原生代碼編譯的更多信息請參考本機原生代碼的編譯器文檔。

Native方法需要通過Java中的System.loadLibrary()方法加在進系統(Native方法將會被編譯成庫文件,例如Linux的本機代碼C++,用C++實現好的Native方法將會被編譯成搜庫通過loadLibrary加載)。在下面的例子中,在Java類中的靜態代碼庫中加載了Java虛擬機所處的平臺上的庫文件(比如在Win上加載的將是dll文件,在Linux加載的將是搜文件),在這個庫文件中實現了Native方法 f(int i, Stirng s):

package pkg;

class Cls { 
    native double f(int i, String s); 

    static { 
        System.loadLibrary(“pkg_Cls”); 
    } 
}

System.loadLibrary方法中的參數是要加載的庫文件名字,這個庫文件名字程序員可以自由定義。JVM系統會自動的遵循相應的平臺標準將這個作爲參數的庫名字轉爲本地庫名字。例如,Solaris系統中會將 pkg_Cls 轉換爲pkg_Cls.so,如果是在Win32系統上將會轉換爲pkg_Cls.dll。

總結下,loadLibrary中的參數填寫的時候並不是程序員可以隨便填寫,而是需要根據你所編譯出來的本機代碼庫的名字進行填寫,但是本機代碼庫的名字程序員是可以隨便命名的(當然也受到系統平臺的限制,一般文件後綴會有要求,還要些不能包含*、&、¥等符號),比如編譯的Linux平臺的動態代碼庫 media.so ,通過loadLibrary方法進行加載的時候參數必須這樣 System.loadLibrary(media),也就是說,最爲loadLibrary的參數傳入的時候只需要把後綴去掉就可以,VM會根據它所處的平臺自動去添加後綴 (有動態庫和靜態庫之分,動態庫後綴 .so 靜態庫後綴 .a ,JVM只能加載動態庫。Win中也有靜態庫動態庫 分別是 .lib .dll)(還有一點需要提到就是還有一個System.load方法,用於在絕對路徑加載動態庫)。

程序員可以用一個單個的動態庫文件來實現在任意數量的Class定義的Native方法。但是有一個前提條件,要想這些Native方法可以使用,就必須使用同一個類加載器(Class Loader)加載這些Class。VM內部維護者每個類加載器加載的動態庫列表。動態庫提供商應該對動態庫設計一個良好的命名方式用以儘量減少命名衝突的可能性。

跟着System.loadLibrary();方法進去看一看會發現裏面Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);執行了這樣一句。會將執行loadLibrary方法的Class所屬的ClassLoader作爲參數傳進去。之所以將動態庫跟類加載器綁定在一起和之所以會有類加載器這個東西道理是一樣的,就是爲了避免出現衝突,大家各用個的庫版本。

如果底層操作系統不支持動態鏈接(不支動態庫),所有Native方法實現必須預先硬鏈接到VM,在這種情況下,VM可以完成loadLibrary調用但是沒有什麼實際意義。程序員還可以通過調用JNI接口函數中的RegisterNatives()方法將Native方法與本機代碼關聯起來(會有專門的章節介紹)。

前文中我提到JNI是隻支持動態鏈接的,這麼快就被打臉,其實JNI也可以滿足一些系統在不支持動態庫的情況下又需要使用JNI接口函數功能的需求。但是這種需求過於小衆,而且實現也有點複雜,根據靜態庫的原理來看甚至需要重新對VM進行編譯,一般沒有這種需求,所以不進行討論,具體的可以參看最新官方文檔,內容不是很長。

1、關於本地方法名字的定義格式

動態鏈接過程中,動態連接器將根據Native名字以及本機方法名字在這兩種方法之間產生關聯信息。Native方法的實現方法名字是通過一下幾個部分組合而成:

  • 前綴Java_。
  • Native方法所處Java類的完全限定類名(以下劃線作爲分隔符)。
  • 下劃線分隔符。
  • 一個全限定方法名。
  • 對於重載的本機方法,兩個下劃線後面跟完全的參數簽名(參數簽名後面會有講解)。

VM將會負責檢查在動態庫中的方法的名字,跟Native方法進行匹配。VM會首先去匹配短一點的名字,也就是說Native方法中參數較少的那一個。然後纔回去匹配帶有參數的長名稱。程序員僅僅在覆蓋了Native方法的定義的時候才需要使用長名稱(也就是加了參數的名稱),但是如果該Native方法沒有被另一個Native方法覆蓋的話就不需要使用長名稱。如果Native方法和Java方法具有一樣的名字依然不用使用長名稱(因爲在動態庫中只有一個版本的Native方法實現)。

大家用IDE直接生產Native對應方法時需要注意以上問題。

在下面示例中,Native方法g可以不適用長名稱進行鏈接,因爲兩位一個名稱也爲個g的方法並不是Native方法,並不在動態庫中,不會應該本地代碼庫中Native方法的實現和Java中Native方法的定義的一一對應。

class Cls1 { 

    int g(int i); 

    native int g(double d); 

} 

我們採用一種簡單的命名修改方案,以確保所有的Unicode字符都可以轉爲有效的C函數名稱(應該不僅僅限於C)。我們使用下劃線字符(“_”)代替完全限定名中的反斜槓(“/”)。由於在Java中名稱和類型的描述不能以數字打頭,所以我們可以使用_0,….,_9作爲轉義字符,像表2-1這樣:

2-1 Unicode字符轉意
轉義字符           意義
_0XXXX             一個Unicode字符XXXX,注意要使用小寫而不是使用大小標示Unicode的字符編碼
_1                  字符"_"
_2                  字符";",一般用於類型簽名中
_3                  字符";",也是用於類型簽名

Both the native methods and the interface APIs follow the standard library-calling convention on a given platform. For example, UNIX systems use the C calling convention, while Win32 systems use __stdcall.(沒有實質性價值的一句話。)

2、Native方法參數

JNI接口指針是Native方法(這裏指Native方法在動態庫中的實現)的第一個參數。JNI接口指針是一個JNIEnv類型指針。第二個參數跟這個Native方法是否是靜態方法有關,當Native方法時一個static方法的時候第二個參數是這個Java class的引用,當這個Native方法不是static方法時指向的是該Native方法所屬的對象。
其餘的參數一一對應於Java中Native方法定義時的參數。Native方法通過返回值將其結果傳遞給調用者。第三章中將會講到JNI中的數據類型以及數據結構,通過這些數據類型用來幫助程序開發者在Java和C類型之間相互映射。

以下代碼示例說明了如何使用C函數實現Native方法 f(int i,String s);。Native方法聲明如下:

package pkg; 

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

使用採用長名稱命名的C函數實現本機方法:

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 ...
}

注意:如上所示我們必須也只能使用JNI接口指針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對應的函數。

四、Native方法中引用Java對象

基本類型數據,像int,char等等這些數據,會在Java和Native實現代碼之間直接進行復制,而Java對象是通過引用傳遞。VM必須跟蹤傳遞到Native方法實現中的所有Java對象,以防止該對象會被垃圾收集器釋放掉。反過來,Native方法中也必須有一種機制用來通知JVM它不在需要這些對象。此外,垃圾收集器也必須能夠移動本機代碼所引用的對象(不是很清楚,應該是在說需要可以將Java對象從一個區域移動到另一個區域)。

1、本地引用和全局引用

JNI將Native代碼中對Java對象的引用分爲兩類:本地引用和全局引用。本地引用只在該Native方法調用期間有用,本機方法返回的時候將會自動解除掉該引用(一旦執行了return對象引用就將失效,其實說是失效僅僅是說有可能會被JVM垃圾收集器回收,但是也不一定會被回收因爲在Java對象中可能會有其引用)。而全局引用將一直有效直到其被顯式的釋放。
Java對象以本地應用的形式傳遞給Native代碼。所有的JNI接口函數返回的Java對象都是本地引用。JNI允許開發者將本地引用轉化爲一個全局引用。JNI接口函數可以接受本地或全局引用作爲參數,而一個Native方法也可以將本地或者全局引用返回給VM。

大部分時候,程序開發者依賴VM在Native方法返回後自動釋放掉本地引用即可,但是,有時候開發者也需要主動的去釋放本地引用。考慮下面這些情況:

  • Native代碼需要操作一個大型的Java對象,於是創建了對該Java對象的本地引用。此時,Native代碼在該調用方法結束之前需要執行很多其他操作,這樣,即使該大型Java對象在剩餘的操作中已經不再被使用,可是因爲此時本地引用的存在,該大型Java對象依然不會被垃圾收集器回收。
  • Native代碼中需要創建大量的本地引用,而VM是需要一定的空間去跟蹤這些本地引用,所以創建太多的本地引用很可能會導致內存不足,

而且這些引用可能並不需要同時使用,這個時候就有主動去釋放的必要。例如,需要在Native代碼中去循環遍歷一個數組,將數組中每一個元素作爲本地引用在每一次迭代中去操作。在每次迭代之後,開發者將不再需要上一次迭代所使用元素的本地引用,這個時候就需要主動去刪除本地引用。

JNI允許開發者在Native代碼中的任何地方去主動的刪除本地引用。 To ensure that programmers can manually free local references, JNI functions are not allowed to create extra local references, except for references they return as the result.(沒有很明白這句,可以肯定的是JNI functions是可以創建本地引用的,並且所有的JNI接口函數返回的Java對象都是本地引用)。

本地引用只在創建他們的線程中有效,所以Native代碼中不能將本地引用從一個線程傳遞到另一個線程。

2、本地引用的實現方式

爲了實現本地引用(根據上面說的是否明白了什麼是本地引用),每次從Java代碼轉換到Native代碼中時,JVM都將創建一個註冊表。這個註冊表將本地引用映射到相應的Java對象,並防止Java對象被垃圾收集器回收。所有傳遞給Native方法的Java對象還有所有JNI接口函數返回的對象都會自動添加到註冊表。當Native方法執行結束之後,註冊表將會被刪除,註冊表也不再會影響到JVM的垃圾收集器釋放對象(當然只是註冊表的影響沒有了,其他地方依然引用了該對象就依然不能被回收)。

該註冊表的實現多種多言,例如使用表、鏈表或者哈希表。需要注意的是註冊表中允許存在重複條目,即註冊表中可能有兩個引用指向同一個Java對象,JNI並沒有義務去檢測和合並重復的引用。哪怕通過引用計數去避免在註冊表出現重複條目是如此簡單,但JNI沒有義務一定要這麼做。

需要注意的是,僅僅通過本地方法棧沒有辦法很好的實現本地引用。所以JNI實現本地引用的時候應該儘量將本地引用存儲到方法區或者堆中(Note that local references cannot be faithfully implemented by conservatively scanning the native stack. The native code may store local references into global or heap data structures.)。

五、訪問Java對象

JNI接口函數提供了基於全局引用以及本地引用的豐富的訪問函數。這意味着Native代碼不需要關心VM內部是怎麼表示Java對象的,在任何VM平臺上Native方法都可以順利編譯執行。

通過不透明的方式使用JNI接口函數去操作Java對象的確會比直接去操作C語言結構數據要多一些性能損耗。但是,我們相信,在大多數情況下,Java開發者是爲了通過Native方法去執行很重要的任務代碼,而這個重要性將忽略掉這點性能開銷。

1、訪問基本數據類型數組

對於包含許多基本數據類型的大型Java對象而言(一般數組具有這個特徵),因爲JNI接口產生的性能損耗往往是不能接受的(比如執行向量或者矩陣運算的Native方法)。使用JNI接口函數去迭代每一個元素將是非常低效的。

下面一種解決方案引入了”pinning(固定)”的概念,Native代碼可以要求VM固定住數組內容。Native代碼直接訪問數組在VM中的直接指針。但是這種方法有兩個要求:

  • 垃圾收集器必須支持固定。
  • VM必須在內存中連續佈局原生數據類型數組。雖然這是大多數原始數組最自然的實現,但是boolean類型數組卻不一般。Although this is the most natural implementation for most primitive arrays, boolean arrays can be implemented as packed or unpacked.Therefore, native code that relies on the exact layout of boolean arrays will not be portable.(可以不理解。。。。)

爲了更好的實現,我們採用了一種妥協的方案達到上面的要求。
首先,我們提供了一組函數可以複製Java原始數據類型數組中的一部分到宿主機內存中。當Native代碼中只需要訪問大型數組中的少量元素時可以使用。
第二,開發者可以使用另一組函數來檢索數組對象的固定版本(pinning將數組固定之後再訪問)。但是請注意,這些功能可能依然需要JVM執行存儲空間分配和複製操作,究竟是否複製取決於虛擬機自己的實現,具體情況如下:

  • 如果垃圾收集器主持固定(pinning),而且Java中的數據佈局方式跟宿主機中使用的佈局方式相同,那就不需要複製。
  • 負責,將數組複製到不可以移動的內存塊(例如C堆中),並且複製的時候需要根據宿主機以及JVM具體情況執行必要的格式轉換。JNI接口函數將返回對應的指針。

最後,JNI接口函數中提供方法用於通知VM,Native方法中不再需要訪問數組元素。通過調用這些方法,系統會取消該數組的固定(pinning),或者將原始數組與其不可移動的副本進行協調並釋放副本(協調一般指將副本更新到Java數據中去)。
這些方法將給VM很大的靈活性。垃圾收集器算法可以針對每個給定的數組對象做單獨的不同的處理。比如,對於較小的對象可以進行復制,對於較大的對象可以採用固定。

JNI接口函數的實現,必須確保在多個線程中運行的Native代碼可以同時訪問一個數組。例如,JNI可以爲每一個固定數組保留一個內部計數器,這樣可以保證多個線程固定的時候不會出現提前解除固定的情況。但是請注意,JNI不需要去主動的給原始數組加鎖去避免Native方法的獨佔訪問。所以,開發者必須去保證數據同步。

2、訪問Java中的字段和方法

JNI允許Native代碼訪問Java對象中的字段以及方法,JNI通過它們的名稱以及類型簽名來識別方法以及字段。往往只需要兩步就可以從字段或者方法簽名獲取到該字段值或者使該方法執行。例如,爲了執行cls類中的f(int i,String s);方法,採用如下步驟

jmethodID mid = env->GetMethodID(cls, “f”, “(ILjava/lang/String;)D”);
jdouble result = env->CallDoubleMethod(obj, mid, 10, str);

上面的jmethodID 可以重複使用,而無需每次去Get一次(看jmethodID定義也可以看出來)。

需要注意的是,字段或者方法ID並不會阻止VM卸載其所屬的class對象。卸載對應class對象後,這些字段ID、方法ID將變得無效。因此如果打算長時間使用該ID,Native方法必須保證直接或間接持有該class的引用,或者每次都重新去獲取字段或者方法ID。
並且JNI不會對VM內部如何實現字段和方法ID施加任何限制。

六、報告編程錯誤

JNI接口函數不負責檢查編程錯誤,包括NULL指針以及非法參數類型錯誤。非法參數類型錯誤包括使用普通Java對象代替Java Class對象(應該也包含反過來)等。之所以JNI接口函數不負責檢查這些編程錯誤是基於以下原因:

  • 強制JNI接口函數檢查所有的可能錯誤的條件將會降低Native代碼的性能。
  • 在許多時候並沒有足夠的運行時類型信息供JNI接口函數去執行此類檢查。

大多數C庫函數都不能有效的防止編程錯誤。例如,printf()函數接收到無效地址時通常會導致程序異常崩潰而不是返回錯誤碼。而且強制C庫函數檢查所有的錯誤條件,很可能會導致重複的檢查(一次在用戶代碼中,一次在庫中)。

程序員不得將非法指針或者錯誤類型參數傳遞給JNI接口函數,因爲這樣做很可能會導致難以預料的結果,包括系統狀態損壞或者VM崩潰。

1、JNI開發中的異常

異常在JNI開發中用起來並不困難,但是要明白文檔中所說的還是需要點內容的,後面會專門針對JNI異常講解(該段暫時沒有翻譯)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章