1. 程式人生 > >在 JNI 程式設計中避免記憶體洩漏與崩潰

在 JNI 程式設計中避免記憶體洩漏與崩潰

JNI 程式設計簡介

JNI,Java Native Interface,是 native code 的程式設計介面。JNI 使 Java 程式碼程式可以與 native code 互動——在 Java 程式中呼叫 native code;在 native code 中嵌入 Java 虛擬機器呼叫 Java 的程式碼。

JNI 程式設計在軟體開發中運用廣泛,其優勢可以歸結為以下幾點:

  1. 利用 native code 的平臺相關性,在平臺相關的程式設計中彰顯優勢。
  2. 對 native code 的程式碼重用。
  3. native code 底層操作,更加高效。

然而任何事物都具有兩面性,JNI 程式設計也同樣如此。程式設計師在使用 JNI 時應當認識到 JNI 程式設計中如下的幾點弊端,揚長避短,才可以寫出更加完善、高效能的程式碼:

  1. 從 Java 環境到 native code 的上下文切換耗時、低效。
  2. JNI 程式設計,如果操作不當,可能引起 Java 虛擬機器的崩潰。
  3. JNI 程式設計,如果操作不當,可能引起記憶體洩漏。

JAVA 中的記憶體洩漏

JAVA 程式設計中的記憶體洩漏,從洩漏的記憶體位置角度可以分為兩種:JVM 中 Java Heap 的記憶體洩漏;JVM 記憶體中 native memory 的記憶體洩漏。

Java Heap 的記憶體洩漏

Java 物件儲存在 JVM 程序空間中的 Java Heap 中,Java Heap 可以在 JVM 執行過程中動態變化。如果 Java 物件越來越多,佔據 Java Heap 的空間也越來越大,JVM 會在執行時擴充 Java Heap 的容量。如果 Java Heap 容量擴充到上限,並且在 GC 後仍然沒有足夠空間分配新的 Java 物件,便會丟擲 out of memory 異常,導致 JVM 程序崩潰。

Java Heap 中 out of memory 異常的出現有兩種原因——①程式過於龐大,致使過多 Java 物件的同時存在;②程式編寫的錯誤導致 Java Heap 記憶體洩漏。

多種原因可能導致 Java Heap 記憶體洩漏。JNI 程式設計錯誤也可能導致 Java Heap 的記憶體洩漏。

JVM 中 native memory 的記憶體洩漏

從作業系統角度看,JVM 在執行時和其它程序沒有本質區別。在系統級別上,它們具有同樣的排程機制,同樣的記憶體分配方式,同樣的記憶體格局。

JVM 程序空間中,Java Heap 以外的記憶體空間稱為 JVM 的 native memory。程序的很多資源都是儲存在 JVM 的 native memory 中,例如載入的程式碼映像,執行緒的堆疊,執行緒的管理控制塊,JVM 的靜態資料、全域性資料等等。也包括 JNI 程式中 native code 分配到的資源。

在 JVM 執行中,多數程序資源從 native memory 中動態分配。當越來越多的資源在 native memory 中分配,佔據越來越多 native memory 空間並且達到 native memory 上限時,JVM 會丟擲異常,使 JVM 程序異常退出。而此時 Java Heap 往往還沒有達到上限。

多種原因可能導致 JVM 的 native memory 記憶體洩漏。例如 JVM 在執行中過多的執行緒被建立,並且在同時執行。JVM 為執行緒分配的資源就可能耗盡 native memory 的容量。

JNI 程式設計錯誤也可能導致 native memory 的記憶體洩漏。對這個話題的討論是本文的重點。

JNI 程式設計中明顯的記憶體洩漏

JNI 程式設計實現了 native code 和 Java 程式的互動,因此 JNI 程式碼程式設計既遵循 native code 程式語言的程式設計規則,同時也遵守 JNI 程式設計的文件規範。在記憶體管理方面,native code 程式語言本身的記憶體管理機制依然要遵循,同時也要考慮 JNI 程式設計的記憶體管理。

本章簡單概括 JNI 程式設計中顯而易見的記憶體洩漏。從 native code 程式語言自身的記憶體管理,和 JNI 規範附加的記憶體管理兩方面進行闡述。

