在 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 手动删除局部引用。
在 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");
- }
- ......
- }
- }
在自己创建的线程 (类似通过 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;
- }
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 字符串交互的方法供参考。
- {
- 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
作 者: qingcuilu
来源: https://sdk.cn/news/7870