JNI編程最佳實踐

本文轉載自:http://www.ibm.com/developerworks/cn/java/j-jni/

Michael Dawson, 高級軟件開發人員, IBM
Graeme Johnson, J9 虛擬機開發經理, IBM
Andrew Low, STSM,J9 虛擬機, IBM

 

簡介: Java™ 本機接口(Java Native Interface,JNI)是一個標準的 Java API,它支持將 Java 代碼與使用其他編程語言編寫的代碼相集成。如果您希望利用已有的代碼資源,那麼可以使用 JNI 作爲您工具包中的關鍵組件 —— 比如在面向服務架構(SOA)和基於雲的系統中。但是,如果在使用時未注意某些事項,則 JNI 會迅速導致應用程序性能低下且不穩定。本文將確定 10 大 JNI 編程缺陷,提供避免這些缺陷的最佳實踐,並介紹可用於實現這些實踐的工具。

 

 

 

Java 環境和語言對於應用程序開發來說是非常安全和高效的。但是,一些應用程序卻需要執行純 Java 程序無法完成的一些任務,比如:

JNI 的發展

JNI 自從 JDK 1.1 發行版以來一直是 Java 平臺的一部分,並且在 JDK 1.2 發行版中得到了擴展。JDK 1.0 發行版包含一個早期的本機方法接口,但是未明確分隔本機代碼和 Java 代碼。在這個接口中,本機代碼可以直接進入 JVM 結構,因此無法跨 JVM 實現、平臺或者甚至各種 JDK 版本進行移植。使用 JDK 1.0 模型升級含有大量本機代碼的應用程序,以及開發能支持多個 JVM 實現的本機代碼的開銷是極高的。

JDK 1.1 中引入的 JNI 支持:

  • 版本獨立性
  • 平臺獨立性
  • VM 獨立性
  • 開發第三方類庫

有一個有趣的地方值得注意,一些較年輕的語言(如 PHP)在它們的本機代碼支持方面仍然在努力克服這些問題。

  • 與舊有代碼集成,避免重新編寫。
  • 實現可用類庫中所缺少的功能。舉例來說,在 Java 語言中實現ping 時,您可能需要 Internet Control Message Protocol (ICMP) 功能,但基本類庫並未提供它。
  • 最好與使用 C/C++ 編寫的代碼集成,以充分發掘性能或其他與環境相關的系統特性。
  • 解決需要非 Java 代碼的特殊情況。舉例來說,核心類庫的實現可能需要跨包調用或者需要繞過其他 Java 安全性檢查。

JNI 允許您完成這些任務。它明確分開了 Java 代碼與本機代碼(C/C++)的執行,定義了一個清晰的 API 在這兩者之間進行通信。從很大程度上說,它避免了本機代碼對 JVM 的直接內存引用,從而確保本機代碼只需編寫一次,並且可以跨不同的 JVM 實現或版本運行。

藉助 JNI,本機代碼可以隨意與 Java 對象交互,獲取和設計字段值,以及調用方法,而不會像 Java 代碼中的相同功能那樣受到諸多限制。這種自由是一把雙刃劍:它犧牲 Java 代碼的安全性,換取了完成上述所列任務的能力。在您的應用程序中使用 JNI 提供了強大的、對機器資源(內存、I/O 等)的低級訪問,因此您不會像普通 Java 開發人員那樣受到安全網的保護。JNI 的靈活性和強大性帶來了一些編程實踐上的風險,比如導致性能較差、出現 bug 甚至程序崩潰。您必須格外留意應用程序中的代碼,並使用良好的實踐來保障應用程序的總體完整性。

本文介紹 JNI 用戶最常遇到的 10 大編碼和設計錯誤。其目標是幫助您認識到並避免它們,以便您可以編寫安全、高效、性能出衆的 JNI 代碼。本文還將介紹一些用於在新代碼或已有代碼中查找這些問題的工具和技巧,並展示如何有效地應用它們。

JNI 編程缺陷可以分爲兩類:

  • 性能:代碼能執行所設計的功能,但運行緩慢或者以某種形式拖慢整個程序。
  • 正確性:代碼有時能正常運行,但不能可靠地提供所需的功能;最壞的情況是造成程序崩潰或掛起。

性能缺陷

程序員在使用 JNI 時的 5 大性能缺陷如下:

不緩存方法 ID、字段 ID 和類

要訪問 Java 對象的字段並調用它們的方法,本機代碼必須調用 FindClass()GetFieldID()GetMethodId() GetStaticMethodID()。對於 GetFieldID()GetMethodID()  GetStaticMethodID(),爲特定類返回的 ID 不會在 JVM 進程的生存期內發生變化。但是,獲取字段或方法的調用有時會需要在 JVM 中完成大量工作,因爲字段和方法可能是從超類中繼承而來的,這會讓 JVM 向上遍歷類層次結構來找到它們。由於 ID 對於特定類是相同的,因此您只需要查找一次,然後便可重複使用。同樣,查找類對象的開銷也很大,因此也應該緩存它們。

舉例來說,清單 1 展示了調用靜態方法所需的 JNI 代碼:


清單 1. 使用 JNI 調用靜態方法
				
int val=1;
jmethodID method;
jclass cls;

cls = (*env)->FindClass(env, "com/ibm/example/TestClass");
if ((*env)->ExceptionCheck(env)) {
   return ERR_FIND_CLASS_FAILED;
}
method = (*env)->GetStaticMethodID(env, cls, "setInfo", "(I)V");
if ((*env)->ExceptionCheck(env)) {
   return ERR_GET_STATIC_METHOD_FAILED;
}
(*env)->CallStaticVoidMethod(env, cls, method,val);
if ((*env)->ExceptionCheck(env)) {
   return ERR_CALL_STATIC_METHOD_FAILED;
}