Native Code 本身的記憶體洩漏

JNI 程式設計首先是一門具體的程式語言,或者 C 語言,或者 C++,或者彙編,或者其它 native 的程式語言。每門程式語言環境都實現了自身的記憶體管理機制。因此,JNI 程式開發者要遵循 native 語言本身的記憶體管理機制,避免造成記憶體洩漏。以 C 語言為例,當用 malloc() 在程序堆中動態分配記憶體時,JNI 程式在使用完後,應當呼叫 free() 將記憶體釋放。總之,所有在 native 語言程式設計中應當注意的記憶體洩漏規則,在 JNI 程式設計中依然適應。

Native 語言本身引入的記憶體洩漏會造成 native memory 的記憶體,嚴重情況下會造成 native memory 的 out of memory。

Global Reference 引入的記憶體洩漏

JNI 程式設計還要同時遵循 JNI 的規範標準,JVM 附加了 JNI 程式設計特有的記憶體管理機制。

JNI 中的 Local Reference 只在 native method 執行時存在,當 native method 執行完後自動失效。這種自動失效,使得對 Local Reference 的使用相對簡單,native method 執行完後,它們所引用的 Java 物件的 reference count 會相應減 1。不會造成 Java Heap 中 Java 物件的記憶體洩漏。

而 Global Reference 對 Java 物件的引用一直有效,因此它們引用的 Java 物件會一直存在 Java Heap 中。程式設計師在使用 Global Reference 時,需要仔細維護對 Global Reference 的使用。如果一定要使用 Global Reference,務必確保在不用的時候刪除。就像在 C 語言中,呼叫 malloc() 動態分配一塊記憶體之後,呼叫 free() 釋放一樣。否則,Global Reference 引用的 Java 物件將永遠停留在 Java Heap 中,造成 Java Heap 的記憶體洩漏。

JNI 程式設計中潛在的記憶體洩漏——對 LocalReference 的深入理解

Local Reference 在 native method 執行完成後,會自動被釋放,似乎不會造成任何的記憶體洩漏。但這是錯誤的。對 Local Reference 的理解不夠,會造成潛在的記憶體洩漏。

本章重點闡述 Local Reference 使用不當可能引發的記憶體洩漏。引入兩個錯誤例項,也是 JNI 程式設計師容易忽視的錯誤;在此基礎上介紹 Local Reference 表,對比 native method 中的區域性變數和 JNI Local Reference 的不同,使讀者深入理解 JNI Local Reference 的實質;最後為 JNI 程式設計師提出應該如何正確合理使用 JNI Local Reference,以避免記憶體洩漏。

錯誤例項 1

在某些情況下,我們可能需要在 native method 裡面建立大量的 JNI Local Reference。這樣可能導致 native memory 的記憶體洩漏,如果在 native method 返回之前 native memory 已經被用光,就會導致 native memory 的 out of memory。

在程式碼清單 1 裡,我們迴圈執行 count 次,JNI function NewStringUTF() 在每次迴圈中從 Java Heap 中建立一個 String 物件,str 是 Java Heap 傳給 JNI native method 的 Local Reference,每次迴圈中新建立的 String 物件覆蓋上次迴圈中 str 的內容。str 似乎一直在引用到一個 String 物件。整個執行過程中,我們看似只建立一個 Local Reference。

執行程式碼清單 1 的程式,第一部分為 Java 程式碼,nativeMethod(int i) 中,輸入引數設定迴圈的次數。第二部分為 JNI 程式碼,用 C 語言實現了 nativeMethod(int i)。

清單 1. Local Reference 引發記憶體洩漏
 Java 程式碼部分
 class TestLocalReference { 
 private native void nativeMethod(int i); 
 public static void main(String args[]) { 
         TestLocalReference c = new TestLocalReference(); 
         //call the jni native method 
         c.nativeMethod(1000000); 
 }  
 static { 
 //load the jni library 
 System.loadLibrary("StaticMethodCall"); 
 } 
 } 

 JNI 程式碼,nativeMethod(int i) 的 C 語言實現
 #include<stdio.h> 
 #include<jni.h> 
 #include"TestLocalReference.h"
 JNIEXPORT void JNICALL Java_TestLocalReference_nativeMethod 
 (JNIEnv * env, jobject obj, jint count) 
 { 
 jint i = 0; 
 jstring str; 

 for(; i<count; i++) 
         str = (*env)->NewStringUTF(env, "0"); 
 } 
