Android Jni/NDK 開發入門詳解

本人為初學者,文章寫得不好,如有錯誤,請大力懟我


如何使用jni進行開發

本文主要針對Android環境進行NDK\Native\Jni開發進行介紹

使用2.2版本之前的Android Studio進行ndk開發是比較繁瑣的,如果你還在使用舊版本的Android Studio,那么建議更新到3.0,現階段3.0已經比較穩定了(雖然舊項目的gradle升級可能需要折騰一下)。下面介紹舊版本的開發流程只是為了能夠更加詳細地介紹jni。

jni并不是android框架內的概念,所以也會提及其他環境使用jni開發的方法,基本上大同小異,不過你可能還需要查閱其他文章來處理一些細節問題(如Windows下生成dll文件)


AS 2.2之前的做法


1.編寫C/C++

  • 首先創建一個java文件,聲明一個自定義的native方法,對我們來說,這個方法就是java層到native層的入口,另外,還需要使用靜態域將so包加載進來
    package com.linjiamin.jnishare;
    
    /**
    * Created by Albert on 17/11/16.
    */
    
    public class JniUtil {
    
        static {
            System.loadLibrary("sotest");
        }
    
        public static native int sum(int num1,int num2);
    }
  • 開始編寫 C/C++代碼之前我們需要兩個頭文件。其中一個是 jni.h,該頭文件包含了對jni數據類型和接口的定義(之后還會介紹),現在開始你所編寫的所有C/C++代碼都需要引入這個頭文件。另外你還需要一個根據剛剛編寫的native方法簽名及類信息生成的頭文件。對前者,簡單地include進來即可,而對于后者,可以使用javah命令生成,當然你也可以選擇親自編寫,使用命令生成的方法如下
    //在終端中
    cd app/src/main/java
    javac com/linjiamin/jnishare/JniUtil.java
    javah com.linjiamin.jnishare.JniUtil
    
    //生成的頭文件如下
    
    /* DO NOT EDIT THIS FILE - it is machine generated */
    #include <jni.h>
    /* Header for class com_linjiamin_jnishare_JniUtil */
    
    #ifndef _Included_com_linjiamin_jnishare_JniUtil
    #define _Included_com_linjiamin_jnishare_JniUtil
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
    * Class:     com_linjiamin_jnishare_JniUtil
    * Method:    sum
    * Signature: (II)I
    */
    JNIEXPORT jint JNICALL Java_com_linjiamin_jnishare_JniUtil_sum 
    (JNIEnv *, jclass, jint, jint);
    
    #ifdef __cplusplus
    }
    #endif
    #endif
  • 簡單說一下如何手寫這個頭文件,預處理指令的寫法都是相同的,將完整類名替換進去即可,對于函數簽名,從左到右按以下順序編寫,當然還是使用javah方法生成更好
    JNIEXPORT: 在android和linux中是空定義的宏,而在windows下被定義為__declspec(dllexport),具體的作用我們不需要關心
    
    jni數據類型:如jint,jboolean,jstring,它們對應于本地方法的返回類型(int,boolean,String)之后會進一步介紹、
    
    JNICALL : 這是__stdcall等函數調用約定(calling conventions)的宏,這些宏用于提示編譯器該函數的參數如何入棧(從左到右,從右到左),以及清理堆棧的方式等
    
    方法名: Java + 完整類名 + 方法名
    
    參數列表:JNIEnv * + jclass\jobject + 所有你定義的參數所對應的jni數據類型 ,JNIEnv*是指向jvm函數表的指針,如果該方法為靜態方法則第二個參數為class否則為jobject,它是含有該方法的class對象或實例
    
    注意JNIEXPORT和JNICALL是固定的
  • 函數具體實現如下,相信大家都能看懂
    #include "jni.h"
    #include "com_linjiamin_jnishare_JniUtil.h"
    //
    // Created by Albert Humbert on 17/11/17.
    //
    
    JNIEXPORT jint JNICALL Java_com_linjiamin_jnishare_JniUtil_sum
    (JNIEnv * env, jclass obj, jint num1, jint num2){
      return num1 + num2;
    }


2.使用ndk編譯so包


