解析Java的JNI編程中的對象引用與內存泄漏問題

JNI,Java Native Interface,是 native code 的編程接口。JNI 使 Java 代碼程序可以與 native code 交互——在 Java 程序中調用 native code;在 native code 中嵌入 Java 虛擬機調用 Java 的代碼。
JNI 編程在軟件開發中運用廣泛,其優勢可以歸結爲以下幾點:
利用 native code 的平臺相關性,在平臺相關的編程中彰顯優勢。
對 native code 的代碼重用。
native code 底層操作,更加高效。
然而任何事物都具有兩面性,JNI 編程也同樣如此。程序員在使用 JNI 時應當認識到 JNI 編程中如下的幾點弊端,揚長避短,纔可以寫出更加完善、高性能的代碼:
從 Java 環境到 native code 的上下文切換耗時、低效。
JNI 編程,如果操作不當,可能引起 Java 虛擬機的崩潰。
JNI 編程,如果操作不當,可能引起內存泄漏。
JAVA 中的內存泄漏
JAVA 編程中的內存泄漏,從泄漏的內存位置角度可以分爲兩種:JVM 中 Java Heap 的內存泄漏;JVM 內存中 native memory 的內存泄漏。

局部和全局引用

JNI將實例、數組類型暴露爲不透明的引用。native代碼從不會直接檢查一個不透明的引用指針的上下文,而是通過使用JNI函數來訪問由不透明的引用所指向的數據結構。因爲只處理不透明的引用,這樣就不需要擔心不同的java VM實現而導致的不同的內部對象的佈局。然而,還是有必要了解一下JNI中不同種類的引用:
1)JNI 支持3中不透明的引用:局部引用、全局引用和弱全局引用。
2)局部和全局引用,有着各自不同的生命週期。局部引用能夠被自動釋放,而全局引用和若全局引用在被程序員釋放之前,是一直有效的。
3)一個局部或者全局引用,使所提及的對象不能被垃圾回收。而弱全局引用,則允許提及的對象進行垃圾回收。
4)不是所有的引用都可以在所有上下文中使用的。例如:在一個創建返回引用native方法之後,使用一個局部引用,這是非法的。

那麼到底什麼是局部引用,什麼事全局引用,它們有什麼不同?

局部引用

多數JNI函數都創建局部引用。例如JNI函數NewObject創建一個實例,並且返回一個指向該實例的局部引用。

局部引用只在創建它的native方法的動態上下文中有效,並且只在native方法的一次調用中有效。所有局部引用只在一個native方法的執行期間有效,在該方法返回時,它就被回收。

在native方法中使用一個靜態變量來保存一個局部引用,以便在隨後的調用中使用該局部引用,這種方式是行不通的。例如以下例子,誤用了局部引用:
/* This code is illegal */  
jstring  

MyNewString(JNIEnv *env, jchar *chars, jint len) { static jclass stringClass = NULL; jmethodID cid; jcharArray elemArr; jstring result; if (stringClass == NULL) { stringClass = (*env)->FindClass(env, "java/lang/String"); if (stringClass == NULL) { return NULL; /* exception thrown */ } } /* It is wrong to use the cached stringClass here, because it may be invalid. */ cid = (*env)->GetMethodID(env, stringClass, "<init>", "([C)V"); ... elemArr = (*env)->NewCharArray(env, len); ... result = (*env)->NewObject(env, stringClass, cid, elemArr); (*env)->DeleteLocalRef(env, elemArr); return result; }

這種保存局部引用的方式是不正確的,因爲FindClass()返回的是對java.lang.String的局部引用。這是因爲,在native代碼從MyNewString返回退出時,VM 會釋放所有局部引用,包括存儲在stringClass變量中的指向類對象的引用。這樣當再次後繼調用MyNewString時,可能會訪問非法地址,導致內存被破壞,或者系統崩潰。

局部引用失效,有兩種方式:‘
1)系統會自動釋放局部變量。
2)程序員可以顯示地管理局部引用的生命週期,例如調用DeleteLocalRef。

一個局部引用可能在被摧毀之前,被傳給多個native方法。例如,MyNewString中,返回一個由NewObject創建的字符串引用,它將由NewObject的調用者來決定是否釋放該引用。而在以下代碼中:

JNIEXPORT jstring JNICALL Java_C_f(JNIEnv *env, jobject this) { char *c_str = ...<pre name="code" class="cpp"> ... <pre name="code" class="cpp">return MyNewString(c_str);<pre name="code" class="cpp">}

在VM接收到來自Java_C_f的局部引用以後,將基礎字符串對象傳遞給ava_C_f的調用者,然後摧毀原本由MyNewString中調用的JNI函數NewObject所創建的局部引用。

