在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。但是有些情况下我们必然会创建多个LocalReference,比如在一个对列表进行遍历的循环体内,这时候开发人员有必要调用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信息。

 先来看显示效果:

再看代码:      

//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的方式。
Comments are closed.