JNI記憶體管理及優化

大頭呆發表於2018-12-19

JVM記憶體和Native記憶體

JNI記憶體管理及優化

上面這張圖大家都應該很熟了,下面只講下和JNI有關的部分

程式計數器

記錄正在執行的虛擬機器位元組碼指令的地址(如果正在執行的是本地方法則為空)。

本地方法棧

本地方法棧與 Java 虛擬機器棧類似,它們之間的區別只不過是本地方法棧為本地方法服務。 本地方法一般是用其它語言(C、C++ 或組合語言等)編寫的,並且被編譯為基於本機硬體和作業系統的程式,對待這些方法需要特別處理。

JNI記憶體管理及優化

堆(Java-Heap)

所有物件都在這裡分配記憶體,是垃圾收集的主要區域("GC 堆")。 堆不需要連續記憶體,並且可以動態增加其記憶體,增加失敗會丟擲 OutOfMemoryError 異常。

可以通過 -Xms 和 -Xmx 兩個虛擬機器引數來指定一個程式的堆記憶體大小,第一個引數設定初始值,第二個引數設定最大值。

java -Xmx1024m -Xms1024m
//-Xmx1024m:設定JVM最大可用記憶體為1024M。
//-Xms1024m:設定JVM初始記憶體為1024m。此值可與-Xmx相同,以避免每次垃圾回收完成後JVM重新分配記憶體。
複製程式碼

在Android系統對於每個應用都有記憶體使用的限制,機器的記憶體限制,在/system/build.prop檔案中配置的。可以在manifest檔案application節點加入 android:largeHeap="true"來讓Dalvik/ART虛擬機器分配更大的堆記憶體空間

直接記憶體(native堆)

也稱為C-Heap,供Java Runtime程式使用的,沒有相應的引數來控制其大小,其大小依賴於作業系統程式的最大值。  Java應用程式都是在Java Runtime Environment(JRE)中執行,而Runtime本身就是由Native語言(如:C/C++)編寫程式。Native Memory就是作業系統分配給Runtime程式的可用記憶體,它與Heap Memory不同,Java Heap 是Java應用程式的記憶體。。Native Memory的主要作用如下:

  • 管理java heap的狀態資料(用於GC);
  • JNI呼叫,也就是Native Stack;
  • JIT(即使編譯器)編譯時使用Native Memory,並且JIT的輸入(Java位元組碼)和輸出(可執行程式碼)也都是儲存在Native Memory;
  • NIO direct buffer;
  • Threads;
  • 類載入器和類資訊都是儲存在Native Memory中的。

JNI記憶體

在Java程式碼中,Java物件被存放在JVM的Java Heap,由垃圾回收器(Garbage Collector,即GC)自動回收就可以。

 在Native程式碼中,記憶體是從Native Memory中分配的,需要根據Native程式設計規範去操作記憶體。如:C/C++使用malloc()/new分配記憶體,需要手動使用free()/delete回收記憶體。

 然而,JNI和上面兩者又有些區別。 JNI提供了與Java相對應的引用型別(如:jobject、jstring、jclass、jarray、jintArray等),以便Native程式碼可以通過JNI函式訪問到Java物件。引用所指向的Java物件通常就是存放在Java Heap,而Native程式碼持有的引用是存放在Native Memory中。

舉個例子,如下程式碼:

jstring jstr = env->NewStringUTF("Hello World!");
複製程式碼
  • jstring型別是JNI提供的,對應於Java的String型別
  • JNI函式NewStringUTF()用於構造一個String物件,該物件存放在Java Heap中,同時返回了一個jstring型別的引用。
  • String物件的引用儲存在jstr中,jstr是Native的一個區域性變數,存放在Native Memory中。

開發人員都應該遇到過OOM(Out of Memory)異常,在JNI開發中,該異常可能發生在Java Heap中,也可能發生在Native Memory中。

java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: native memory exhausted
複製程式碼

Java Heap 中出現 Out of Memory異常的原因有兩種:

