Android JNI出坑指南

时间:2022-05-05
本文章向大家介绍Android JNI出坑指南,主要内容包括局部引用超限、未调用DetachCurrentThread导致线程无法正常退出、多线程场景下FindClass调用失败、使用emoji表情导致Crash或服务端解析失败、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

在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;
}

参考资料

1.JNI对象引用概述:https://www.ibm.com/support/knowledgecenter/zh/SSYKE2_7.0.0/com.ibm.java.win.70.doc/diag/understanding/jni_gc.html

2.在JNI变成中避免内存泄漏:https://www.ibm.com/developerworks/cn/java/j-lo-jnileak/index.html

3.JNI Tips:https://developer.android.com/training/articles/perf-jni.html


如果您觉得我们的内容还不错,就请转发到朋友圈,和小伙伴一起分享吧~