執行結果
 JVMCI161: FATAL ERROR in native method: Out of memory when expanding 
 local ref table beyond capacity 
 at TestLocalReference.nativeMethod(Native Method) 
 at TestLocalReference.main(TestLocalReference.java:9)

執行結果證明,JVM 執行異常終止,原因是建立了過多的 Local Reference,從而導致 out of memory。實際上,nativeMethod 在執行中建立了越來越多的 JNI Local Reference,而不是看似的始終只有一個。過多的 Local Reference,導致了 JNI 內部的 JNI Local Reference 表記憶體溢位。

錯誤例項 2

例項 2 是例項 1 的變種,Java 程式碼未作修改,但是 nativeMethod(int i) 的 C 語言實現稍作修改。在 JNI 的 native method 中實現的 utility 函式中建立 Java 的 String 物件。utility 函式只建立一個 String 物件,返回給呼叫函式,但是 utility 函式對呼叫者的使用情況是未知的,每個函式都可能呼叫它,並且同一函式可能呼叫它多次。在例項 2 中,nativeMethod 在迴圈中呼叫 count 次,utility 函式在建立一個 String 物件後即返回,並且會有一個退棧過程,似乎所建立的 Local Reference 會在退棧時被刪除掉,所以應該不會有很多 Local Reference 被建立。實際執行結果並非如此。

清單 2. Local Reference 引發記憶體洩漏
 Java 程式碼部分參考例項 1,未做任何修改。

 JNI 程式碼,nativeMethod(int i) 的 C 語言實現
 #include<stdio.h> 
 #include<jni.h> 
 #include"TestLocalReference.h"
 jstring CreateStringUTF(JNIEnv * env) 
 { 
 return (*env)->NewStringUTF(env, "0"); 
 } 
 JNIEXPORT void JNICALL Java_TestLocalReference_nativeMethod 
 (JNIEnv * env, jobject obj, jint count) 
 { 
 jint i = 0; 
 for(; i<count; i++) 
 { 
         str = CreateStringUTF(env); 
 } 
 } 
執行結果
 JVMCI161: FATAL ERROR in native method: Out of memory when expanding local ref 
 table beyond  capacity 
 at TestLocalReference.nativeMethod(Native Method) 
 at TestLocalReference.main(TestLocalReference.java:9)

執行結果證明,例項 2 的結果與例項 1 的完全相同。過多的 Local Reference 被建立,仍然導致了 JNI 內部的 JNI Local Reference 表記憶體溢位。實際上,在 utility 函式 CreateStringUTF(JNIEnv * env)

執行完成後的退棧過程中,建立的 Local Reference 並沒有像 native code 中的區域性變數那樣被刪除,而是繼續在 Local Reference 表中存在,並且有效。Local Reference 和區域性變數有著本質的區別。

Local Reference 深層解析

Java JNI 的文件規範只描述了 JNI Local Reference 是什麼(存在的目的),以及應該怎麼使用 Local Reference(開放的介面規範)。但是對 Java 虛擬機器中 JNI Local Reference 的實現並沒有約束,不同的 Java 虛擬機器有不同的實現機制。這樣的好處是,不依賴於具體的 JVM 實現,有好的可移植性;並且開發簡單,規定了“應該怎麼做、怎麼用”。但是弊端是初級開發者往往看不到本質,“不知道為什麼這樣做”。對 Local Reference 沒有深層的理解,就會在程式設計過程中無意識的犯錯。

Local Reference 和 Local Reference 表

理解 Local Reference 表的存在是理解 JNI Local Reference 的關鍵。

JNI Local Reference 的生命期是在 native method 的執行期(從 Java 程式切換到 native code 環境時開始建立,或者在 native method 執行時呼叫 JNI function 建立),在 native method 執行完畢切換回 Java 程式時,所有 JNI Local Reference 被刪除,生命期結束(呼叫 JNI function 可以提前結束其生命期)。

