1. 程式人生 > >Android JNI出坑指南

Android JNI出坑指南

在Android程式設計中,出於硬體互動,跨平臺,安全性,第三方庫等方面的考慮,我們需要Java與C/C++互相呼叫,這就需要藉助Java平臺的JNI介面(Java Native Interface)。Android早期版本因JNI呼叫效能,native程式碼除錯困難而被詬病,但近年來效能已經有不錯的優化,Android NDK對C++開發支援也越來越好,特別是在Android Studio上開發除錯C++程式碼極為方便。

然而JNI使用上還是有不少的坑和需要注意之處,特別是在多執行緒場景下使用JNI,不注意的話很容易出Bug。筆者結合自身經驗、網上資料對JNI的坑進行總結,如果有不正確或遺漏之處歡迎指出。

區域性引用超限

當我們通過FindClass,NewStringUtf等獲取jclass或jobject,如果沒有呼叫DeleteLocalRef刪除區域性引用,可能會出現記憶體洩漏或區域性引用超限(local reference table overflow)的問題。

區域性引用(Local Reference)是native code中對Java物件的對映,相當於持有一個Java物件的引用。區域性引用屬於JNI的引用型別,即是jobject或其子類。區域性引用限於其建立的堆疊幀和執行緒,並且在其建立的堆疊幀返回時會自動刪除。也就是說一般情況下區域性引用會在返回Java方法時自己刪除。但呼叫過程中如果存在迴圈、遞迴等呼叫層次過多的情況,很可能會導致區域性引用數量超過區域性引用限制導致崩潰。另一方面如果本地方法沒有返回Java層,或本地執行緒沒有斷開與JVM的連線,區域性引用無法自動釋放會導致記憶體洩漏或區域性引用超限的問題。

因此,我們定製規範,在區域性引用使用完畢後,需要儘快呼叫DeleteLocalRef手動刪除區域性引用。

未呼叫DetachCurrentThread導致執行緒無法正常退出

在natvie執行緒中呼叫了AttachCurrentThread連線到虛擬機器,但執行緒退出前未呼叫DetachCurrentThread取消連線,會導致執行緒無法正常退出,有類似錯誤日誌:”thread exiting, not yet detached”,甚至導致VM abort。

JNIEnv是一個指向全部JNI方法的指標。該指標只在建立它的執行緒有效,不能跨執行緒傳遞。如果是從Java層通過native方法呼叫到C/C++方法,則會建立一個棧楨(stack frame)儲存虛擬機器相關資訊,包括JNIEnv指標,即在native函式的入參處可獲得。且此種情況不需要呼叫DetachCurrentThread

取消連線。如果是在native層通過pthread_create等方式建立的執行緒,則需要呼叫了AttachCurrentThread連線到虛擬機器,才能獲取JNIEnv指標。且線上程退出前需要呼叫DetachCurrentThread取消連線。

因此,對於native執行緒,在呼叫JNI方法前可以先Attach,呼叫完成後立即Detach。不過這樣手動呼叫顯得較為繁瑣。Google官方JNI指南文件建議在Android2.0以上可使用pthread_key,線上程析構時自動呼叫Detach以簡化操作。

Threads attached through JNI must call DetachCurrentThread before they exit. If coding this directly is awkward, in Android 2.0 (Eclair) and higher you can use pthread_key_create to define a destructor function that will be called before the thread exits, and call DetachCurrentThread from there. (Use that key with pthread_setspecific to store the JNIEnv in thread-local-storage; that way it’ll be passed into your destructor as the argument.)

不過需要注意一個程序中pthread_key的數量是有限制的,特別是三星Android4.3手機的可用pthread_key只有64個,儘量程序內複用pthread_key。下面是筆者參考Cocos部分實現的封裝,供大家參考:


extern "C" {
    pthread_key_t s_threadKey;

    static void detach_current_thread_(void *env)
    {
        JAVAVM->DetachCurrentThread();
    }

    static bool getenv_(JNIEnv **env)
    {
        bool bRet = false;
        switch (JAVAVM->GetEnv((void **)env, JNI_VERSION_1_4))
        {
            case JNI_OK:
                bRet = true;
                break;
            case JNI_EDETACHED:
                if (JAVAVM->AttachCurrentThread(env, 0) < 0)
                {
                    break;
                }
                if (pthread_getspecific(s_threadKey) == NULL)
                {
                    pthread_setspecific(s_threadKey, env);
                }
                bRet = true;
                break;
            default:
                break;
        }
        return bRet;
    }

    void MSDKJniHelper::SetJavaVM(JavaVM *vm)
    {
        static bool is_init = false;
        if (is_init == false)
        {
            is_init = true;
            pthread_key_create(&s_threadKey, detach_current_thread_);
            LOG_INFO("init pthread_key");
        }
        ......
    }
}

多執行緒場景下FindClass呼叫失敗

在自己建立的執行緒(類似通過pthread_create)中呼叫FindClass會失敗得到空的返回,從而導致呼叫失敗。

如果在Java層呼叫到native層,會攜帶棧楨(stack frame)資訊,其中包含此應用類的Class Loader,因此場景下JNI能通過此應用類載入器獲取類資訊。 而在使用自己建立並Attach到虛擬機器的執行緒時,因為沒有棧楨(stack frame)資訊,此場景下虛擬機器會通過另外的系統類載入器尋找應用類資訊,但此類載入器並未載入應用類,因此FindClass返回空。

建議通過快取應用類的Class Loader解決此問題,下面是參考程式碼。另外還需注意檢查類名有沒有寫錯(格式類似於java/lang/String),並且確認相應的類沒有被混淆。