當我們每次希望調用方法時查找類和方法 ID 都會產生六個本機調用,而不是第一次緩存類和方法 ID 時需要的兩個調用。

緩存會對您應用程序的運行時造成顯著的影響。考慮下面兩個版本的方法,它們的作用是相同的。清單 2 使用了緩存的字段 ID:


清單 2. 使用緩存的字段 ID
				
int sumValues2(JNIEnv* env, jobject obj, jobject allValues){

   jint avalue = (*env)->GetIntField(env, allValues, a);
   jint bvalue = (*env)->GetIntField(env, allValues, b);
   jint cvalue = (*env)->GetIntField(env, allValues, c);
   jint dvalue = (*env)->GetIntField(env, allValues, d);
   jint evalue = (*env)->GetIntField(env, allValues, e);
   jint fvalue = (*env)->GetIntField(env, allValues, f);

   return avalue + bvalue + cvalue + dvalue + evalue + fvalue;
}

性能技巧 #1

查找並全局緩存常用的類、字段 ID 和方法 ID。

清單 3 沒有使用緩存的字段 ID:


清單 3. 未緩存字段 ID
				
int sumValues2(JNIEnv* env, jobject obj, jobject allValues){
   jclass cls = (*env)->GetObjectClass(env,allValues);
   jfieldID a = (*env)->GetFieldID(env, cls, "a", "I");
   jfieldID b = (*env)->GetFieldID(env, cls, "b", "I");
   jfieldID c = (*env)->GetFieldID(env, cls, "c", "I");
   jfieldID d = (*env)->GetFieldID(env, cls, "d", "I");
   jfieldID e = (*env)->GetFieldID(env, cls, "e", "I");
   jfieldID f = (*env)->GetFieldID(env, cls, "f", "I");
   jint avalue = (*env)->GetIntField(env, allValues, a);
   jint bvalue = (*env)->GetIntField(env, allValues, b);
   jint cvalue = (*env)->GetIntField(env, allValues, c);
   jint dvalue = (*env)->GetIntField(env, allValues, d);
   jint evalue = (*env)->GetIntField(env, allValues, e);
   jint fvalue = (*env)->GetIntField(env, allValues, f);
   return avalue + bvalue + cvalue + dvalue + evalue + fvalue
}

清單 2 用 3,572 ms 運行了 10,000,000 次。清單 3 用了 86,217 ms — 多花了 24 倍的時間。

觸發數組副本

JNI 在 Java 代碼和本機代碼之間提供了一個乾淨的接口。爲了維持這種分離,數組將作爲不透明的句柄傳遞,並且本機代碼必須回調 JVM 以便使用 set 和 get 調用操作數組元素。Java 規範讓 JVM 實現決定讓這些調用提供對數組的直接訪問,還是返回一個數組副本。舉例來說,當數組經過優化而不需要連續存儲時,JVM 可以返回一個副本。(參見 參考資料 獲取關於 JVM 的信息)。

隨後,這些調用可以複製被操作的元素。舉例來說,如果您對含有 1,000 個元素的數組調用 GetLongArrayElements(),則會造成至少分配或複製 8,000 字節的數據(每個 long 1,000 元素 * 8 字節)。當您隨後使用 ReleaseLongArrayElements() 更新數組的內容時,需要另外複製 8,000 字節的數據來更新數組。即使您使用較新的 GetPrimitiveArrayCritical(),規範仍然准許 JVM 創建完整數組的副本。

性能技巧 #2

獲取和更新僅本機代碼需要的數組部分。在只要數組的一部分時通過適當的 API 調用來避免複製整個數組。

GetTypeArrayRegion()  SetTypeArrayRegion() 方法允許您獲取和更新數組的一部分,而不是整個數組。通過使用這些方法訪問較大的數組,您可以確保只複製本機代碼將要實際使用的數組部分。

舉例來說,考慮相同方法的兩個版本,如清單 4 所示:


清單 4. 相同方法的兩個版本
				
jlong getElement(JNIEnv* env, jobject obj, jlongArray arr_j, 
                 int element){
   jboolean isCopy;
   jlong result;
   jlong* buffer_j = (*env)->GetLongArrayElements(env, arr_j, &isCopy);
   result = buffer_j[element];
   (*env)->ReleaseLongArrayElements(env, arr_j, buffer_j, 0);
   return result;
}

jlong getElement2(JNIEnv* env, jobject obj, jlongArray arr_j, 
                  int element){
     jlong result;
     (*env)->GetLongArrayRegion(env, arr_j, element,1, &result);
     return result;
}

第一個版本可以生成兩個完整的數組副本,而第二個版本則完全沒有複製數組。當數組大小爲 1,000 字節時,運行第一個方法 10,000,000 次用了 12,055 ms;而第二個版本僅用了 1,421 ms。第一個版本多花了 8.5 倍的時間!

性能技巧 #3

在單個 API 調用中儘可能多地獲取或更新數組內容。如果可以一次較多地獲取和更新數組內容,則不要逐個迭代數組中的元素。

另一方面,如果您最終要獲取數組中的所有元素,則使用GetTypeArrayRegion() 逐個獲取數組中的元素是得不償失的。要獲取最佳的性能,應該確保以儘可能大的塊的來獲取和更新數組元素。如果您要迭代一個數組中的所有元素,則 清單 4 中這兩個getElement() 方法都不適用。比較好的方法是在一個調用中獲取大小合理的數組部分,然後再迭代所有這些元素,重複操作直到覆蓋整個數組。

回訪而不是傳遞參數