1)程式過於龐大,致使過多 Java 物件的同時存在;
2)程式編寫的錯誤導致 Java Heap 記憶體洩漏。
複製程式碼

Native Memory中出現 Out of Memory異常的原因:

1)程式申請過多資源,系統未能滿足,比如說大量執行緒資源;
2)程式編寫的錯誤導致Native Memory記憶體洩漏。
複製程式碼

JNI引用

JNI引用有三種:Local ReferenceGlobal ReferenceWeak Global Reference。下面分別來介紹一下這三種引用記憶體分配和管理。

Local Reference

只在Native Method執行時存在,只在建立它的執行緒有效,不能跨執行緒使用。它的生命期是在Native Method的執行期開始建立(從Java程式碼切換到Native程式碼環境時,或者在Native Method執行時呼叫JNI函式時),在Native Method執行完畢切換回Java程式碼時,所有Local Reference被刪除(GC會回收其記憶體),生命期結束(呼叫DeleteLocalRef()可以提前回收記憶體,結束其生命期)。

 實際上,每當執行緒從Java環境切換到Native程式碼環境時,JVM 會分配一塊記憶體用於建立一個Local Reference Table,這個Table用來存放本次Native Method 執行中建立的所有Local Reference。每當在 Native程式碼中引用到一個Java物件時,JVM 就會在這個Table中建立一個Local Reference。比如,我們呼叫 NewStringUTF() 在 Java Heap 中建立一個 String 物件後,在 Local Reference Table 中就會相應新增一個 Local Reference

Local Reference 表、Local Reference 和 Java 物件的關係

JNI記憶體管理及優化

接下來舉個簡單例子說明一下:

jstring jstr = env->NewStringUTF("Hello World!");
複製程式碼
  • jstr存放在Native Method Stack中,是一個區域性變數
  • 對於開發者來說,Local Reference Table是不可見的
  • Local Reference Table的記憶體不大,所能存放的Local Reference數量也是有限的(在Android中預設最大容量是512個),使用不當就會引起溢位異常
  • Local Reference並不是Native裡面的區域性變數,區域性變數存放在堆疊中,其引用存放在Local Reference Table中。

在Native Method結束時,JVM會自動釋放Local Reference,但Local Reference Table是有大小限制的,在開發中應該及時使用DeleteLocalRef()刪除不必要的Local Reference,不然可能會出現溢位錯誤:

JNI ERROR (app bug): local reference table overflow (max=512)
複製程式碼

在C/C++中例項化的JNI物件,如果不返回java,必須用release掉或delete,否則記憶體洩露。包括NewStringUTF,NewObject。對於一般的基本資料型別(如:jint,jdouble等),是沒必要呼叫該函式刪除掉的。如果返回java不必delete,java會自己回收。

Global Reference

Local Reference是在Native Method執行的時候出現的,而Global Reference是通過JNI函式NewGlobalRef()DeleteGlobalRef()來建立和刪除的。 Global Reference具有全域性性,可以在多個Native Method呼叫過程和多執行緒中使用,在主動呼叫DeleteGlobalRef之前,它是一直有效的(GC不會回收其記憶體)。

/**
 * 建立obj引數所引用物件的新全域性引用。obj引數既可以是全域性引用,也可以是區域性引用。全域性引用通過呼叫DeleteGlobalRef()來顯式撤消。
 * @param obj 全域性或區域性引用。
 * @return 返回全域性引用。如果系統記憶體不足則返回 NULL。
*/
jobject NewGlobalRef(jobject obj);

/**
 * 刪除globalRef所指向的全域性引用
 * @param globalRef 全域性引用
*/
void DeleteGlobalRef(jobject globalRef);
複製程式碼

使用 Global reference時,當 native code 不再需要訪問Global reference 時,應當呼叫 JNI 函式 DeleteGlobalRef() 刪除 Global reference和它引用的 Java 物件。否則Global Reference引用的 Java 物件將永遠停留在 Java Heap 中,從而導致 Java Heap 的記憶體洩漏。