實際上,每當執行緒從 Java 環境切換到 native code 上下文時(J2N),JVM 會分配一塊記憶體,建立一個 Local Reference 表,這個表用來存放本次 native method 執行中建立的所有的 Local Reference。每當在 native code 中引用到一個 Java 物件時,JVM 就會在這個表中建立一個 Local Reference。比如,例項 1 中我們呼叫 NewStringUTF() 在 Java Heap 中建立一個 String 物件後,在 Local Reference 表中就會相應新增一個 Local Reference。

圖 1. Local Reference 表、Local Reference 和 Java 物件的關係
圖 1. Local Reference 表、Local Reference 和 Java 物件的關係

圖 1 中:

⑴執行 native method 的執行緒的堆疊記錄著 Local Reference 表的記憶體位置(指標 p)。

⑵ Local Reference 表中存放 JNI Local Reference,實現 Local Reference 到 Java 物件的對映。

⑶ native method 程式碼間接訪問 Java 物件(java obj1,java obj2)。通過指標 p 定位相應的 Local Reference 的位置,然後通過相應的 Local Reference 對映到 Java 物件。

⑷當 native method 引用一個 Java 物件時,會在 Local Reference 表中建立一個新 Local Reference。在 Local Reference 結構中寫入內容,實現 Local Reference 到 Java 物件的對映。

⑸ native method 呼叫 DeleteLocalRef() 釋放某個 JNI Local Reference 時,首先通過指標 p 定位相應的 Local Reference 在 Local Ref 表中的位置,然後從 Local Ref 表中刪除該 Local Reference,也就取消了對相應 Java 物件的引用(Ref count 減 1)。

⑹當越來越多的 Local Reference 被建立,這些 Local Reference 會在 Local Ref 表中佔據越來越多記憶體。當 Local Reference 太多以至於 Local Ref 表的空間被用光,JVM 會丟擲異常,從而導致 JVM 的崩潰。

Local Ref 不是 native code 的區域性變數

很多人會誤將 JNI 中的 Local Reference 理解為 Native Code 的區域性變數。這是錯誤的。

Native Code 的區域性變數和 Local Reference 是完全不同的,區別可以總結為:

⑴區域性變數儲存線上程堆疊中,而 Local Reference 儲存在 Local Ref 表中。

⑵區域性變數在函式退棧後被刪除,而 Local Reference 在呼叫 DeleteLocalRef() 後才會從 Local Ref 表中刪除,並且失效,或者在整個 Native Method 執行結束後被刪除。

⑶可以在程式碼中直接訪問區域性變數,而 Local Reference 的內容無法在程式碼中直接訪問,必須通過 JNI function 間接訪問。JNI function 實現了對 Local Reference 的間接訪問,JNI function 的內部實現依賴於具體 JVM。

程式碼清單 1 中 str = (*env)->NewStringUTF(env, "0");

str 是 jstring 型別的區域性變數。Local Ref 表中會新建立一個 Local Reference,引用到 NewStringUTF(env, "0") 在 Java Heap 中新建的 String 物件。如圖 2 所示:

圖 2. str 間接引用 string 物件
圖 2. str 間接引用 string 物件

圖 2 中,str 是區域性變數,在 native method 堆疊中。Local Ref3 是新建立的 Local Reference,在 Local Ref 表中,引用新建立的 String 物件。JNI 通過 str 和指標 p 間接定位 Local Ref3,但 p 和 Local Ref3 對 JNI 程式設計師不可見。

Local Reference 導致記憶體洩漏

在以上論述基礎上,我們通過分析錯誤例項 1 和例項 2,來分析 Local Reference 可能導致的記憶體洩漏,加深對 Local Reference 的深層理解。

分析錯誤例項 1:

區域性變數 str 在每次迴圈中都被重新賦值,間接指向最新建立的 Local Reference,前面建立的 Local Reference 一直保留在 Local Ref 表中。

在例項 1 執行完第 i 次迴圈後,記憶體佈局如圖 3:

圖 3. 執行 i 次迴圈後的記憶體佈局
圖 3. 執行 i 次迴圈後的記憶體佈局