在調用某個方法時,您經常會在傳遞一個有多個字段的對象以及單獨傳遞字段之間做出選擇。在面向對象設計中,傳遞對象通常能提供較好的封裝,因爲對象字段的變化不需要改變方法簽名。但是,對於 JNI 來說,本機代碼必須通過一個或多個 JNI 調用返回到 JVM 以獲取需要的各個字段的值。這些額外的調用會帶來額外的開銷,因爲從本機代碼過渡到 Java 代碼要比普通方法調用開銷更大。因此,對於 JNI 來說,本機代碼從傳遞進來的對象中訪問大量單獨字段時會導致性能降低。

考慮清單 5 中的兩個方法,第二個方法假定我們緩存了字段 ID:


清單 5. 兩個方法版本
				
int sumValues(JNIEnv* env, jobject obj, jint a, jint b,jint c, jint d, jint e, jint f){
   return a + b + c + d + e + f;
}

int sumValues2(JNIEnv* env, jobject obj, jobject allValues){

   jint avalue = (*env)->GetIntField(env, allValues, a);
   jint bvalue = (*env)->GetIntField(env, allValues, b);
   jint cvalue = (*env)->GetIntField(env, allValues, c);
   jint dvalue = (*env)->GetIntField(env, allValues, d);
   jint evalue = (*env)->GetIntField(env, allValues, e);
   jint fvalue = (*env)->GetIntField(env, allValues, f);
   
   return avalue + bvalue + cvalue + dvalue + evalue + fvalue;
}

性能技巧 #4

如果可能,將各參數傳遞給 JNI 本機代碼,以便本機代碼回調 JVM 獲取所需的數據。

sumValues2() 方法需要 6 個 JNI 回調,並且運行 10,000,000 次需要 3,572 ms。其速度比 sumValues() 慢 6 倍,後者只需要 596 ms。通過傳遞 JNI 方法所需的數據,sumValues() 避免了大量的 JNI 開銷。

錯誤認定本機代碼與 Java 代碼之間的界限

本機代碼和 Java 代碼之間的界限是由開發人員定義的。界限的選定會對應用程序的總體性能造成顯著的影響。從 Java 代碼中調用本機代碼以及從本機代碼調用 Java 代碼的開銷比普通的 Java 方法調用高很多。此外,這種越界操作會干擾 JVM 優化代碼執行的能力。舉例來說,隨着 Java 代碼與本機代碼之間互操作的增加,實時編譯器的效率會隨之降低。經過測量,我們發現從 Java 代碼調用本機代碼要比普通調用多花 5 倍的時間。同樣,從本機代碼中調用 Java 代碼也需要耗費大量的時間。

性能技巧 #5

定義 Java 代碼與本機代碼之間的界限,最大限度地減少兩者之間的互相調用。

因此,在設計 Java 代碼與本機代碼之間的界限時應該最大限度地減少兩者之間的相互調用。消除不必要的越界調用,並且應該竭力在本機代碼中彌補越界調用造成的成本損失。最大限度地減少越界調用的一個關鍵因素是確保數據處於 Java/本機界限的正確一側。如果數據未在正確的一側,則另一側訪問數據的需求則會持續發起越界調用。

舉例來說,如果我們希望使用 JNI 爲某個串行端口提供接口,則可以構造兩種不同的接口。第一個版本如清單 6 所示:


清單 6. 到串行端口的接口:版本 1
				
/**
 * Initializes the serial port and returns a java SerialPortConfig objects
 * that contains the hardware address for the serial port, and holds
 * information needed by the serial port such as the next buffer 
 * to write data into
 * 
 * @param env JNI env that can be used by the method
 * @param comPortName the name of the serial port
 * @returns SerialPortConfig object to be passed ot setSerialPortBit 
 *          and getSerialPortBit calls
 */
jobject initializeSerialPort(JNIEnv* env, jobject obj,  jstring comPortName);

/**
 * Sets a single bit in an 8 bit byte to be sent by the serial port
 *
 * @param env JNI env that can be used by the method
 * @param serialPortConfig object returned by initializeSerialPort
 * @param whichBit value from 1-8 indicating which bit to set
 * @param bitValue 0th bit contains bit value to be set 
 */
void setSerialPortBit(JNIEnv* env, jobject obj, jobject serialPortConfig, 
  jint whichBit,  jint bitValue);

/**
 * Gets a single bit in an 8 bit byte read from the serial port
 *
 * @param env JNI env that can be used by the method
 * @param serialPortConfig object returned by initializeSerialPort
 * @param whichBit value from 1-8 indicating which bit to read
 * @returns the bit read in the 0th bit of the jint 
 */
jint getSerialPortBit(JNIEnv* env, jobject obj, jobject serialPortConfig, 
  jint whichBit);

/**
 * Read the next byte from the serial port
 * 
 * @param env JNI env that can be used by the method
 */
void readNextByte(JNIEnv* env, jobject obj);

/**
 * Send the next byte
 *
 * @param env JNI env that can be used by the method
 */
void sendNextByte(JNIEnv* env, jobject obj);

 清單 6 中,串行端口的所有配置數據都存儲在由 initializeSerialPort() 方法返回的 Java 對象中,並且將 Java 代碼完全控制對硬件中各數據位的設置。清單 6 所示版本的一些問題會造成其性能差於清單 7 中的版本:


清單 7. 到串行端口的接口:版本 2
				
/**
 * Initializes the serial port and returns an opaque handle to a native
 * structure that contains the hardware address for the serial port 
 * and holds information needed by the serial port such as 
 * the next buffer to write data into
 *
 * @param env JNI env that can be used by the method
 * @param comPortName the name of the serial port
 * @returns opaque handle to be passed to setSerialPortByte and 
 *          getSerialPortByte calls 
 */
