誰建立誰銷燬,誰分配誰釋放——JNI呼叫時的記憶體管理

騰訊音樂技術發表於2022-12-07

在QQ音樂AndroidTV端的Cocos版本的開發過程中,我們希望儘量多的複用現有的業務邏輯,避免重複製造輪子。因此,我們使用了大量的JNI呼叫,來實現Java層和Native層(主要是C++)的程式碼通訊。一個重要的問題是JVM不會幫我們管理Native Memory所分配的記憶體空間的,本文就主要介紹如何在JNI呼叫時,對於Java層和Native層對映物件的記憶體管理策略。

1. 在Java層利用JNI呼叫Native層程式碼

如果有Java層嘗試呼叫Native層的程式碼,我們通常用Java物件來封裝C++的物件。舉個例子,在Java層的一個監聽播放狀態的類:MusicPlayListener,作用是將播放狀態傳送給位於Native層的Cocos,通知Cocos在介面上修改顯示圖示,例如“播放”,“暫停”等等。

 第一種做法,是在Java類的建構函式中,呼叫Native層的建構函式,分配Native Heap的記憶體空間,之後,在Java類的finalize方法中呼叫Native層的解構函式,回收Native Heap的記憶體空間。

// in Java:
public class MusicPlayListener {
    // 指向底層物件的指標,偽裝成Java的long
    private final long ptr; 

    public MusicPlayListener() {
        ptr = ccCreate();
    }

    // 在finalize裡釋放
    public void finalize() { 
        ccFree(ptr);
    }

    // 是否正在播放
    public void setPlayState(boolean isPlaying){ 
        ccSetPlayState(ptr,isPlaying);
    }

    private static native long ccCreate();
    private static native void ccFree(long ptr);
    private native void ccSetPlayState(long ptr,boolean isPlaying);
}

// in C:
jlong Java_MusicPlayListener_ccCreate(JNIEnv* env, jclass unused) {
    // 呼叫建構函式分配記憶體空間
    CCMusicPlayListener* musicPlayListener = 
        new CCMusicPlayListener(); 
    return (jlong) musicPlayListener;
}

void Java_MusicPlayListener_ccFree(
    JNIEnv* env,
    jclass unused,
    jlong ptr) {
        // 釋放記憶體空間   
        delete ptr; 
}

void Java_MusicPlayListener_ccSetPlayState(
    JNIEnv* env,
    jclass unused,
    jlong ptr,
    jboolean isPlaying) {
        //將播放狀態通知給UI執行緒
        (reinterpret_cast<CCMusicPlayListener*>(ptr))->setPlayState(isPlaying);    
}

這種做法會讓Java物件和Native物件的生命週期保持一致,當Java物件在Java Heap中,被GC判定為回收時,同時會將Native Heap中的物件回收。

不透過finalize的話,也可以用其他類似的機制適用於上述場景。比如Java標準庫提供的DirectByteBuffer的實現,用基於PhantomReference的sun.misc.Cleaner來清理,本質上跟finalize方式一樣,只是比finalize稍微安全一點,他可以避免”懸空指標“的問題。

這種方式的一個重要缺點,就是不管是finalize還是其他類似的方法,都依賴於JVM的GC來處理的。換句話說,如果不觸發GC,那麼finalize方法就不會及時呼叫,這可能會導致Native Heap資源耗盡,而導致程式出錯。當Native層需要申請一個很大空間的記憶體時,有一定機率出現Native OutOfMemoryError的問題,然後找了半天也發現不了問題在哪裡...

第二種方法是對Api的一些簡單調整,以解決上述問題。不在JNI的包裝類的建構函式中初始化Native層物件,儘量寫成open/close的形式,在open的時候初始化Native資源,close的時候釋放,finalize作為最後的保險再檢查釋放一次。

雖然沒有本質上的變化,但open/close這種Api設計,一般來說,對90%的開發人員還是能夠提醒他們使用close的,至於剩下的10%...好像除了開除也沒啥好辦法了...

2. 在Native層利用JNI呼叫Java層程式碼 

上一種情況,是以Java層為主導,Native層物件的生命週期受Java層物件的控制。下面要介紹的是另一種情況,即Native層物件為主導,由他控制Java層物件的生命週期。

2.1 Native層操作Java層物件

想要在native層操作Java Heap中的物件,需要位於Native層的引用(Reference)以指向Java Heap中的記憶體空間。JNI中為我們提供了三種引用:本地引用(Local Reference),全域性引用(Global Reference)和弱全域性引用(Weak Global Reference)。

Local Reference的生命週期持續到一個Native Method的結束,當Native Method返回時Java Heap中的物件不再被持有,等待GC回收。一定要注意不要在Native Method中申請過多的Local Reference,每個Local Reference都會佔用一定的JVM資源,過多的Local Reference會導致JVM記憶體溢位而導致Native Method的Crash。但是有些情況下我們必然會建立多個Local Reference,比如在一個對列表進行遍歷的迴圈體內,這時候開發人員有必要呼叫DeleteLocalRef手動清除不再使用的Local Reference。

//C++程式碼
class Coo{
public:
   void Foo(){
     //獲得區域性引用物件ret
     jobject ret = env->CallObjectMethod();  

    for(int i =0;i<10;i++){
        //獲得區域性引用物件cret
        jobject cret = env->CallObjectMethod();  

        //...

        //手動回收區域性引用物件cret 
        env->DeleteLocalRef(cret);        
    }
  }  //native method 返回,區域性引用物件ret被自動回收
};