局部對象只屬於創建它們的線程,只在該線程中有效。一個線程想要調用另一個線程創建的局部引用是不被允許的。將一個局部引用保存到全局變量中,然後在其它線程中使用它,這是一種錯誤的編程。

全局引用

在一個native方法被多次調用之間,可以使用一個全局引用跨越它們。一個全局引用可以跨越多個線程,並且在被程序員釋放之前,一致有效。和局部引用一樣,全局引用保證了所引用的對象不會被垃圾回收。

和局部引用不一樣(局部變量可以由多數JNI函數創建),全局引用只能由一個JNI函數創建(NewGlobalRef)。下面是一個使用全局引用版本的MyNewString:
/* This code is OK */  
jstring  

MyNewString(JNIEnv *env, jchar *chars, jint len) { static jclass stringClass = NULL; ... if (stringClass == NULL) { jclass localRefCls = (*env)->FindClass(env, "java/lang/String"); if (localRefCls == NULL) { return NULL; /* exception thrown */ } /* Create a global reference */ stringClass = (*env)->NewGlobalRef(env, localRefCls); /* The local reference is no longer useful */ (*env)->DeleteLocalRef(env, localRefCls); /* Is the global reference created successfully? */ if (stringClass == NULL) { return NULL; /* out of memory exception thrown */ } } ... }


弱全局引用


弱全局引用是在java 2 SDK1.2纔出現的。它由NewGolableWeakRef函數創建,並且被DeleteGloablWeakRef函數摧毀。和全局引用一樣,它可以跨native方法調用,也可以跨越不同線程。但是和全局引用不同的是,它不阻止對基礎對象的垃圾回收。下面是弱全局引用版的MyNewString:

JNIEXPORT void JNICALL  

Java_mypkg_MyCls_f(JNIEnv *env, jobject self) { static jclass myCls2 = NULL; if (myCls2 == NULL) { jclass myCls2Local = (*env)->FindClass(env, "mypkg/MyCls2"); if (myCls2Local == NULL) { return; /* can't find class */ } myCls2 = NewWeakGlobalRef(env, myCls2Local); if (myCls2 == NULL) { return; /* out of memory */ } } ... /* use myCls2 */ }

弱全局引用在一個被native代碼緩存着的引用不想阻止基礎對象被垃圾回收時,非常有用。如以上例子,mypkg.MyCls.f需要緩存mypkg.MyCls2的引用。而通過將mypkg.MyCls2緩存到弱引用中,能夠實現MyCls2類依舊可以被卸載。


上面代碼中,我們假設了MyCls類和MyCls2類的生命週期是相同的(例如,在同一個類中被加載、卸載)。所以沒有考慮MyCls2被卸載了,然後在類MyCls和native方法的實現Java_mypkg_MyCls_f還要被繼續使用時,再被重新加載起來的情況。針對於這個MyCls2類可能被卸載再加載的情況,在使用時,需要檢查該弱全局引用是否還有效。如何檢查,這將在下面提到。

比較引用

可以用JNI函數IsSameObject來檢查給定的兩個局部引用、全局引用或者弱全局引用,是否指向同一個對象。
(*env)->IsSameObject(env, obj1, obj2)  
返回值爲:
JNI_TRUE,表示兩個對象一致,是同一個對象。
JNI_FALSE,表示兩個對象不一致,不是同一個對象。


在java VM中NULL是null的引用。
如果一個對象obj是局部引用或者全局引用,則可以這樣來檢查它是否指向null對象:

(*env)->IsSameObject(env, obj, NULL)

或者:

NULL == obj


而對於弱全局引用,以上規則需要改變一下:
我們可以用這個函數來判斷一個非0弱全局引用wobj所指向的對象是否仍舊存活着(依舊有效)。

(*env)->IsSameObject(env, wobj, NULL)

返回值:
JNI_TRUE,表示對象已經被回收了。
JNI_FALSE,表示wobj指向的對象,依舊有效。

 釋放引用
除了引用的對象要佔用內存,每個JNI引用本身也會消耗一定內存。作爲一個JNI程序員,應該對在一段給定的時間裏,程序會用到的引用的個數,做到心中有數。特別是,儘管程序所創建的局部引用最終會被VM會被自動地釋放,仍舊需要知道在程序在執行期間的任何時刻,創建的局部引用的上限個數。創建過多的引用,即便他們是瞬間、短暫的,也會導致內存耗盡。