jlong initializeSerialPort2(JNIEnv* env, jobject obj, jstring comPortName);

/**
 * sends a byte on the serial port
 * 
 * @param env JNI env that can be used by the method
 * @param serialPortConfig opaque handle for the serial port
 * @param byte the byte to be sent
 */
void sendSerialPortByte(JNIEnv* env, jobject obj, jlong serialPortConfig, 
    jbyte byte);

/**
 * Reads the next byte from the serial port
 * 
 * @param env JNI env that can be used by the method
 * @param serialPortConfig opaque handle for the serial port
 * @returns the byte read from the serial port
 */
jbyte readSerialPortByte(JNIEnv* env, jobject obj,  jlong serialPortConfig);

性能技巧 #6

構造應用程序的數據,使它位於界限的正確的側,並且可以由使用它的代碼訪問,而不需要大量跨界調用。

最顯著的一個問題就是,清單 6 中的接口在設置或檢索每個位,以及從串行端口讀取字節或者向串行端口寫入字節都需要一個 JNI 調用。這會導致讀取或寫入的每個字節的 JNI 調用變成原來的 9 倍。第二個問題是,清單 6 將串行端口的配置信息存儲在 Java/本機界限的錯誤一側的某個 Java 對象上。我們僅在本機側需要此配置數據;將它存儲在 Java 側會導致本機代碼向 Java 代碼發起大量回調以獲取/設置此配置信息。清單 7 將配置信息存儲在一個本機結構中(比如,一個struct),並向 Java 代碼返回了一個不透明的句柄,該句柄可以在後續調用中返回。這意味着,當本機代碼正在運行時,它可以直接訪問該結構,而不需要回調 Java 代碼獲取串行端口硬件地址或下一個可用的緩衝區等信息。因此,使用 清單 7 的實現的性能將大大改善。

使用大量本地引用而未通知 JVM

JNI 函數返回的任何對象都會創建本地引用。舉例來說,當您調用 GetObjectArrayElement() 時,將返回對數組中對象的本地引用。考慮清單 8 中的代碼在運行一個很大的數組時會使用多少本地引用:


清單 8. 創建本地引用
				
void workOnArray(JNIEnv* env, jobject obj, jarray array){
   jint i;
   jint count = (*env)->GetArrayLength(env, array);
   for (i=0; i < count; i++) {
      jobject element = (*env)->GetObjectArrayElement(env, array, i);
      if((*env)->ExceptionOccurred(env)) {
         break;
      }
      
      /* do something with array element */
   }
}

每次調用 GetObjectArrayElement() 時都會爲元素創建一個本地引用,並且直到本機代碼運行完成時纔會釋放。數組越大,所創建的本地引用就越多。

性能技巧 #7

當本機代碼造成創建大量本地引用時,在各引用不再需要時刪除它們。

這些本地引用會在本機方法終止時自動釋放。JNI 規範要求各本機代碼至少能創建 16 個本地引用。雖然這對許多方法來說都已經足夠了,但一些方法在其生存期中卻需要更多的本地引用。對於這種情況,您應該刪除不再需要的引用,方法是使用 JNI DeleteLocalRef() 調用,或者通知 JVM 您將使用更多的本地引用。

清單 9 向 清單 8 中的示例添加了一個 DeleteLocalRef() 調用,用於通知 JVM 本地引用已不再需要,以及將可同時存在的本地引用的數量限制爲一個合理的數值,而與數組的大小無關:


清單 9. 添加 DeleteLocalRef() 
				
void workOnArray(JNIEnv* env, jobject obj, jarray array){
   jint i;
   jint count = (*env)->GetArrayLength(env, array);
   for (i=0; i < count; i++) {
      jobject element = (*env)->GetObjectArrayElement(env, array, i);
      if((*env)->ExceptionOccurred(env)) {
         break;
      }
      
      /* do something with array element */

      (*env)->DeleteLocalRef(env, element);
   }
}

性能技巧 #8

如果某本機代碼將同時存在大量本地引用,則調用 JNIEnsureLocalCapacity() 方法通知 JVM 並允許它優化對本地引用的處理。

您可以調用 JNI EnsureLocalCapacity() 方法來通知 JVM 您將使用超過 16 個本地引用。這將允許 JVM 優化對該本機代碼的本地引用的處理。如果無法創建所需的本地引用,或者 JVM 採用的本地引用管理方法與所使用的本地引用數量之間不匹配造成了性能低下,則未成功通知 JVM 會導致 FatalError

正確性缺陷

5 大 JNI 正確性缺陷包括:

使用錯誤的 JNIEnv

執行本機代碼的線程使用 JNIEnv 發起 JNI 方法調用。但是,JNIEnv 並不是僅僅用於分派所請求的方法。JNI 規範規定每個 JNIEnv對於線程來說都是本地的。JVM 可以依賴於這一假設,將額外的線程本地信息存儲在 JNIEnv 中。一個線程使用另一個線程中的JNIEnv 會導致一些小 bug 和難以調試的崩潰問題。

正確性技巧 #1

僅在相關的單一線程中使用 JNIEnv

線程可以調用通過 JavaVM 對象使用 JNI 調用接口的 GetEnv() 來獲取JNIEnvJavaVM 對象本身可以通過使用 JNIEnv 方法調用 JNIGetJavaVM() 來獲取,並且可以被緩存以及跨線程共享。緩存JavaVM 對象的副本將允許任何能訪問緩存對象的線程在必要時獲取對它自己的 JNIEnv 訪問。要實現最優性能,線程應該繞過 JNIEnv,因爲查找它有時會需要大量的工作。

未檢測異常