Weak Global Reference

NewWeakGlobalRef()DeleteWeakGlobalRef()進行建立和刪除,它與Global Reference的區別在於該型別的引用隨時都可能被GC回收。

於Weak Global Reference而言,可以通過isSameObject()將其與NULL比較,看看是否已經被回收了。如果返回JNI_TRUE,則表示已經被回收了,需要重新初始化弱全域性引用。Weak Global Reference的回收時機是不確定的,有可能在前一行程式碼判斷它是可用的,後一行程式碼就被GC回收掉了。為了避免這事事情發生,JNI官方給出了正確的做法,通過NewLocalRef()獲取Weak Global Reference,避免被GC回收。

注意點

Local Reference 不是 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。

注意釋放所有對jobject的引用:

extern "C"
JNIEXPORT jstring JNICALL
Java_com_test_application_MainActivity_init(JNIEnv *env, jobject instance, jstring data,
                                      jbyteArray array) {

    int len = env->GetArrayLength(array);
    const char *utfChars = env->GetStringUTFChars(data, 0);
    jbyte *arrayElements = env->GetByteArrayElements(array, NULL);

    jstring pJstring = env->NewStringUTF(utfChars); 

    jbyteArray jpicArray = env->NewByteArray(len);
    env->SetByteArrayRegion(jpicArray, 0, len, arrayElements);
    
    // TODO
    
    env->DeleteLocalRef(pJstring);
    env->DeleteLocalRef(jpicArray);

    env->ReleaseStringUTFChars(data, utfChars);
    env->ReleaseByteArrayElements(array, arrayElements, 0);

    std::string hello = "Hello from C++";
    jstring result = env->NewStringUTF(hello.c_str());
    return result;
}
複製程式碼

其它的還有:

jclass ref= (env)->FindClass("java/lang/String");
 
env->DeleteLocalRef(ref);
複製程式碼

因為根據jni.h裡的定義:

typedef jobject         jclass;
複製程式碼

jclass也是jobject。而jmethodID/jfielID和jobject沒有繼承關係,它們不是object,只是個整數,不存在被釋放與否的問題。

區域性引用和全域性引用的轉換

注意Local Reference的生命週期,如果在Native中需要長時間持有一個Java物件,就不能使用將jobject儲存在Native,否則在下次使用的時候,即使同一個執行緒呼叫,也將會無法使用。下面是錯誤的做法:

jstring global;

extern "C" JNIEXPORT jstring JNICALL
Java_org_hik_libyuv_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    jstring local = env->NewStringUTF(hello.c_str());
    global = local;
    return local;
}


複製程式碼

正確的做法是使用Global Reference,如下:

jstring global;

extern "C" JNIEXPORT jstring JNICALL
Java_org_hik_libyuv_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    jstring local = env->NewStringUTF(hello.c_str());
    global = static_cast<jstring>(env->NewGlobalRef(global));
    return local;
}
複製程式碼

多執行緒

JNIEnv和jobject物件都不能跨執行緒使用。 對於jobject,解決辦法是

a、m_obj = env->NewGlobalRef(obj);//建立一個全域性變數  

b、jobject obj = env->AllocObject(m_cls);//在每個執行緒中都生成一個物件
複製程式碼

對於JNIEnv,解決辦法是在每個執行緒中都重新生成一個env

JavaVM *gJavaVM;//宣告全域性變數
(*env)->GetJavaVM(env, &gJavaVM);//在JNI方法的中賦值

JNIEnv *env;//在其它執行緒中獲取當前執行緒的env  
m_jvm->AttachCurrentThread((void **)&env, NULL);  
複製程式碼

當在一個執行緒裡面呼叫AttachCurrentThread後,如果不需要用的時候一定要DetachCurrentThread,否則執行緒無法正常退出,導致JNI環境一直被佔用。

參考文章

C++呼叫JAVA方法詳解

JNI記憶體管理

Java 虛擬機器

相關文章