繼續執行完第 i+1 次迴圈後,記憶體佈局發生變化,如圖 4:

圖 4. 執行 i+1 次迴圈後的記憶體佈局
圖 4. 執行 i+1 次迴圈後的記憶體佈局

圖 4 中,區域性變數 str 被賦新值,間接指向了 Local Ref i+1。在 native method 執行過程中,我們已經無法釋放 Local Ref i 佔用的記憶體,以及 Local Ref i 所引用的第 i 個 string 物件所佔據的 Java Heap 記憶體。所以,native memory 中 Local Ref i 被洩漏,Java Heap 中建立的第 i 個 string 物件被洩漏了。

也就是說在迴圈中,前面建立的所有 i 個 Local Reference 都洩漏了 native memory 的記憶體,建立的所有 i 個 string 物件都洩漏了 Java Heap 的記憶體。

直到 native memory 執行完畢,返回到 Java 程式時(N2J),這些洩漏的記憶體才會被釋放,但是 Local Reference 表所分配到的記憶體往往很小,在很多情況下 N2J 之前可能已經引發嚴重記憶體洩漏,導致 Local Reference 表的記憶體耗盡,使 JVM 崩潰,例如錯誤例項 1。

分析錯誤例項 2:

例項 2 與例項 1 相似,雖然每次迴圈中呼叫工具函式 CreateStringUTF(env) 來建立物件,但是在 CreateStringUTF(env) 返回退棧過程中,只是區域性變數被刪除,而每次呼叫建立的 Local Reference 仍然存在 Local Ref 表中,並且有效引用到每個新建立的 string 物件。str 區域性變數在每次迴圈中被賦新值。

這樣的記憶體洩漏是潛在的,但是這樣的錯誤在 JNI 程式設計師程式設計過程中卻經常出現。通常情況,在觸發 out of memory 之前,native method 已經執行完畢,切換回 Java 環境,所有 Local Reference 被刪除,問題也就沒有顯露出來。但是某些情況下就會引發 out of memory,導致例項 1 和例項 2 中的 JVM 崩潰。

控制 Local Reference 生命期

因此,在 JNI 程式設計時,正確控制 JNI Local Reference 的生命期。如果需要建立過多的 Local Reference,那麼在對被引用的 Java 物件操作結束後,需要呼叫 JNI function(如 DeleteLocalRef()),及時將 JNI Local Reference 從 Local Ref 表中刪除,以避免潛在的記憶體洩漏。

總結

本文闡述了 JNI 程式設計可能引發的記憶體洩漏,JNI 程式設計既可能引發 Java Heap 的記憶體洩漏,也可能引發 native memory 的記憶體洩漏,嚴重的情況可能使 JVM 執行異常終止。JNI 軟體開發人員在程式設計中,應當考慮以下幾點,避免記憶體洩漏:

  • native code 本身的記憶體管理機制依然要遵循。
  • 使用 Global reference 時,當 native code 不再需要訪問 Global reference 時,應當呼叫 JNI 函式 DeleteGlobalRef() 刪除 Global reference 和它引用的 Java 物件。Global reference 管理不當會導致 Java Heap 的記憶體洩漏。
  • 透徹理解 Local reference,區分 Local reference 和 native code 的區域性變數,避免混淆兩者所引起的 native memory 的記憶體洩漏。
  • 使用 Local reference 時,如果 Local reference 引用了大的 Java 物件,當不再需要訪問 Local reference 時,應當呼叫 JNI 函式 DeleteLocalRef() 刪除 Local reference,從而也斷開對 Java 物件的引用。這樣可以避免 Java Heap 的 out of memory。
  • 使用 Local reference 時,如果在 native method 執行期間會建立大量的 Local reference,當不再需要訪問 Local reference 時,應當呼叫 JNI 函式 DeleteLocalRef() 刪除 Local reference。Local reference 表空間有限,這樣可以避免 Local reference 表的記憶體溢位,避免 native memory 的 out of memory。
  • 嚴格遵循 Java JNI 規範書中的使用規則。
轉載於: http://www.ibm.com/developerworks/cn/java/j-lo-jnileak/