本機能調用的許多 JNI 方法都會引起與執行線程相關的異常。當 Java 代碼執行時,這些異常會造成執行流程發生變化,這樣便會自動調用異常處理代碼。當某個本機方法調用某個 JNI 方法時會出現異常,但檢測異常並採用適當措施的工作將由本機來完成。一個常見的 JNI 缺陷是調用 JNI 方法而未在調用完成後測試異常。這會造成代碼有大量漏洞以及程序崩潰。

舉例來說,考慮調用 GetFieldID() 的代碼,如果無法找到所請求的字段,則會出現 NoSuchFieldError。如果本機代碼繼續運行而未檢測異常,並使用它認爲應該返回的字段 ID,則會造成程序崩潰。舉例來說,如果 Java 類經過修改,導致 charField 字段不再存在,則清單 10 中的代碼可能會造成程序崩潰 — 而不是拋出一個 NoSuchFieldError


清單 10. 未能檢測異常
				jclass objectClass;
jfieldID fieldID;
jchar result = 0;

objectClass = (*env)->GetObjectClass(env, obj);
fieldID = (*env)->GetFieldID(env, objectClass, "charField", "C");
result = (*env)->GetCharField(env, obj, fieldID);

正確性技巧 #2

在發起可能會導致異常的 JNI 調用後始終檢測異常。

添加異常檢測代碼要比在事後嘗試調試崩潰簡單很多。經常,您只需要檢測是否出現了某個異常,如果是則立即返回 Java 代碼以便拋出異常。然後,使用常規的 Java 異常處理流程處理它或者顯示它。舉例來說,清單 11 將檢測異常:


清單 11. 檢測異常
				jclass objectClass;
jfieldID fieldID;
jchar result = 0;

objectClass = (*env)->GetObjectClass(env, obj);
fieldID = (*env)->GetFieldID(env, objectClass, "charField", "C");
if((*env)->ExceptionOccurred(env)) {
   return;
}
result = (*env)->GetCharField(env, obj, fieldID);

不檢測和清除異常會導致出現意外行爲。您可以確定以下代碼的問題嗎?

fieldID = (*env)->GetFieldID(env, objectClass, "charField", "C");
if (fieldID == NULL){
   fieldID = (*env)->GetFieldID(env, objectClass,"charField", "D");
}
return (*env)->GetIntField(env, obj, fieldID);

問題在於,儘管代碼處理了初始 GetFieldID() 未返回字段 ID 的情況,但它並未清除 此調用將設置的異常。因此,本機返回的結果會造成立即拋出一個異常。

未檢測返回值

許多 JNI 方法都通過返回值來指示調用成功與否。與未檢測異常相似,這也存在一個缺陷,即代碼未檢測返回值卻假定調用成功而繼續運行。對於大多數 JNI 方法來說,它們都設置了返回值和異常狀態,這樣應用程序更可以通過檢測異常狀態或返回值來判斷方法運行正常與否。

正確性技巧 #3

始終檢測 JNI 方法的返回值,幷包括用於處理錯誤的代碼路徑。

您可以確定以下代碼的問題嗎?

clazz = (*env)->FindClass(env, "com/ibm/j9//HelloWorld");
method = (*env)->GetStaticMethodID(env, clazz, "main",
                   "([Ljava/lang/String;)V");
(*env)->CallStaticVoidMethod(env, clazz, method, NULL);

問題在於,如果未發現 HelloWorld 類,或者如果 main() 不存在,則本機將造成程序崩潰。

未正確使用數組方法

GetXXXArrayElements()  ReleaseXXXArrayElements() 方法允許您請求任何元素。同樣,GetPrimitiveArrayCritical()ReleasePrimitiveArrayCritical()GetStringCritical() ReleaseStringCritical() 允許您請求數組元素或字符串字節,以最大限度降低直接指向數組或字符串的可能性。這些方法的使用存在兩個常見的缺陷。其一,忘記在 ReleaseXXX() 方法調用中提供更改。即便使用 Critical 版本,也無法保證您能獲得對數組或字符串的直接引用。一些 JVM 始終返回一個副本,並且在這些 JVM 中,如果您在 ReleaseXXX() 調用中指定了 JNI_ABORT,或者忘記調用了 ReleaseXXX(),則對數組的更改不會被複制回去。

舉例來說,考慮以下代碼:

void modifyArrayWithoutRelease(JNIEnv* env, jobject obj, jarray arr1) {
   jboolean isCopy;
   jbyte* buffer = (*env)-> (*env)->GetByteArrayElements(env,arr1,&isCopy);
   if ((*env)->ExceptionCheck(env)) return; 
   
   buffer[0] = 1;
}

正確性技巧 #4

不要忘記爲每個 GetXXX() 使用模式 0(複製回去並釋放內存)調用 ReleaseXXX()

在提供直接指向數組的指針的 JVM 上,該數組將被更新;但是,在返回副本的 JVM 上則不是如此。這會造成您的代碼在一些 JVM 上能夠正常運行,而在其他 JVM 上卻會出錯。您應該始終始終包括一個釋放(release)調用,如清單 12 所示:


清單 12. 包括一個釋放調用
				
void modifyArrayWithRelease(JNIEnv* env, jobject obj, jarray arr1) {
   jboolean isCopy;
   jbyte* buffer = (*env)-> (*env)->GetByteArrayElements(env,arr1,&isCopy);
   if ((*env)->ExceptionCheck(env)) return; 
   
   buffer[0] = 1;

   (*env)->ReleaseByteArrayElements(env, arr1, buffer, JNI_COMMIT);
   if ((*env)->ExceptionCheck(env)) return;
}

第二個缺陷是不注重規範對在 GetXXXCritical()  ReleaseXXXCritical() 之間執行的代碼施加的限制。本機可能不會在這些方法之間發起任何調用,並且可能不會由於任何原因而阻塞。未重視這些限制會造成應用程序或 JVM 中出現間斷性死鎖。

