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

参考资料

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


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

原文发布于微信公众号 - 腾讯Bugly(weixinBugly)

原文发表时间:2017-11-30

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏coolblog.xyz技术专栏

MyBatis 源码分析 - 配置文件解析过程

由于本篇文章篇幅比较大,所以这里拿出一节对本文进行快速概括。本篇文章对 MyBatis 配置文件中常用配置的解析过程进行了较为详细的介绍和分析,包括但不限于se...

13120
来自专栏DOTNET

asp.net web api 异常捕获

1 向客户端发送错误消息 使用throw new HttpResponseException()向客户端抛出错误信息。 HttpResponseExceptio...

531120
来自专栏Java学习网

Java 实现线程死锁

Java 实现线程死锁 概述 春节的时候去面试了一家公司,笔试题里面有一道是使用简单的代码实现线程的‘死锁’,当时没有想到这道题考的是Synchronized关...

26160
来自专栏哲学驱动设计

模式应用:自定义匹配

    本篇博客记录了我在工作过程中的一个设计单元。 需求 GIX4项目中需要为非国标清单进行匹配,用户自定义匹配规则。规则可以被存储到数据库中,下次重复使用...

20950
来自专栏从流域到海域

《笨办法学Python》 第20课手记

《笨办法学Python》 第20课手记 本节课讲函数与文件,内容比较简单,但请注意常见问题解答,你应该记住那些内容。 指针表示存储地址。 原代码如下: from...

21660
来自专栏Ryan Miao

java中byte, iso-8859-1, UTF-8,乱码的根源

Post@https://ryan-miao.github.io 测试代码https://github.com/Ryan-Miao/someTest/comm...

67870
来自专栏玄魂工作室

如何学python-第六课 流程控制-IF,ELSE,条件语句

在上一篇文章里,我们介绍了流程控制的概念,并介绍了布尔类型。今天,我们会把上节课学到的东西与 if、else结合起来使用。 条件判断语句 条件判断语句会根据语句...

36080
来自专栏Java学习网

Java内存模型深度解读

Java内存模型深度解读 Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的。Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一...

26670
来自专栏散尽浮华

shell脚本之特殊符号总结性梳理

# 井号 (comments) 这几乎是个满场都有的符号 #!/bin/bash 井号也常出现在一行的开头,或者位于完整指令之后,这类情况表示符号后面的是注...

214100
来自专栏逍遥剑客的游戏开发

UE4学习笔记: Gameplay Classes

28570

扫码关注云+社区

领取腾讯云代金券