Global Reference的生命週期完全由程式設計師控制,你可以呼叫NewGlobalRef方法將一個Local Reference轉變為Global Reference,Global Reference的生命週期會一直持續到你顯式的呼叫DeleteGlobalRef,這有點像C++的動態記憶體分配,你需要記住new/delete永遠是成對出現的。

//C++程式碼
class Coo{
public:
    void Foo(){
     //獲得區域性引用物件ret
     jobject ret = env->CallObjectMethod(); 
     //獲的全域性引用物件gret 
     jobject gret = env->NewGlobalRef(ret);  
 }//native method 返回,區域性引用物件ret被自動回收
 //gret不會回收,造成記憶體溢位
};

Weak Global Reference是一種特殊的Global Reference,它允許JVM在Java Heap執行GC時回收Native層所持有的Java物件,前提是這個物件除了Weak Reference以外,沒有被其他引用持有。我們在使用Weak Global Reference之前,可以使用IsSameObject來判斷位於Java Heap中的物件是否被釋放。

2.2 Native層釋放的同時釋放Java層物件

C++中的物件總會在其生命週期結束時,呼叫自身的解構函式,釋放動態分配的記憶體空間,Cocos利用資源釋放池(其本質是一種引用計數機制)來管理所有繼承自cocos2d::CCObject(3.2版本之後變為cocos::Ref)的物件。換言之,物件的生命週期交給Cocos管理,我們需要關心物件的析構過程。

 一種簡單有效的做法,是在C++的建構函式中,例項化Java層的物件,在C++的解構函式中釋放Java層物件。舉個例子,主介面需要拉取Java層程式碼來解析後臺協議,獲取到主介面的幾個圖片的URL資訊。

 先來看顯示效果:

誰建立誰銷燬,誰分配誰釋放——JNI呼叫時的記憶體管理

    

再看程式碼:      

//C++程式碼
class CCMainDeskListener
{
public:
    CCMainDeskListener();
    ~CCMainDeskListener();
private:
    //Java層物件的全域性引用
    jobject retGlobal;                   
};

CCMainDeskListener::CCMainDeskListener()
{
    //獲得本地引用
    jobject ret = CallStaticObjectMethod();   
    //建立全域性引用    
    retGlobal = NewGlobalRef(ret); 
    //清除本地引用  
    DeleteLocalRef(ret);             

}

CCMainDeskListener::~CCMainDeskListener()
{
    //清除全域性引用
    DeleteGlobalRef(retGlobal);   
}

在C++的建構函式中,呼叫Java層的方法初始化了Java物件,這個引用分配的記憶體空間位於Java Heap。之後我們建立全域性引用,避免Local Reference在Native Method結束之後被回收,而全域性引用在解構函式中被刪除,這樣就保證了Java Heap中的物件被釋放,保持Native層和Java層的釋放做到同步。

上述方法中,Java層物件的生命週期是跟隨Native層物件的生命週期的,Native層物件的生命週期結束時會釋放對於Java層物件的持有,讓GC去回收資源。我們想進一步瞭解Native層物件的什麼時候被回收,接下來介紹一下Cocos的記憶體管理策略。    

   3.Cocos的記憶體管理 

C++中,在堆上分配和釋放動態記憶體的方法是new和delete,程式設計師要小心的使用它們,確保每次呼叫了new之後,都有delete與之對應。為了避免因為遺漏delete而造成的記憶體洩露,C++標準庫(STL)提供了auto_ptr和shared_ptr,本質上都是用來確保當物件的生命週期結束時,堆上分配的記憶體被釋放。

Cocos採用的是引用計數的記憶體管理方式,這已經是一種十分古老的管理方式了,不過這種方式簡單易實現,當物件的引用次數減為0時,就呼叫delete方法將物件清除掉。具體實現上來說,Cocos會為每個程式建立一個全域性的CCAutoreleasePool類,開發人員不能自己建立釋放池,僅僅需要關注release和retain方法,不過前提是你的物件必須要繼承自cocos2d::CCObject類(3.0版本之後變為cocos2d::Ref類),這個類是Cocos所有物件繼承的基類,有點類似於Java的Object類。

 當你呼叫object->autorelease()方法時,物件就被放到了自動釋放池中,自動釋放池會幫助你保持這個obejct的生命週期,直到當前訊息迴圈的結束。在這個訊息迴圈的最後,假如這個object沒有被其他類或容器retain過,那麼它將自動釋放掉。例如,layer->addChild(sprite),這個sprite增加到這個layer的子節點列表中,他的宣告週期就會持續到這個layer釋放的時候,而不會在當前訊息迴圈的最後被釋放掉。

跟記憶體管理有關的方法,一共有三個:release(),retain()和autorelease()。release和retain的作用分別是將當前引用次數減一和加一,autorelease的作用則是將當前物件的管理交給PoolManager。當物件的引用次數減為0時,PoolManager就會呼叫delete,回收記憶體空間。

release和retain的作用分別是將當前引用次數減一和加一,autorelease的作用則是將當前物件的管理交給PoolManager。當物件的引用次數減為0時,PoolManager就會呼叫delete,回收記憶體空間。

 一般情況下,我們需要記住的就是繼承自Ref的物件,使用create方法建立例項後,是不需要我們手動delete的,因為create方法會自己呼叫autorelease方法。

   4.總結

 JNI呼叫時,即可能造成Native Heap的溢位,也可能造成Java Heap的溢位,作為JNI軟體開發人員,應該注意以下幾點:

  1. Native層(一般是C++)本身的記憶體管理。

  2. 不使用的Global Reference和Local Reference都要及時釋放。

  3. Java層呼叫JNI時儘量使用open/close的格式替代建構函式/finalize的方式。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31557897/viewspace-2671498/,如需轉載,請註明出處,否則將追究法律責任。

相關文章