舉例來說,以下代碼看上去可能沒有問題:

void workOnPrimitiveArray(JNIEnv* env, jobject obj, jarray arr1) {
   jboolean isCopy;
   jbyte* buffer = (*env)->GetPrimitiveArrayCritical(env, arr1, &isCopy); 
   if ((*env)->ExceptionCheck(env)) return; 
   
   processBufferHelper(buffer);
   
   (*env)->ReleasePrimitiveArrayCritical(env, arr1, buffer, 0); 
   if ((*env)->ExceptionCheck(env)) return;
}

正確性技巧 #5

確保代碼不會在 GetXXXCritical() ReleaseXXXCritical() 調用之間發起任何 JNI 調用或由於任何原因出現阻塞。

但是,我們需要驗證在調用 processBufferHelper() 時可以運行的所有代碼都沒有違反任何限制。這些限制適用於在 Get  Release 調用之間執行的所有代碼,無論它是不是本機的一部分。

未正確使用全局引用

本機可以創建一些全局引用,以保證對象在不再需要時纔會被垃圾收集器回收。常見的缺陷包括忘記刪除已創建的全局引用,或者完全失去對它們的跟蹤。考慮一個本機創建了全局引用,但是未刪除它或將它存儲在某處:

lostGlobalRef(JNIEnv* env, jobject obj, jobject keepObj) {
   jobject gref = (*env)->NewGlobalRef(env, keepObj);
}

正確性技巧 #6

始終跟蹤全局引用,並確保不再需要對象時刪除它們。

創建全局引用時,JVM 會將它添加到一個禁止垃圾收集的對象列表中。當本機返回時,它不僅會釋放全局引用,應用程序還無法獲取引用以便稍後釋放它 — 因此,對象將會始終存在。不釋放全局引用會造成各種問題,不僅因爲它們會保持對象本身爲活動狀態,還因爲它們會將通過該對象能接觸到的所有對象都保持爲活動狀態。在某些情況下,這會顯著加劇內存泄漏。

避免常見缺陷

假設您編寫了一些新 JNI 代碼,或者繼承了別處的某些 JVI 代碼,如何才能確保避免了常見缺陷,或者在繼承代碼中發現它們?表 1 提供了一些確定這些常見缺陷的技巧:


表 1. 確定 JNI 編程缺陷的清單
 未緩存觸發數組副本錯誤界限過多回訪使用大量本地引用使用錯誤的 JNIEnv未檢測異常未檢測返回值未正確使用數組未正確使用全局引用
規範驗證           X X   X  
方法跟蹤 X X X X     X   X X
轉儲                   X
-verbose:jni         X          
代碼審查 X X X X X X X X X X

您可以在開發週期的早期確定許多常見缺陷,方法如下:

根據 JNI 規範驗證新代碼

維持規範的限制列表並審查本機與列表的遵從性是一個很好的實踐,這可以通過手動或自動代碼分析來完成。確保遵從性的工作可能會比調試由於違背限制而出現的細小和間斷性故障輕鬆很多。下面提供了一個專門針對新開發代碼(或對您來說是新的)的規範順從性檢查列表:

  • 驗證 JNIEnv 僅與與之相關的線程使用。
  • 確認未在 GetXXXCritical()  ReleaseXXXCritical() 部分調用 JNI 方法。
  • 對於進入關鍵部分的方法,驗證該方法未在釋放前返回。
  • 驗證在所有可能引起異常的 JNI 調用之前都檢測了異常。
  • 確保所有 Get/Release 調用在各 JNI 方法中都是相匹配的。

IBM 的 JVM 實現包括開啓自動 JNI 檢測的選項,其代價是較慢的執行速度。與出色的代碼單元測試相結合,這是一種極爲強大的工具。您可以運行應用程序或單元測試來執行遵從性檢查,或者確定所遇到的 bug 是否是由本機引起的。除了執行上述規範遵從性檢查之外,它還能確保:

  • 傳遞給 JNI 方法的參數屬於正確的類型。
  • JNI 代碼未讀取超過數組結束部分之外的內容。
  • 傳遞給 JNI 方法的指針都是有效的。

JNI 檢測報告的所有結論並不一定都是代碼中的錯誤。它們還包括一些針對代碼的建議,您應該仔細閱讀它們以確保代碼功能正常。

您可以通過以下命令行啓用 JNI 檢測選項:

Usage: -Xcheck:jni:[option[,option[,...]]]

        all            check application and system classes
        verbose        trace certain JNI functions and activities
        trace          trace all JNI functions
        nobounds       do not perform bounds checking on strings and arrays
        nonfatal       do not exit when errors are detected
        nowarn         do not display warnings
        noadvice       do not display advice
        novalist       do not check for va_list reuse
        valist         check for va_list reuse
        pedantic       perform more thorough, but slower checks
        help           print this screen

使用 IBM JVM 的 -Xcheck:jni 選項作爲標準開發流程的一部分可以幫助您更加輕鬆地找出代碼錯誤。特別是,它可以幫助您確定在錯誤線程中使用 JNIEnv 以及未正確使用關鍵區域的缺陷的根源。

最新的 Sun JVM 提供了一個類似的 -Xcheck:jni 選項。它的工作原理不同於 IBM 版本,並且提供了不同的信息,但是它們的作用是相同的。它會在發現未符合規範的代碼時發出警告,並且可以幫助您確定常見的 JNI 缺陷。

分析方法跟蹤