包結構
  • 現在在main包下創建一個jni包,將你的頭文件和c/c++文件放進去,然后,你還需要兩份mk文件,mk文件是makefile文件的一部分,makefile包含c/c++編譯器的編譯命令、順序和規則,如果你不了解makefile是什么,那也沒什么關系,后面會講解Android.mk和Application.mk文件的書寫規范
  • 注意請在Android Library中進行ndk開發,不要使用Java Library,前者會生成aar包,可以包含so以及其他資源文件,后者會生成jar,jar通常只能調用外部so包,網上也有文章將jar當中的so包用文件流寫到本地調用的,建議不要嘗試這種騷操作


編寫Android.mk
  • 一個最基本的Android.mk如下
    LOCAL_PATH := $(call my-dir)
    include $(CLEAR_VARS)
    LOCAL_MODULE := libsotest
    LOCAL_SRC_FILES := com_linjiamin_jnishare_JniUtil.cpp
    include $(BUILD_SHARED_LIBRARY)
  • LOCAL_PATH := $(call my-dir),Android.mk必須以該屬性開頭,它用于指定源文件的路徑,my-dir是一個返回Android.mk文件所在目錄的宏
  • include $(CLEAR_VARS) ,指定負責文件的清理的makefile文件,一般固定這么寫就好
  • LOCAL_SRC_FILES,需要編譯的c/c++文件,如無后綴則默認為cpp文件
  • include $(BUILD_SHARED_LIBRARY) ,收集上次清理后的源文件信息,并決定如何編譯
  • LOCAL_C_INCLUDES,頭文件的搜索路徑
  • TARGET_ARCH,指定AB,如armeabi,armeabi-v7a


編寫Application.mk
  • 一個典型的Application.mk文件如下
    APP_PLATFORM = android-24
    APP_ABI := armeabi,armeabi-v7a,x86_64,arm64-v8a
    APP_STL := stlport_static
    APP_OPTIM := debug
  • APP_PLATFORM,ndk版本號,你可以在ndk-bundle文件夾中查看所本地ndk版本
    # for mac
    /Users/alberthumbert/Library/Android/sdk/ndk-bundle/platforms
  • APP_ABI,指定APP_ABI版本,這會決定ndk編譯出的so包數量,關于ABI的介紹見下文,推薦至少包含armeabi或armeabi-v7a
  • APP_STL 如何連接c++標準庫 ,包括 stlport_static ,stlport_shared ,system,分別表示靜態,動態,系統默認
  • APP_OPTIM,包括debug,和release,這會決定so中是否包含調試信息
  • APP_MODULES,填寫so包的名字,如果沒有這個屬性,則按照Android.mk中的進行命名,注意如果文件中含有多個該屬性,則會按照先后順序為你編譯出來的so文件命名
  • 填寫完這兩個mk文件之后,需要在gradle中指定so庫的路徑,gradle會自動將so文件打包進來,在andorid閉包中添加
    sourceSets.main {
    jniLibs.srcDir 'src/main/libs'
    jni.srcDirs = []
    }
  • 如無意外這個時候我們的項目就可以運行起來了,打印log如下
    11-17 16:33:37.563 3824-3824/? D/JniUtil: test: 2


人生苦短,我用AS 3.0


自動生成函數

  • 如果出于不幸、粗心、經驗不足等原因,你的項目無法運行,請不要懷疑你的智商,下面為你帶來傻瓜式的ndk開發流程
  • 最新版的AS,可以為你使用ndk開發提供很大的方便,請確保你在SDK Tools中下載了CMake、LLDB、NDK
  • 首先創建一個新項目,并勾選Include C++ support
  • C++ Standard中可以選擇使用的C++標準,默認是CMake所使用的標準,Exceptions Support可以啟用C++異常處理,一般這些選項使用默認的就可以了
  • 項目創建完畢之后你可以看見官方已經為你做好了很多工作,并且帶了一個c++的hello world示例,你需要關注的主要有cpp和External Build Files 兩個目錄,前者用于放置你的C++源文件,后者根據不同的ABI版本放置了CMake腳本
  • 接著我們直接在MainActivity中添加一個native方法,然后選中該方法,按下alt+enter,讓IDE為我們自動生成C++函數
        public native boolean booleanFromJNI();
  • 在native-lib.cpp中可以看見自動生成的函數,我們只需要實現該函數即可
    JNIEXPORT jboolean JNICALL
    Java_com_linjiamin_myapplication_MainActivity_booleanFromJNI(JNIEnv *env, jobject instance) {
    
        // TODO
    
    }
  • 注意使用上面的方法你可以在任意一個java文件中聲明native方法,IDE會自動在native-lib.cpp中為你生成對應的函數簽名,當然,你也不是非要把所有的C/C++代碼都寫在一個文件里,下面來講解一下CMakeList的基本寫法