釋放局部引用
多數情況下,在執行一個native方法時,你不需要擔心局部引用的釋放,java VM會在native方法返回調用者的時候釋放。然而有時候需要JNI程序員顯示的釋放局部引用,來避免過高的內存使用。那麼什麼時候需要顯示的釋放呢,且看一下情景:
1)在單個native方法調用中,創建了大量的局部引用。這可能會導致JNI局部引用表溢出。此時有必要及時地刪除那些不再被使用的局部引用。例如以下代碼,在該循環中,每次都有可能創建一個巨大的字符串數組。在每個迭代之後,native代碼需要顯示地釋放指向字符串元素的局部引用:

for (i = 0; i < len; i++) { jstring jstr = (*env)->GetObjectArrayElement(env, arr, i); ... /* process jstr */ (*env)->DeleteLocalRef(env, jstr); }

2)你可能要創建一個工具函數,它會被未知的上下文調用。例如之前提到到MyNewString這個例子,它在每次返回調用者欠,都及時地將局部引用釋放。


3)native方法,可能不會返回(例如,一個可能進入無限事件分發的循環中的方法)。此時在循環中釋放局部引用,是至關重要的,這樣才能不會無限期地累積,進而導致內存泄露。


4)native方法可能訪問一個巨大的對象,因此,創建了一個指向該對象的局部引用。native方法在返回調用者之前,除訪問對象之外,還執行了額外的計算。指向這個大對象的局部引用,將會包含該對象,以防被垃圾回收。這個現象會持續到native方法返回到調用者時,即便這個對象不會再被使用,也依舊會受保護。在以下例子中,由於在lengthyComputation()前,顯示地調用了DeleteLocalRef,所以垃圾回收器有機會可以釋放lref所指向的對象。

/* A native method implementation */ JNIEXPORT void JNICALL Java_pkg_Cls_func(JNIEnv *env, jobject this) { lref = ... /* a large Java object */ ... /* last use of lref */ (*env)->DeleteLocalRef(env, lref); lengthyComputation(); /* may take some time */ return; /* all local refs are freed */ }

這個情形的實質,就是允許程序在native方法執行期間,java的垃圾回收機制有機會回收native代碼不在訪問的對象。

管理局部引用
不知道java 7怎麼樣了,應該更強大吧,有時間,去看看,這裏且按照java2的特性來吧。
SDK1.2中提供了一組額外的函數來管理局部引用的生命週期。他們是EnsureLocalCapacity、NewLocalRef、PushLocalFram以及PopLocalFram。
JNI的規範要求VM可以自動確保每個native方法可以創建至少16個局部引用。經驗顯示,如果native方法中未包含和java VM的對象進行復雜的互相操作,這個容量對大多數native方法而言,已經足夠了。如果,出現這還不夠的情況,需要創建更多的局部引用,那麼native方法可以調用EnsureLocalCapacity來保證這些局部引用有足夠的空間。

/* The number of local references to be created is equal to the length of the array. */ if ((*env)->EnsureLocalCapacity(env, len)) < 0) { ... /* out of memory */ } for (i = 0; i < len; i++) { jstring jstr = (*env)->GetObjectArrayElement(env, arr, i); ... /* process jstr */ /* DeleteLocalRef is no longer necessary */ }
這樣做,所消耗的內存,自然就有可能比之前的版本來的多。


另外,PushLocalFram\PopLocalFram函數允許程序員創建嵌套作用域的局部引用。如下代碼:

#define N_REFS ... /* the maximum number of local references used in each iteration */ for (i = 0; i < len; i++) { if ((*env)->PushLocalFrame(env, N_REFS) < 0) { ... /* out of memory */ } jstr = (*env)->GetObjectArrayElement(env, arr, i); ... /* process jstr */ (*env)->PopLocalFrame(env, NULL); }

PushLocalFram爲指定數目的局部引用,創建一個新的作用域,PopLocalFram摧毀最上層的作用域,並且釋放該域中的所有局部引用。


使用這兩個函數的好處是它們可以管理局部引用的生命週期,而不需關係在執行過程中可能被創建的每個單獨局部引用。例子中,如果處理jstr的過程,創建了額外的局部引用,它們也會在PopLocalFram之後被立即釋放。


NewLocalRef函數,在你寫一個工具函數時,非常有用。這個會在下面章節——管理引用的規則,具體分析。

native代碼可能會創建超出16個局部引用的範圍,也可能將他們保存在PushLocalFram或者EnsureLocalCapacity調用,VM會爲局部引用分配所需要的內存。然而,這些內存是否足夠,是沒有保證的。如果內存分配失敗,虛擬機將會退出。

 釋放全局引用


在native代碼不再需要訪問一個全局引用的時候,應該調用DeleteGlobalRef來釋放它。如果調用這個函數失敗,Java VM將不會回收對應的對象。