生成對已調用本機方法以及這些本機方法發起的 JNI 回調的跟蹤,這對確定大量常見缺陷的根源是非常有用的。可確定的問題包括:

  • 大量 GetFieldID()  GetMethodID() 調用 — 特別是,如果這些調用針對相同的字段和方法 — 表示字段和方法未被緩存。
  • GetTypeArrayElements() 調用實例(而非 GetTypeArrayRegion())有時表示存在不必要的複製。
  • 在 Java 代碼與本機代碼之前來回快速切換(由時間戳指示)有時表示 Java 代碼與本機代碼之間的界限有誤,從而造成性能較差。
  • 每個本機函數調用後面都緊接着大量 GetFieldID() 調用,這種模式表示並未傳遞所需的參數,而是強制本機回訪完成工作所需的數據。
  • 調用可能拋出異常的 JNI 方法之後缺少對 ExceptionOccurred()  ExceptionCheck() 的調用表示本機未正確檢測異常。
  • GetXXX()  ReleaseXXX() 方法調用的數量不匹配表示缺少釋放操作。
  •  GetXXXCritical()  ReleaseXXXCritical() 調用之間調用 JNI 方法表示未遵循規範施加的限制。
  • 如果調用 GetXXXCritical()  ReleaseXXXCritical() 之間相隔的時間較長,則表示未遵循 “不要阻塞調用” 規範所施加的限制。
  • NewGlobalRef()  DeleteGlobalRef() 調用之間出現嚴重失衡表示釋放不再需要的引用時出現故障。

一些 JVM 實現提供了一種可用於生存方法跟蹤的機制。您還可以通過各種外部工具來生成跟蹤,比如探查器和代碼覆蓋工具。

IBM JVM 實現提供了許多用於生成跟蹤信息的方法。第一種方法是使用 -Xcheck:jni:trace 選項。這將生成對已調用的本機方法以及它們發起的 JNI 回調的跟蹤。清單 13 顯示某個跟蹤的摘錄(爲便於閱讀,隔開了某些行):


清單 13. IBM JVM 實現所生成的方法跟蹤
				