編寫CMakeList

  • 下面是一份官方寫好的CMakeList.txt,這個文件可以在你當前項目的app目錄下看到
    add_library( # 設置編譯出來的so包的名字. 不需要添加lib前綴
                 native-lib
    
                 # 設置為共享鏈接庫. 有SHARED,STATIC兩種可選
                 SHARED
    
                 # 設置源文件的相對路徑,可將多個源文件進行編譯
                 src/main/cpp/native-lib.cpp )
    
    # Searches for a specified prebuilt library and stores the path as a
    # variable. Because CMake includes system libraries in the search path by
    # default, you only need to specify the name of the public NDK library
    # you want to add. CMake verifies that the library exists before
    # completing its build.
    
    find_library( # 設置外部引用庫.這個庫支持你在c/c++中打印log,具體請見  android/log.h
                log-lib
                # 外部引用庫的名稱
                log )
    
    # Specifies libraries CMake should link to your target library. You
    # can link multiple libraries, such as libraries you define in this
    # build script, prebuilt third-party libraries, or system libraries.
    
    target_link_libraries( # 指定被鏈接的庫.
                        native-lib
    
                        # 鏈接log-lib到native-lib
                        ${log-lib} )
  • 現在我們想要將不同的源文件編譯成多份so包,例如我在cpp目錄下添加一份test-lib.cpp文件,代碼如下
    extern "C"
    JNIEXPORT jboolean JNICALL
    Java_com_linjiamin_myapplication_JniUtil_booleanFromJNI(JNIEnv *env, jobject instance) {
    
    //上面提到的log庫可以這么使用,而且你應該使用宏讓它好看些
    __android_log_print(ANDROID_LOG_DEBUG,"stringFromJNI","%d",0);
    return (jboolean) true;
    
    }
  • 那么可以在剛剛的CMakeList中設定我們的so包,在最后加上
    add_library( test-lib SHARED src/main/cpp/test-lib.cpp )
  • 編譯之后 可以看到 build/intermediates/cmake/debug/obj/ 路徑下不同的ABI目錄中都有了兩份so文件,分別是libnative-lib.so,libtest-lib.so
  • 如果你在不同的路徑下放置了源文件,并且希望對于每一個特定的路徑都有一份自己特定的CMakeList文件來描述這些源文件的打包規則(這看起來是個好習慣),可以使用add_subdirectory("目錄名")方法指定子路徑,子路徑當中的放置CMakeList會被執行


使用g++編譯so包

  • 對于非安卓開發者,這里再簡單介紹一個使用g++編譯so包的方法,使用這種方法你無需ndk環境,也不用編寫mk、CMakeList文件,完全使用命令行進行編譯,當然我更推薦你去學習cmake
  • 編寫java文件并用javah指令生成頭文件,再編寫cpp文件,這個流程對于不同平臺的jni開發都是相同的(雖然Intelligent Idea這種IDE可以為你自動生成頭文件),那么現在需要一份對應平臺下的jni.h文件,可以在你的jdk當中查找,編譯器可能還會提示你需要一份jni_md.h文件,它也在jdk當中
    $ cd /Library/Java/JavaVirtualMachines/jdk1.7.0_71.jdk
    $ find . -iname jni.h
    ./Contents/Home/include/jni.h
  • 我這里用的是ndk當中的jni.h文件,下載ndk之后在sdk當中可以找到
    $ cd /Users/alberthumbert/Library/Android/sdk/ndk-bundle
    $ find . -iname jni.h
    ./sysroot/usr/include/jni.h
  • 現在假定你以及有了一份java文件,兩份頭文件,一份cpp或c文件,那么可以使用如下命令將他們編譯成so文件,注意最后的參數不一定是必要的,如果你想編譯安卓平臺可用的so包那么建議加上
    g++ com_linjiamin_jnishare_JniUtil.cpp -fPIC -shared -o libsotest.so -Wl,--hash-style=sysv