// java程式碼
public class JniAdapter {
    public static ClassLoader getClassLoader() {
        return JniAdapter.class.getClassLoader();
    }
}
// C/C++程式碼
JavaVM *MSDKJniHelper::java_vm_ = NULL;
jobject MSDKJniHelper::class_loader_obj_ = NULL;
jmethodID MSDKJniHelper::find_class_mid_ = NULL;

void MSDKJniHelper::SetJavaVM(JavaVM *vm)
{
    ......
    java_vm_ = vm;
    JNIEnv *env;
    if (!getenv_(&env))
    {
        return;
    }
    jclass classLoaderClass = env->FindClass("java/lang/ClassLoader");
    jclass adapterClass = env->FindClass("com/tencent/msdk/framework/JniAdapter");
    if (adapterClass)
    {
        jmethodID getClassLoader = env->GetStaticMethodID(adapterClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
        jobject obj = env->CallStaticObjectMethod(adapterClass, getClassLoader);
        class_loader_obj_ = env->NewGlobalRef(obj);
        find_class_mid_ = env->GetMethodID(classLoaderClass, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;");
        env->DeleteLocalRef(classLoaderClass);
        env->DeleteLocalRef(adapterClass);
        env->DeleteLocalRef(obj);
    }
}

jclass MSDKJniHelper::GetClass(const char *className)
{
    CheckAndClearException();

    JNIEnv *p_env = 0;
    jclass ret = 0;
    do
    {
        if (!p_env)
        {
            if (!getenv_(&p_env))
            {
                break;
            }
        }
        jstring j_class_name = p_env->NewStringUTF(className);
        ret = (jclass)p_env->CallObjectMethod(
            MSDKJniHelper::class_loader_obj_, MSDKJniHelper::find_class_mid_, j_class_name);
        p_env->DeleteLocalRef(j_class_name);
    } while (0);
    if (!ret)
    {
        LOG_ERROR("Failed to find class of %s", className);
    }
    return ret;
}

使用emoji表情導致Crash或服務端解析失敗

Java與Jni互動時,在Jni層字元編碼為Modified UTF-8。通過jni的NewStringUTF方法把C++的字串轉換為jstring時,如果入參為emoji表情或其他非Modified UTF8編碼字元將導致Crash。另外使用jni的GetStringUTFChars方法把jstring轉換為C++字串時得到的字串編碼為Modified UTF8,如果直接傳遞到服務端或其他使用方,emoji表情將出現解析失敗的問題。

Modified UTF-8的特點:

標準和變種的UTF-8有兩個不同點。

第一,空字元(null character,U+0000)使用雙位元組的0xc0 0x80,而不是單位元組的0x00。這保證了在已編碼字串中沒有嵌入空位元組。因為C語言等語言程式中,單位元組空字元是用來標誌字串結尾的。當已編碼字串放到這樣的語言中處理,一個嵌入的空字元將把字串一刀兩斷。

第二個不同點是基本多文種平面之外字元的編碼的方法。在標準UTF-8中,這些字元使用4位元組形式編碼,而在改正的UTF-8中,這些字元和UTF-16一樣首先表示為代理對(surrogate pairs),然後再像CESU-8那樣按照代理對分別編碼。這樣改正的原因更是微妙。Java中的字元為16位長,因此一些Unicode字元需要兩個Java字元來表示。語言的這個性質蓋過了Unicode的增補平面的要求。儘管如此,為了要保持良好的向後相容、要改變也不容易了。這個改正的編碼系統保證了一個已編碼字串可以一次編為一個UTF-16碼,而不是一次一個Unicode碼點。不幸的是,這也意味著UTF-8中需要4位元組的字元在變種UTF-8中變成需要6位元組。(摘自維基百科)

因此在與其他元件進行互動或與服務端進行通訊時要注意不要誤把變種Modified UTF-8當成UTF-8資料。可以先將Java的String用UTF-8編碼轉換成byte陣列,再轉換成C/C++字串即可保證字元編碼為UTF-8。下面是Java與C++使用UTF-8字串互動的方法供參考。


jstring ToJavaString(const char *buffer, int size)
{
    jclass str_class = GetClass("java/lang/String");
    jmethodID init_mid = JNIENV->GetMethodID(str_class, "<init>", "([BLjava/lang/String;)V");
    jbyteArray bytes = JNIENV->NewByteArray(size);
    JNIENV->SetByteArrayRegion(bytes, 0, size, (jbyte *)buffer);
    jstring encoding = JNIENV->NewStringUTF("utf-8");
    jstring result = (jstring)JNIENV->NewObject(str_class, init_mid, bytes, encoding);
    JNIENV->DeleteLocalRef(str_class);
    JNIENV->DeleteLocalRef(encoding);
    JNIENV->DeleteLocalRef(bytes);
    return result;
}

std::string ToStdString(jstring jstr)
{
    std::string result;
    jclass str_class = JNIENV->FindClass("java/lang/String");
    jstring encoding = JNIENV->NewStringUTF("utf-8");
    jmethodID mid = JNIENV->GetMethodID(str_class, "getBytes", "(Ljava/lang/String;)[B");
    JNIENV->DeleteLocalRef(str_class);

    jbyteArray jbytes = (jbyteArray)JNIENV->CallObjectMethod(jstr, mid, encoding);
    JNIENV->DeleteLocalRef(encoding);

    jsize str_len = JNIENV->GetArrayLength(jbytes);
    if (str_len > 0)
    {
        char *bytes = (char*)malloc(str_len);
        JNIENV->GetByteArrayRegion(jbytes, 0, str_len, (jbyte*)bytes);
        result = std::string(bytes, str_len);
        free(bytes);
    }
    JNIENV->DeleteLocalRef(jbytes);
    return result;
}

參考資料