Call JNI: java/lang/System.getPropertyList()[Ljava/lang/String; {
00177E00   Arguments: void
00177E00   FindClass("java/lang/String")
00177E00   FindClass("com/ibm/oti/util/Util")
00177E00   Call JNI: com/ibm/oti/vm/VM.useNativesImpl()Z {
00177E00     Arguments: void
00177E00     Return: (jboolean)false
00177E00   }
00177E00   Call JNI: java/security/AccessController.initializeInternal()V {
00177E00     Arguments: void
00177E00     FindClass("java/security/AccessController")
00177E00     GetStaticMethodID(java/security/AccessController, "doPrivileged", 
             "(Ljava/security/PrivilegedAction;)Ljava/lang/Object;")
00177E00     GetStaticMethodID(java/security/AccessController, "doPrivileged", 
             "(Ljava/security/PrivilegedExceptionAction;)Ljava/lang/Object;")
00177E00     GetStaticMethodID(java/security/AccessController, "doPrivileged", 
             "(Ljava/security/PrivilegedAction;Ljava/security/AccessControlContext;)
             Ljava/lang/Object;")
00177E00     GetStaticMethodID(java/security/AccessController, "doPrivileged", 
             "(Ljava/security/PrivilegedExceptionAction;
             Ljava/security/AccessControlContext;)Ljava/lang/Object;")
00177E00     Return: void
00177E00   }
00177E00   GetStaticMethodID(com/ibm/oti/util/Util, "toString", 
             "([BII)Ljava/lang/String;")
00177E00   NewByteArray((jsize)256)
00177E00   NewObjectArray((jsize)118, java/lang/String, (jobject)NULL)
00177E00   SetByteArrayRegion([B@0018F7D0, (jsize)0, (jsize)30, (void*)7FF2E1D4)
00177E00   CallStaticObjectMethod/CallStaticObjectMethodV(com/ibm/oti/util/Util, 
             toString([BII)Ljava/lang/String;, (va_list)0007D758) {
00177E00     Arguments: (jobject)0x0018F7D0, (jint)0, (jint)30
00177E00     Return: (jobject)0x0018F7C8
00177E00   }
00177E00   ExceptionCheck()

清單 13 中的跟蹤摘錄顯示了已調用的本機方法(比如 AccessController.initializeInternal()V)以及本機方法發起的 JNI 回調。

使用 -verbose:jni 選項

Sun 和 IBM JVM 還提供了一個 -verbose:jni 選項。對於 IBM JVM 而言,開啓此選項將提供關於當前 JNI 回調的信息。清單 14 顯示了一個示例:


清單 14. 使用 IBM JVM 的 -verbose:jni 列出 JNI 回調
				
<JNI GetStringCritical: buffer=0x100BD010>
<JNI ReleaseStringCritical: buffer=100BD010>
<JNI GetStringChars: buffer=0x03019C88>
<JNI ReleaseStringChars: buffer=03019C88>
<JNI FindClass: java/lang/String>
<JNI FindClass: java/io/WinNTFileSystem>
<JNI GetMethodID: java/io/WinNTFileSystem.<init> ()V>
<JNI GetStaticMethodID: com/ibm/j9/offload/tests/HelloWorld.main ([Ljava/lang/String;)V>
<JNI GetMethodID: java/lang/reflect/Method.getModifiers ()I>
<JNI FindClass: java/lang/String>

對於 Sun JVM 而言,開啓 -verbose:jni 選項不會提供關於當前調用的信息,但它會提供關於所使用的本機方法的額外信息。清單 15 顯示了一個示例:


清單 15. 使用 Sun JVM 的 -verbose:jni 
				
[Dynamic-linking native method java.util.zip.ZipFile.getMethod ... JNI]
[Dynamic-linking native method java.util.zip.Inflater.initIDs ... JNI]
[Dynamic-linking native method java.util.zip.Inflater.init ... JNI]
[Dynamic-linking native method java.util.zip.Inflater.inflateBytes ... JNI]
[Dynamic-linking native method java.util.zip.ZipFile.read ... JNI]
[Dynamic-linking native method java.lang.Package.getSystemPackage0 ... JNI]
[Dynamic-linking native method java.util.zip.Inflater.reset ... JNI]

開啓此選項還會讓 JVM 針對使用過多本地引用而未通知 JVM 的情況發起警告。舉例來說,IBM JVM 生成了這樣一個消息:

JVMJNCK065W JNI warning in FindClass: Automatically grew local reference frame capacity 
from 16 to 48. 17 references are in use. 
Use EnsureLocalCapacity or PushLocalFrame to explicitly grow the frame. 

雖然 -verbose:jni  -Xcheck:jni:trace 選項可幫助您方便地獲取所需的信息,但手動審查此信息是一項艱鉅的任務。一個不錯的提議是,創建一些腳本或實用工具來處理由 JVM 生成的跟蹤文件,並查看 警告

生成轉儲

運行中的 Java 進程生成的轉儲包含大量關於 JVM 狀態的信息。對於許多 JVM 來說,它們包括關於全局引用的信息。舉例來說,最新的 Sun JVM 在轉儲信息中包括這樣一行:

JNI global references: 73

通過生成前後轉儲,您可以確定是否創建了任何未正常釋放的全局引用。

您可以在 UNIX® 環境中通過對 java 進程發起 kill -3  kill -QUIT 來請求轉儲。在 Windows® 上,使用 Ctrl+Break 組合鍵。

對於 IBM JVM,使用以下步驟獲取關於全局引用的信息:

  1.  -Xdump:system:events=user 添加到命令行。這樣,當您在 UNIX 系統上調用 kill -3 或者在 Windows 上按下 Ctrl+Break 時,JVM 便會生成轉儲。
  2. 程序在運行中時會生成後續轉儲。
  3. 運行 jextract -nozip core.XXX output.xml,這將會將轉儲信息提取到可讀格式的 output.xml 中。
  4. 查找 output.xml 中的 JNIGlobalReference 條目,它提供關於當前全局引用的信息,如清單 16 所示:

清單 16. output.xml 中的 JNIGlobalReference 條目
				
<rootobject type="Thread" id="0x10089990" reachability="strong" />
<rootobject type="Thread" id="0x10089fd0" reachability="strong" />
<rootobject type="JNIGlobalReference" id="0x100100c0" reachability="strong" />
<rootobject type="JNIGlobalReference" id="0x10011250" reachability="strong" />
<rootobject type="JNIGlobalReference" id="0x10011840" reachability="strong" />
<rootobject type="JNIGlobalReference" id="0x10011880" reachability="strong" />
<rootobject type="JNIGlobalReference" id="0x10010af8" reachability="strong" />
<rootobject type="JNIGlobalReference" id="0x10010360" reachability="strong" />
<rootobject type="JNIGlobalReference" id="0x10081f48" reachability="strong" />
<rootobject type="StringTable" id="0x10010be0" reachability="weak" />
<rootobject type="StringTable" id="0x10010c70" reachability="weak" />
<rootobject type="StringTable" id="0x10010d00" reachability="weak" />
<rootobject type="StringTable" id="0x10011018" reachability="weak" />

通過查看後續 Java 轉儲中報告的數值,您可以確定全局引用是否出現的泄漏。

參見 參考資料 獲取關於使用轉儲文件以及 IBM JVM 的 jextract 的更多信息。

執行代碼審查

代碼審查經常可用於確定常見缺陷,並且可以在各種級別上完成。繼承新代碼時,快速掃描可以發現各種問題,從而避免稍後花費更多時間進行調試。在某些情況下,審查是確定缺陷實例(比如未檢查返回值)的唯一方法。舉例來說,此代碼的問題可能可以通過代碼審查輕鬆確定,但卻很難通過調試來發現:

int calledALot(JNIEnv* env, jobject obj, jobject allValues){
   jclass cls = (*env)->GetObjectClass(env,allValues); 
   jfieldID a = (*env)->GetFieldID(env, cls, "a", "I");
   jfieldID b = (*env)->GetFieldID(env, cls, "b", "I");
   jfieldID c = (*env)->GetFieldID(env, cls, "c", "I");
   jfieldID d = (*env)->GetFieldID(env, cls, "d", "I");
   jfieldID e = (*env)->GetFieldID(env, cls, "e", "I");
   jfieldID f = (*env)->GetFieldID(env, cls, "f", "I");

}

jclass getObjectClassHelper(jobject object){ 
   /* use globally cached JNIEnv */
   return cls = (*globalEnvStatic)->GetObjectClass(globalEnvStatic,allValues); 
}

代碼審查可能會發現第一個方法未正確緩存字段 ID,儘管重複使用了相同的 ID,並且第二個方法所使用的 JNIEnv 並不在應該在的線程上。

結束語

現在,您已經瞭解了 10 大 JNI 編程缺陷,以及一些用於在已有或新代碼中確定它們的良好實踐。堅持應用這些實踐有助於提高 JNI 代碼的正確率,並且您的應用程序可以實現所需的性能水平。

有效集成已有代碼資源的能力對於面向對象架構(SOA)和基於雲的計算這兩種技術的成功至關重要。JNI 是一項非常重要的技術,用於將非 Java 舊有代碼和組件集成到基於 Java 的平臺中,充當 SOA 或基於雲的系統的基本元素。正確使用 JNI 可以加速將這些組件轉變爲服務的過程,並允許您從現有投資中獲得最大優勢。

使用 Java Native Interface 的最佳實踐

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