*注意,nix平臺使用lib表示一個so庫,所以so文件必須以lib開頭,但加載時請將lib前綴去掉**

  • 你可以用絕對路徑加載一個so文件
    System.load("\***\***\***.so")
  • 你也可以將so文件放入系統加載路徑當中,調用 System.getProperty("java.library.path")方法得到系統加載路徑,想要修改這個路徑,可以在修改.bashrc(或者當前使用的其他shell)中添加
    export PATH=PATH:/XXX
  • 運行jar時動態指定路徑也是可以的,這樣會暫時覆蓋上面寫入的屬性
    java -jar -Djava.library.path=/**/**   **.jar
  • 重新在java代碼中加載so文件,*nix平臺注意去掉lib前綴
    System.loadLibrary ("***");


ABI與so

  • 這里再啰嗦一下別的東西,可以先跳過,之后再倒回來看
  • 由于目前我們的項目很簡單,沒有用到第三方so庫和也沒有去除多余so庫為apk瘦身,因此不用考慮兼容問題,但實際開發項目時通常沒這么簡單。我們知道,編譯出來的so是二進制文件,由于不同的CPU支持不同的指令集,所以我們需要考慮兼容性的問題。一個包含多種指令集及其相關約定的實現被稱之為ABI,一個CPU架構支持一種到多種ABI。安卓平臺就是針對ABI進行編譯和打包的。
  • 可能看了上面這一段會比較暈,那么我就舉一個例子來說明,比如ARMv7架構的CUP,支持 armeabi和armeabi-v7a兩種ABI,而armeabi這種ABI支持Thumb-1,ARMV5TE等指令集,armeabi-v7a這種ABI又支持Thumb-2和VFPv3-D16等指令集,也就是說,一種CPU架構對應多種ABI類型,一種ABI類型對于多種指令集
  • 一個so文件只支持一種ABI,因此你會發現在lib下每一個包都是以ABI來命名的,同名的so文件被按照其支持的ABI進行分類
  • 目前ABI一共有七種,那么是不是意味者我們的每一個so都需要編譯成七種,然后全都打包進apk當中呢,答案是否定的。目前CUP流行的架構主要有ARM系列,x86,x86_64,但移動設備大部分都是ARMv7架構,少數是ARM架構,由于ARMv7架構兼容armeabi,因此類似淘寶、微信、餓了么的國內大廠通常只使用armeabi一種ABI,Facebook,Twitter等外國大廠則是只保留了armeabi-v7a,這是十分合理的,apk只保留一種通用的ABI,而最適應的so可以在外部去下載
  • 那么是不是只要編譯一種so文件就可以了呢?不完全正確,如果你引用了第三方的ndk,而第三方在兼容性做得比較好的情況下適配了多種ABI,又或者目前你的lib下so包的數量參差不齊。當一個apk安裝時就有可能查找到了最適用的ABI的路徑存在,但里面又沒有想要的so,這時它不會自動去查找其他ABI版本的so,而是會crash,為了解決這個問題,請在lib包下只保留一個包(通常是armeabi或者armeabi-v7a),或者每個名字的so在不同包下都存在對應版本,并且在app的gradle的defaultConfig閉包中添加你所適配好的ABI,這樣安裝時只會從你所指定的ABI中查找so包
    ndk{
        abiFilters  "armeabi-v7a", "x86", "armeabi"
    }


什么是jni

現在我們已經可以進行簡單的ndk開發了,但為了加深理解認識,讓我再來啰嗦一下jni

看過這一部分之后你對jni應該會有更近一步的感性認識


jni.h