在native代碼不在需要訪問一個弱全局引用的時候,應該調用DeleteWeakGlobalRef來釋放它。如果調用這個函數失敗了,java VM 仍舊將會回收對應的底層對象,但是,不會回收這個弱引用本身所消耗掉的內存。


 管理引用的規則
管理引用的目的是爲了清除不需要的內存佔用和對象保留。

總體來說,只有兩種類型的native代碼:直接實現native方法的函數,在二進制上下文中被使用的工具函數。

在寫native方法的實現的時候,需要當心在循環中過度創建局部引用,以及在native方法中被創建的,卻不返回給調用者的局部引用。在native方法方法返回後還留有16個局部引用在使用中,將它們交給java VM來釋放,這是可以接受的。但是native方法的調用,不應該引起全局引用和弱全局引用的累積。應爲這些引用不會在native方法返後被自動地釋放。


在寫工具函數的時候,必須要注意不能泄露任何局部引用或者超出該函數之外的執行。因爲一個工具函數,可能在意料之外的上下文中,被不停的重複調用。任何不需要的引用創建都有可能導致內存泄露。
1)當一個返回一個基礎類型的工具函數被調用,它必須應該沒有局部引用、若全局引用的累積。
2)當一個返回一個引用類型的工具函數被調用,它必須應該沒有局部、全局或若全局引用的累積,除了要被作爲返回值的引用。


一個工具函數以捕獲爲目的創建一些全局或者弱全局引用,這是可接受的,因爲只有在最開始的時候,纔會創建這些引用。


如果一個工具函數返回一個引用,你應該使返回的引用的類型(例如局部引用、全局引用)作爲函數規範的一部分。它應該始終如一,而不是有時候返回一個局部引用,有時候卻返回一個全局引用。調用者需要知道工具函數返回的引用的類型,以便正確地管理自己的JNI引用。以下代碼重複地調用一個工具工具函數(GetInfoString)。我們需要知道GetInfoString返回的引用的類型,以便釋放該引用:

while (JNI_TRUE) { jstring infoString = GetInfoString(info); ... /* process infoString */ ??? /* we need to call DeleteLocalRef, DeleteGlobalRef, or DeleteWeakGlobalRef depending on the type of reference returned by GetInfoString. */ }

在java2 SDK1.2中,NewLocalRef函數可以用來保證一個工具函數一直返回一個局部引用。爲了說明這個問題,我們對MyNewString做一些改動,它緩存了一個被頻繁請求的字符串(“CommonString”)到全局引用:

jstring  

MyNewString(JNIEnv *env, jchar *chars, jint len) { static jstring result; /* wstrncmp compares two Unicode strings */ if (wstrncmp("CommonString", chars, len) == 0) { /* refers to the global ref caching "CommonString" */ static jstring cachedString = NULL; if (cachedString == NULL) { /* create cachedString for the first time */ jstring cachedStringLocal = ... ; /* cache the result in a global reference */ cachedString = (*env)->NewGlobalRef(env, cachedStringLocal); } return (*env)->NewLocalRef(env, cachedString); } ... /* create the string as a local reference and store in result as a local reference */ return result; }

正常的流程返回的時候局部引用。就像之前解釋的那樣,我們必須將緩存字符保存到一個全局引用中,這樣就可以在多個線程中調用native方法時,都能訪問它。

return (*env)->NewLocalRef(env, cachedString);

這條語句,創建了一個局部引用,它指向了緩存在全局引用的指向的統一對象。作爲和調用者的約定的一部分,MyNewString總是返回一個局部引用。


PushLocalFram、PopLocalFram函數用來管理局部引用的生命週期特別得方便。只需要在native函數的入口調用PushLocalFram,在函數退出時調用PopLocalFram,局部變量就會被釋放。

jobject f(JNIEnv *env, ...) { jobject result; if ((*env)->PushLocalFrame(env, 10) < 0) { /* frame not pushed, no PopLocalFrame needed */ return NULL; } ... result = ...; if (...) { /* remember to pop local frame before return */ result = (*env)->PopLocalFrame(env, result); return result; } ... result = (*env)->PopLocalFrame(env, result); /* normal return */ return result; }

PopLocalFram函數調用失敗時,可能會導致未定義的行爲,例如VM崩潰。

內存泄漏問題
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 編程實現了 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 的內存泄漏。

以上是雲棲社區小編爲您精心準備的的內容,在雲棲社區的博客、問答、公衆號、人物、課程等欄目也有的相關內容,歡迎繼續使用右上角搜索按鈕進行搜索java jni android jni內存泄漏、jni 內存泄漏、jni編程、jni編程指南、jni編程指南中文版,以便於您獲取更多的相關知識。

轉自:https://yq.aliyun.com/ziliao/160372

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