jni數據類型

  • 現在來看看c/c++層的數據類型是怎么對應到java層的
  • 首先是基本類型,根據java中的定義定義了j*類型
    /* Primitive types that match up with Java equivalents. */
    typedef uint8_t  jboolean; /* unsigned 8 bits */
    typedef int8_t   jbyte;    /* signed 8 bits */
    typedef uint16_t jchar;    /* unsigned 16 bits */
    typedef int16_t  jshort;   /* signed 16 bits */
    typedef int32_t  jint;     /* signed 32 bits */
    typedef int64_t  jlong;    /* signed 64 bits */
    typedef float    jfloat;   /* 32-bit IEEE 754 */
    typedef double   jdouble;  /* 64-bit IEEE 754 */
  • 對于引用類型,c和c++有區別,在c++中 jobject是類,而jstring和各種類型的數組都是jobject的子類的指針,在c中jobject是一個void*指針,而其他引用類型其實都是jobject
    class _jobject {};
    class _jclass : public _jobject {};
    class _jstring : public _jobject {};
    class _jarray : public _jobject {};
    class _jobjectArray : public _jarray {};
    class _jbooleanArray : public _jarray {};
    class _jbyteArray : public _jarray {};
    class _jcharArray : public _jarray {};
    class _jshortArray : public _jarray {};
    class _jintArray : public _jarray {};
    class _jlongArray : public _jarray {};
    class _jfloatArray : public _jarray {};
    class _jdoubleArray : public _jarray {};
    class _jthrowable : public _jobject {};
    
    typedef _jobject*       jobject;
    typedef _jclass*        jclass;
    typedef _jstring*       jstring;
    typedef _jarray*        jarray;
    typedef _jobjectArray*  jobjectArray;
    typedef _jbooleanArray* jbooleanArray;
    typedef _jbyteArray*    jbyteArray;
    typedef _jcharArray*    jcharArray;
    typedef _jshortArray*   jshortArray;
    typedef _jintArray*     jintArray;
    typedef _jlongArray*    jlongArray;
    typedef _jfloatArray*   jfloatArray;
    typedef _jdoubleArray*  jdoubleArray;
    typedef _jthrowable*    jthrowable;
    typedef _jobject*       jweak;
    
    
    /*
     * Reference types, in C.
     */
    typedef void*           jobject;
    typedef jobject         jclass;
    typedef jobject         jstring;
    typedef jobject         jarray;
    typedef jarray          jobjectArray;
    typedef jarray          jbooleanArray;
    typedef jarray          jbyteArray;
    typedef jarray          jcharArray;
    typedef jarray          jshortArray;
    typedef jarray          jintArray;
    typedef jarray          jlongArray;
    typedef jarray          jfloatArray;
    typedef jarray          jdoubleArray;
    typedef jobject         jthrowable;
    typedef jobject         jweak;
  • jvalue是一個比較特殊的聯合體,一般在需要調用java層方法時做為方法參數傳入,如 void CallVoidMethodA(jobject obj, jmethodID methodID, jvalue* args) ,通過jobject和表示其方法的jmethodID即可特定一個具體的方法,然后將我們的jvalue作為函數列表傳入
    typedef union jvalue {
            jboolean    z;
            jbyte       b;
            jchar       c;
            jshort      s;
            jint        i;
            jlong       j;
            jfloat      f;
            jdouble     d;
            jobject     l;
        } jvalue;
  • 在這一方面我們可以討論的內容比較少,總的來說,由于C/C++ 中基本類型的字節數依賴與實現,所以在native層轉換到java層是不能直接使用原本的int,long等類型而是根據java中的約定使用jni.h指定了相同長度與有符號的類型,而java中的類則可以使用類或結構體的指針來解決


常用的接口

在講解JNIEnv和JavaVM之前先來嘗試一下各種jni的基本操作,版本較新的AS已經支持了對C/C++ 的智能提示和代碼補全功能,你可以很方便地試用JNIEnv提供的接口

這里只介紹幾個例子,以后有時間我會另寫文章介紹這些接口,強烈推薦你使用AS把可調用的函數瀏覽并選擇性地使用一遍


修改成員變量

  • 通過之前的例子你應該已經知道怎么從native層中獲取一個變量了,現在再進一步,我們使用native方法直接改變成員變量的值,在MainActivity中定義一個native方法
    public class MainActivity extends AppCompatActivity {
    
        public String mString = null;
    
    static {
            System.loadLibrary("native-lib");
        }
    
        private static final String TAG = "MainActivity";
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
    
            Log.d(TAG, "onCreate: "+mString);
            getFieldFromJNI();
            Log.d(TAG, "onCreate: "+ mString);
        }
    
        public native String getFieldFromJNI();
    
    }
  • 在來看C++實現,注意你可能在閱覽其他文章時發現調用jni函數時有兩種不同的寫法 env-> 和 (*env)->,這是由于C與C++的實現有所差異,不影響我們使用。如果你使用過反射改變成員變量的值,應該可以毫不費力地理解下面這段代碼
    extern "C"
    JNIEXPORT jstring JNICALL
    Java_com_linjiamin_jnilearning_MainActivity_getFieldFromJNI(JNIEnv *env, jobject instance) {
    
        jclass clazz = env->GetObjectClass(instance);
        //獲取調用者的class對象
        jfieldID  jfID = env ->GetFieldID(clazz,"mString","Ljava/lang/String;");
        //獲取成員變量的鍵
        jstring strValue = (jstring) env->GetObjectField(instance, jfID);
        //獲取成員變量的值,不做操作
        char chars[10] = "whatever";
        jstring newValue = env->NewStringUTF(chars);
        //創建一個String對象
        env->SetObjectField(instance,jfID,newValue);
        //設置新的值
        return strValue;
    }


創建引用


引用類型
  • 了解jni中的引用類型,有助于你編寫高效的代碼并且解決內存泄漏等問題,jni中的引用類型可以分為三種,局部引用,全局引用,弱(全局)引用,通常jvm會在函數返回后自動為你釋放局部引用,但你需要自行管理全局引用的生命周期


局部引用
  • 其實我們之前已經接觸過局部引用了,調用jni函數通常會創建新對象的實例并返回一個局部引用,局部引用只在一次native函數的調用周期中存在,在函數結束時被釋放
  • 通常我們需要避免返回全局引用,而是返回創建出來的局部引用,例如這樣
    return (jstring) env->NewLocalRef(someValue);
  • 我們剛剛說過,局部引用在函數的調用過程中存在,也就是說如果不進行人為的銷毀操作,它將一直存在,在任意native函數中執行下面這段代碼,你將接受到一個異常,跟java不同,gc不會及時回收通過這種方法創建出來的變量
    for(int i = 0;i<1000000000;i++){
        jstring newValue = env->NewStringUTF(chars);
    }
  • 你可以調用 DeleteLocalRef函數銷毀一個局部引用,這個函數現在就可以執行了,不過他會比一般函數耗時些
        for(int i = 0;i<1000000000;i++){
            jstring newValue = env->NewStringUTF(chars);
            env->DeleteLocalRef(newValue);
        }


全局引用
  • 之前說過局部引用在函數的調用過程中存在,我們不能直接使用顯式賦值的方式將局部引用強行將其緩存起來
    jobject gInstance;
    
    …
    
    extern "C" JNIEXPORT void JNICALL 
    Java_com_linjiamin_jnilearning_MainActivity_useCPlusThread(JNIEnv *env, jobject instance) {
    methodID = env->GetMethodID(clazz,"sayHello","()V");
    gInstance = instance;
    ...
    }
  • 上面的例子將會報錯,不過對于jni開發,你可能常常只能收到含糊的報錯信息,甚至收不到報錯信息
    JNI DETECTED ERROR IN APPLICATION: native code passing in reference to invalid stack indirect reference table or invalid reference: 0x7fff871642e0
  • 我們可以使用 NewGlobalRef 函數來創建一個全局引用,但是注意,你需要對自己的行為負責,全局引用只有在你的手動調用 DeleteGlobalRef 函數之后才會被釋放,你可以在JNI_OnLoad 中進行緩存工作,在JNI_OnUnload函數中進行緩存的清除
    jobject gInstance;
    
    …
    extern "C" JNIEXPORT void JNICALL 
    Java_com_linjiamin_jnilearning_MainActivity_useCPlusThread(JNIEnv *env, jobject instance) {
    
    methodID = env->GetMethodID(clazz,"sayHello","()V");
    gInstance = env->NewGlobalRef(instance);
    ...
    }
    
    …
    
    void fun() {
    ...
    ...
    env->DeleteGlobalRef(gInstance);
    }


弱引用
  • 弱引用和全局引用大體上類似,但是當內存不足時它會被GC回收,通過 NewWeakGlobalRef 函數可以創建一個弱引用,和Java層的弱引用一致,它不會阻止自己所指向的對象被GC回收
    gInstance = env->NewWeakGlobalRef(instance);
  • 但是這不意味著你可以不用管理弱引用的生命周期,在不需要它時請主動釋放弱引用,注意,弱引用的釋放不會導致它所指向的對象被GC回收
    env->DeleteWeakGlobalRef(gInstance)
  • 最好在使用弱引用時判斷它的對象是否已被釋放,你可能會理所當然地使用 == 進行判斷,這種方法是錯誤的,除非這個引用從來就沒有被初始化過,不然表達式將永遠為真,解決方案是使用jni提供的接口進行比較,有的文章也推薦再次使用NewWeakGlobalRef來達到這樣的效果,個人認為這兩種方案除了在可讀性上的區別外沒什么不同
    if (env->IsSameObject(gInstance,NULL)) {
        __android_log_print(ANDROID_LOG_DEBUG,"fun","%s","instance is NULL");
    }
    
        //或者
    
    if (!gInstance || !env->NewWeakGlobalRef(gInstance)) {
        __android_log_print(ANDROID_LOG_DEBUG,"fun","%s","instance is NULL");
    }


JNIEnv,JavaVM 以及多線程

  • 你可能已經意識到,目前為止我們都是通過JNIEnv來使用jni的,實際上JNIEnv提供了Native函數的基礎環境,具體來說,它包含了一個指向函數表的指針,這也就是為什么我們需要通過JNIEnv才能調用native方法,JNIEnv也代表了具體的進程環境,因此不允許跨進程調用,最好的做法是永遠不要緩存JNIEnv,你可以通過JavaVM來創造它的實例
  • JavaVM是java虛擬機的代表,它可以跨線程調用,它是一個全局對象,典型的jni環境中一個進程可以有多個JavaVM,但是在安卓環境當中他在每個進程中只有一個實例,通常你可以在JNI_OnLoad 函數,或其可以獲取JNIEnv的地方得到它_
        env->GetJavaVM(&gVm);
  • 下面在C++線程中模仿耗時操作,并調用Java層方法傳回數據,首先定義接受數據的方法和一個native方法,這里的參數列表稍微定義得復雜一點,方便之后演示jvalue的使用方法
    public void resultCallback(boolean isSuccess,int result,String data){
        Log.d(TAG, "resultCallback: "+ isSuccess + " "+result + " " +data);
    }
    
    public native void useCPlusThread();
  • native方法的實現如下,這里我們通過GetJavaVM方法得到了JavaVM對象,JavaVM用于我們之后獲取JNIEnv,同時我們把調用者通過全局引用緩存起來,注意這里的methodID不需要使用NewGlobalRef,它是一個結構體,直接賦值即可,由于java支持重載,需要輸入方法函數列表的標識才可以特定一個方法,每個基本類型都有其對應的縮寫,而對于類我們需要通過包名和類名來指定。然后我們開啟五個線程進行耗時操作
    extern "C"
    JNIEXPORT void JNICALL
    Java_com_linjiamin_jnilearning_MainActivity_useCPlusThread(JNIEnv *env, jobject instance) {
    
    env->GetJavaVM(&gVm);
    jclass clazz = env->GetObjectClass(instance);
    
    
    methodID = env->GetMethodID(clazz, "resultCallback", "(ZILjava/lang/String;)V");
    gInstance = env->NewGlobalRef(instance);
    
    pthread_t pthread[5];
    
    for(int i = 0;i<5;i++){
        pthread_create(&pthread[i], NULL, &fun, NULL);
    }
    }
  • 線程方法的實現如下,linux系統的sleep函數定義在unistd.h文件中,我們使用它來模仿耗時操作,像剛剛說過的一樣JNIEnv不能跨進程調用,那么這里使用AttachCurrentThread函數得到實例,這個函數同時也會將當前線程綁定到JavaVM上,然后我們使用CallVoidMethodA來調用剛剛緩存起來的實例的方法,也就是java層的resultCallback方法,jvalue數組可以作為參數列表傳入,另外你也可以使用更為簡便的CallVoidMethod函數,最后記得使用DetachCurrentThread函數解綁,除非你使用DeleteLocalRef函數釋放引用,不然你通過JNIEnv獲取的局部引用在你調用DetachCurrentThread之前都不會被銷毀,并且在函數結束后造成內存泄漏
    void *fun(void *arg) {
    sleep(3);
    
    JNIEnv *env;
    if (gVm->AttachCurrentThread(&env, NULL) != JNI_OK) {
        __android_log_print(ANDROID_LOG_DEBUG, "callJniInDifferentThread", "%s", "attach failed");
        return NULL;
    }
    
    jvalue * args = new jvalue[3];
    args[0].z = (jboolean) true;
    args[1].i = 1000;
    args[2].l = env->NewStringUTF("some data");
    
    env->CallVoidMethodA(gInstance, methodID, args);
    
    if (gVm->DetachCurrentThread() != JNI_OK) {
        __android_log_print(ANDROID_LOG_DEBUG, "callJniInDifferentThread", "%s", "detach failed");
    }
    
    return NULL;
    
    }
  • 注,各種類型對應的縮寫如下,請使用一下的縮寫特定具體的方法
類型 縮寫
Boolean Z
Byte B
Char C
Short S
Int I
Long L
Float F
Double D
Void V
Object 以"L"開頭,以";"結尾,中間是用"/" 隔開的包及類名。比如:Ljava/lang/String;如果是嵌套類,則用$來表示嵌套。例如 "(Ljava/lang/String;Landroid/os/FileUtils$FileStatus;)Z"
返回值與參數 例 (IB)L 表示返回類型為long,參數為int和byte的函數


內存泄漏


局部引用的內存模型

  • 剛剛提到JVM在一定程度上會為你管理局部引用的生命周期,但這不意味著局部引用等于局部變量。每當線程從Java層切換到native層時,JVM會創建局部引用表,它們維系了你的C/C++變量和Java層變量。
  • 下圖中出現的本地方法棧是和虛擬機棧類似的一種概念,但它用于運行native方法,注意,規范只約定了jni的操作和使用方法,對實現沒有明確的要求,有些虛擬機會將虛擬機棧與本地方法棧合并實現,這里只大致地描繪本地方法棧的結構。當java層切入到native層(以下簡稱J2N過程,反之為N2J),或者在native函數中調用了jni接口時會導致本地方法棧的入棧操作,本地引用表在J2N時創建,并在N2J時銷毀,在這個過程中,每當局部引用被合法創建,該局部引用都會被添加到表中并映射到java堆中的一個對象
  • 看回前面這個例子,我們在循環中不斷創建新的局部引用,并且賦值給變量newValue,這些不斷創建的引用并不會立即釋放,并且我們之后也無法獲取到這些還留在表中的引用,所以他們都導致了內存泄漏。一般情況下局部引用表分配到的內存空間很小,這種內存泄漏很容易就會導致內存溢出,虛擬機崩潰。為了編寫更加安全流暢的代碼,我建議你遵循下面幾個規范
    for(int i = 0;i<1000000000;i++){
        jstring newValue = env->NewStringUTF(chars);
    }


引用的使用規范

  • native編程首先需要遵循C/C++自身的內存管理機制,除了局部引用以外,JVM不會為你做更多的內存釋放工作,所以當你使用malloc函數分配內存空間后必須使用free函數進行釋放,這和其他平臺上的C/C++編程沒什么不同
  • 全局變量對java層對象的引用一直有效,請在不用時進行刪除,否它所指向的對象將一直留在堆中
  • 和剛剛介紹局部引用時說的一樣,在函數返回之前,局部引用不會自動釋放,如果創建過多的引用將會導致內存溢出的風險,如果你的函數只會創建為數不多的局部引用,那么完全可以將刪除引用的操作交給JVM去處理,但如果你的函數會創建大量的引用,特別是在開啟循環的請況下,請自行調用DeleteLocalRef函數


推薦閱讀

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容