JNI/NDK入门指南之正确姿势了解,使用,管理,缓存JNI引用

  JNI/NDK入门指南之正确姿势了解,使用,管理,缓存JNI引用


前言

  对于本篇我是写了删,删了又写,当然不是我纠结于我的文笔不好。而是因为本章涉及的知识面比较广,而我如果由于个人原因不能将JNI中的引用讲得透彻明白甚至误导了读者的话,那就是最大的罪过了!但是又不得不写,因为如果想真正的将JNI用于实战而不是仅仅的了解,那么JNI的引用是逃脱不了的。所以我只能硬着头皮上了,力求讲明白,如果有错误或者不对的请给位读者指出,或者一起探讨。



一. 开篇

  在前面的篇章JNI/NDK入门指南之JNI多线程回调Java方法中,相信读者对跨线程乱用局部引用jobject导致的程序崩溃还历历在目吗,多么痛的领悟啊。出现上述问题最根本的原因就是因为如果一个Java对象没有被其它成员变量或者静态变量引用的话,就随时有可能会被GC(啥是GC后面会简要概括)回收掉,就像我们前面通过JNI传递下来的Java对象在本地方法中的jobject。所以我们在编写本地代码时,要注意从 JVM 中获取到的引用在使用时被 GC 回收的可能性。由于本地代码不能直接通过引用操作 JVM 内部的数据结构,要进行这些操作必须调用相应的 JNI 接口来间接操作所引用的数据结构。JNI 提供了和 Java 相对应的引用类型,供本地代码配合 JNI 接口间接操作 JVM 内部的数据内容使用。如:jobject、jstring、jclass、jarray、jintArray等。因为我们只通过 JNI 接口操作 JNI 提供的引用类型数据结构,而且每个 JVM 都实现了 JNI 规范相应的接口,所以我们不必担心特定 JVM 中对象的存储方式和内部数据结构等信息,我们只需要学习 JNI 中三种不同的引用即可。


1.1 GC是个啥

在前面JNI/NDK入门指南之JNI多线程回调Java方法篇章和本篇章多次提到了GC,那么究竟啥是GC呢?GC,是负责回收Java中不再使用的对象(那啥是不再使用的对象呢),它的英文全称是Garbage Collection,也就是所谓的垃圾回收。JVM 会在适当的时机触发 GC 操作,一旦进行 GC 操作,就会将一些不再使用的对象进行回收。那么哪些对象会被认为是不再使用,并且可以被回收的呢?,常见的就是可达性分析算法,那么啥是可达性分析算法呢?


1.2 可达性分析算法

可达性分析算法(Reachability Analysis)的基本思路是,通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 节点到该节点不可达),则证明该对象是不可用的。
在这里插入图片描述
思路是通过可达性算法,成功解决了引用计数所无法解决的问题-“循环依赖”,只要你无法与 GC Root 建立直接或间接的连接,系统就会判定你为可回收对象。那这样就引申出了另一个问题,哪些属于 GC Root。此时的你是不是心里在想,这不是都是Java层的吗,和JNI中的本地对象没有任何关系啊,其实不然因为Java的内存区域包括JNI部分的。


1.3 Java 内存区域

  • 在 Java 语言中,可作为 GC Root 的对象包括以下4种:
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

在这里插入图片描述



二. 深入了解JNI中三种引用以及使用场景

在前面的章节我们了解了为啥JNI的引用可能会被GC,以及啥是GC和啥时候被GC等等。那么在本章章节我将带领大家深入探讨一下JNI中的三种引用以及使用场景。

我们知道在 JNI 规范中定义了三种引用:局部引用(Local Reference)、全局引用(Global Reference)、弱全局引用(Weak Global Reference)。Local和 Global 引用有不同的生命周期. Local Ref在native method执行完毕后被JavaVM自动释放,而 GlobalRef,WeakRef在程序员主动释放前一直有效。并且各种引用都有使用范围. 如LocalRef只能在当前线程的native method中使用,下面让我们分别详细介绍。


2.1 局部引用

局部引用,顾名思义局部的引用,和局部变量有异曲同工之处。局部引用是一种最常见的引用,JNI中的局部引用主要通过JNI接口从Java传递下来或者通过NewLocalRef和各种JNI接口创建(FindClass、NewObject、GetObjectClass和NewCharArray等)。局部引用会阻止GC回收所引用的对象,至于为什么参见前面章节GC可达性算法。局部引用不在本地函数中跨函数使用,不能跨线前使用,当然也不能缓存起来使用,这个对于有过C/C++开发经验的读者应该非常熟悉可以参考局部变量的使用。函数返回后局部引用所引用的对象会被JVM自动释放,或调用DeleteLocalRef释放如env->DeleteLocalRef(local_ref)

2.1.1 局部引用的创建

下面我们来通过一个例子来举例说明局部引用的创建,如下:

cls_string = env->FindClass("java/lang/String");
cid_string = env->GetMethodID(cls_string, "<init>", "([C)V");
object_str= (jstring)env->NewObject(cls_string, cid_string, elemArray);
jstring str_obj_local_ref = env->NewLocalRef(object_str);   // 通过NewLocalRef函数创建

2.1.2 局部引用的正确使用

局部引用也被称为本地引用,通常在函数中创建并使用(通常并不是所有,通过JNI以参数形式传递进来的对象引用也可以称为局部引用)。并且局部引用会阻止 GC 回收所引用的对象。比如,调用 NewObject接口创建一个新的对象实例并返回一个对这个对象的局部引用。局部引用通常只有在创建它的本地方法返回前有效,本地方法返回到 Java 层之后,如果 Java 层没有对返回的局部引用使用的话,局部引用就会被 JVM 自动释放。你可能会为了提高程序的性能,在函数中将局部引用存储在静态变量中缓存起来,供下次调用时使用。这种方式是错误的,因为函数返回后局部引很可能马上就会被释放掉,静态变量中存储的就是一个被释放后的内存地址,成了一个野针对,下次再使用的时候就会造成非法地址的访问,使程序崩溃。请看下面一个例子,错误的缓存了 String 的 Class 引用。
JNI端错误代码演示:

JNIEXPORT jstring JNICALL Java_com_pax_api_references_JNIReferences_errCacheLocalRefernce
  (JNIEnv * env, jobject object, jcharArray j_char_arr)

{
	jcharArray elemArray;
    jchar *chars = NULL;
    jstring object_str = NULL;
    static jclass cls_string = NULL;
    static jmethodID cid_string = NULL;
    // 注意:这里缓存局引用的做法是错误,这里做为一个反面教材提醒大家,下面会说到。
    if (cls_string == NULL) {
        cls_string = env->FindClass("java/lang/String");
        if (cls_string == NULL) {
            return NULL;
        }
    }else{
		LOGE(TAG,"cls_string has initialize\n");
	}

    // 缓存String的构造方法ID
    if (cid_string == NULL) {
        cid_string = env->GetMethodID(cls_string, "<init>", "([C)V");
        if (cid_string == NULL) {
            return NULL;
        }
    }

    int len = env->GetArrayLength(j_char_arr);
	LOGE(TAG,"The chars lenght : %d\n", len);
    // 创建一个字符数组
    elemArray = env->NewCharArray(len);
    if (elemArray == NULL) {
        return NULL;
    }
    // 获取数组的指针引用,注意:不能直接将jcharArray作为SetCharArrayRegion函数最后一个参数
    chars = env->GetCharArrayElements(j_char_arr,NULL);
    if (chars == NULL) {
        return NULL;
    }


    // 将Java字符数组中的内容复制指定长度到新的字符数组中
    env->SetCharArrayRegion(elemArray, 0, len, chars);

    // 调用String对象的构造方法,创建一个指定字符数组为内容的String对象
    object_str = (jstring)env->NewObject(cls_string, cid_string, elemArray);

    // 释放本地引用
    env->DeleteLocalRef(elemArray);
    return object_str;
}

上面的代码非常简单,通过 FindClass 返回一个对 java.lang.String 对象的局部引用,然后将其缓存在 cls_string中,这种 做法是错误的。然后我们在Java层调用该Native方法看看会有啥结果!
测试代码:

    private void opeateerrCacheLocalRefernce(){
        JNIReferences mJniReferences = new JNIReferences();
        String str = mJniReferences.errCacheLocalRefernce("Hello JNI".toCharArray());
        Log.e("REFERENCES","str : " + str);
        mJniReferences.errCacheLocalRefernce("Hello JNI".toCharArray());
    }

运行演示:

I/REFERENCES( 9777): The chars lenght : 9
E/REFERENCES( 9777): str : Hello JNI
I/REFERENCES( 9777): cls_string has initialize
I/REFERENCES( 9777): The chars lenght : 9
I/DEBUG   (  302): *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
I/DEBUG   (  302): Build fingerprint: 'PAX/CB03/CB03:5.1.1/LMY47V/CB03_CH_V4.70_S:user/release-keys'
I/DEBUG   (  302): Revision: '0'
I/DEBUG   (  302): ABI: 'arm'
I/DEBUG   (  302): pid: 9777, tid: 9777, name: com.pax.jni  >>> com.pax.jni <<<
I/DEBUG   (  302): signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------
I/DEBUG   (  302): Abort message: 'art/runtime/check_jni.cc:65] JNI DETECTED ERROR IN APPLICATION: jclass is an invalid local reference: 0x100019 (0xdead4321)'
I/DEBUG   (  302):     r0 00000000  r1 00002631  r2 00000006  r3 00000000
I/DEBUG   (  302):     r4 b6f67e38  r5 00000006  r6 00000058  r7 0000010c
I/DEBUG   (  302):     r8 00000000  r9 b819dea8  sl b81a80f0  fp 00000001
I/DEBUG   (  302):     ip 00002631  sp becbaad0  lr b6df3ac9  pc b6e1a260  cpsr 60070010
I/DEBUG   (  302):
I/DEBUG   (  302): backtrace:
I/DEBUG   (  302):     #00 pc 0003a260  /system/lib/libc.so (tgkill+12)
I/DEBUG   (  302):     #01 pc 00013ac5  /system/lib/libc.so (pthread_kill+52)
I/DEBUG   (  302):     #02 pc 000146db  /system/lib/libc.so (raise+10)
I/DEBUG   (  302):     #03 pc 00010e7d  /system/lib/libc.so (__libc_android_abort+36)
I/DEBUG   (  302):     #04 pc 0000f534  /system/lib/libc.so (abort+4)
I/DEBUG   (  302):     #05 pc 00215c69  /system/lib/libart.so (_ZN3art7Runtime5AbortEv+160)
I/DEBUG   (  302):     #06 pc 000a62c5  /system/lib/libart.so (_ZN3art10LogMessageD1Ev+1312)
I/DEBUG   (  302):     #07 pc 000aff25  /system/lib/libart.so (_ZN3artL8JniAbortEPKcS1_+1084)
I/DEBUG   (  302):     #08 pc 000b046f  /system/lib/libart.so (_ZN3art9JniAbortFEPKcS1_z+58)
I/DEBUG   (  302):     #09 pc 000b1d5b  /system/lib/libart.so (_ZN3art11ScopedCheck13CheckInstanceENS0_12InstanceKindEP8_jobject+298)
I/DEBUG   (  302):     #10 pc 000b26df  /system/lib/libart.so (_ZN3art11ScopedCheck5CheckEbPKcz.constprop.129+474)
I/DEBUG   (  302):     #11 pc 000b5569  /system/lib/libart.so (_ZN3art8CheckJNI10NewObjectVEP7_JNIEnvP7_jclassP10_jmethodIDSt9__va_list+44)
I/DEBUG   (  302):     #12 pc 00000c6b  /data/app/com.pax.jni-2/lib/arm/libreferences.so (_ZN7_JNIEnv9NewObjectEP7_jclassP10_jmethodIDz+14)
I/DEBUG   (  302):     #13 pc 00000d31  /data/app/com.pax.jni-2/lib/arm/libreferences.so (Java_com_pax_api_references_JNIReferences_errCacheLocalRefernce+188)
I/DEBUG   (  302):     #14 pc 0006292b  /data/dalvik-cache/arm/data@[email protected]@[email protected]
I/DEBUG   (  302):
I/DEBUG   (  302): Tombstone written to: /data/tombstones/tombstone_07

错误分析:
在第一次调用Native方法errCacheLocalRefernce返回后,JVM会释放该本地方法,并且也会释放在这个方法执行期间创建的所有局部引用,也包含对 String 的 Class 引用cls_string。当再次调用 newString 时,newString 所指向引用的内存空间已经被释放,成为了一个野指针,再访问这个指针的引用时,会导致因非法的内存访问造成程序崩溃。这个也在前面章节JNI/NDK入门指南之JNI多线程回调Java方法也有相关的验证。

2.1.2 局部引用的正确释放

通过前面的章节我们知道,释放局部引用有两种方式,一个是本地方法执行完毕后 JVM 自动释放,另外一个是自己调用 DeleteLocalRef 手动释放。既然 JVM 会在函数返回后会自动释放所有局部引用,为什么还需要手动释放呢?大部分情况下,我们在实现一个本地方法时不必担心局部引用的释放问题,函数被调用完成后,JVM 会自动释放函数中创建
的所有局部引用。尽管如此,以下几种情况下,为了避免内存溢出,我们应该手动释放局部引用,这对于期望长期稳定运行的系统来说是必不可少的。
注意: 这里提到的从本地方法返回,返回是指回到Java 层,如果从一个本地函数返回到另一个本地函数,局部引用依然是有效的。
下面让我们看看,在那些场景下要特别注意要主动调用DeleteLocalRef 函数手动释放(当然最后的情况是,不需要都立马释放):

  • JNI 会将创建的局部引用都存储在一个局部引用表中,如果这个表超过了最大容量限制,就会造成局部引用表溢出,使程序崩溃。经测试,Android 上的 JNI 局部引用表最大数量是 512 个。当我们在实现一个本地方法时,可能需要创建大量的局部引用,如果没有及时释放,就有可能导致 JNI 局部引用表的溢出,所以,在不需要局部引用时就立即调用 DeleteLocalRef 手动删除。比如,在下面的代码中,本地代码遍历一个特别大的字符串数组,每遍历一个元素,都会创建一个局部引用,当对使用完这个元素的局部引用时,就应该马上手动释放它。假如我们不释放呢,看看会有什么错误:
	for(int i = 0; i < 600; i++)
	{
		jstring temp = (jstring)env->NewObject(cls_string, cid_string, elemArray);
	}

运行演示

I/REFERENCES(10546): The chars lenght : 9
--------- beginning of crash
I/DEBUG   (  302): *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
I/DEBUG   (  302): Build fingerprint: 'PAX/CB03/CB03:5.1.1/LMY47V/CB03_CH_V4.70_S:user/release-keys'
I/DEBUG   (  302): Revision: '0'
I/DEBUG   (  302): ABI: 'arm'
I/DEBUG   (  302): pid: 10546, tid: 10546, name: com.pax.jni  >>> com.pax.jni <<<
I/DEBUG   (  302): signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------
I/DEBUG   (  302): Abort message: 'art/runtime/indirect_reference_table.cc:98] JNI ERROR (app bug): local reference table overflow (max=512)'
I/DEBUG   (  302):     r0 00000000  r1 00002932  r2 00000006  r3 00000000
I/DEBUG   (  302):     r4 b6f67e38  r5 00000006  r6 00000058  r7 0000010c
I/DEBUG   (  302):     r8 00000000  r9 b819dea8  sl b81a80f0  fp 00000001
I/DEBUG   (  302):     ip 00002932  sp becbac50  lr b6df3ac9  pc b6e1a260  cpsr 60070010
I/DEBUG   (  302):
I/DEBUG   (  302): backtrace:
I/DEBUG   (  302):     #00 pc 0003a260  /system/lib/libc.so (tgkill+12)
I/DEBUG   (  302):     #01 pc 00013ac5  /system/lib/libc.so (pthread_kill+52)
I/DEBUG   (  302):     #02 pc 000146db  /system/lib/libc.so (raise+10)
I/DEBUG   (  302):     #03 pc 00010e7d  /system/lib/libc.so (__libc_android_abort+36)
I/DEBUG   (  302):     #04 pc 0000f534  /system/lib/libc.so (abort+4)
I/DEBUG   (  302):     #05 pc 00215c69  /system/lib/libart.so (_ZN3art7Runtime5AbortEv+160)
I/DEBUG   (  302):     #06 pc 000a62c5  /system/lib/libart.so (_ZN3art10LogMessageD1Ev+1312)
I/DEBUG   (  302):     #07 pc 00156679  /system/lib/libart.so (_ZN3art22IndirectReferenceTable3AddEjPNS_6mirror6ObjectE+232)
I/DEBUG   (  302):     #08 pc 001ccc53  /system/lib/libart.so (_ZN3art3JNI10NewObjectVEP7_JNIEnvP7_jclassP10_jmethodIDSt9__va_list+334)
I/DEBUG   (  302):     #09 pc 000b5579  /system/lib/libart.so (_ZN3art8CheckJNI10NewObjectVEP7_JNIEnvP7_jclassP10_jmethodIDSt9__va_list+60)
I/DEBUG   (  302):     #10 pc 00000c6b  /data/app/com.pax.jni-1/lib/arm/libreferences.so (_ZN7_JNIEnv9NewObjectEP7_jclassP10_jmethodIDz+14)
I/DEBUG   (  302):     #11 pc 00000d47  /data/app/com.pax.jni-1/lib/arm/libreferences.so (Java_com_pax_api_references_JNIReferences_errCacheLocalRefernce+210)
I/DEBUG   (  302):     #12 pc 0006292b  /data/dalvik-cache/arm/data@[email protected]@[email protected]
I/DEBUG   (  302):
I/DEBUG   (  302): Tombstone written to: /data/tombstones/tombstone_00

错误分析
这个错误很明显就是本地引用表溢出,最大只允许512个。

I/DEBUG   (  302): Abort message: 'art/runtime/indirect_reference_table.cc:98] JNI ERROR (app bug): local reference table overflow (max=512)'
  • 在编写JNI工具类时,即时释放局部引用。
    在编写工具类时,很难知道被调用者具体会是谁,考虑到通用性,完成工具类的任务之后,就要及时释放相应的局部引用,防止被占着内存空间。就像前面的每次调用 newString 之后,都会遗留两个引用占用空间(elemArray和cls_string,cls_string 不用 static 缓存的情况下)。

  • 不需要返回的 Native 方法,即时释放局部引用。
    如果 Native 方法不会返回,那么自动释放局部引用就失效了,这时候就必须要手动释放。比如, while(tr
    ue) { if (有新的消息) { 处理之。。。。} else { 等待新的消息。。。}} 在某个一直等待的循环中,如果不及时释放局部引用,很快就会溢出了,所以我们必须显示的释放局部引用。

  • 局部引用使用完了就删除,而不是要等到函数结尾才释放。
    局部引用会阻止所引用的对象被 GC 回收。比如你写的一个本地函数中刚开始需要访问一个大对象,因此一开始就创建了一个对这个对象的引用,但在函数返回前会有一个大量的非常复杂的计算过程,而在这个计算过程当中是不需要前面创建的那个大对象的引用的。但是,在计算的过程当中,如果这个大对象的引用还没有被释放的话,会阻止 GC 回收这个对象,内存一直占用者,造成资源的浪费,特别是在内存比较紧张的时候更加。所以这种情况下,在进行复杂计算之前就应该把引用给释放了,以免不必要的资源浪费。

/* 假如这是一个本地方法实现,当然是伪代码 */
JNIEXPORT void JNICALL do_XXX_func(JNIEnv *env, jobject this)
{
	lref = ... /* lref引用的是一个大的Java对象 */
	... /* 在这里已经处理完业务逻辑后,这个对象已经使用完了 */
	(*env)->DeleteLocalRef(env, lref); /* 及时删除这个对这个大对象的引用,GC就可以对它回收,并释放相应的资源*/
	doTimeconsumingoperation(); /* 在里有个比较耗时的计算过程 */
	return; /* 计算完成之后,函数返回之前所有引用都已经释放 */
}

2.2 全局引用

全局引用,顾名思义就是全局的引用,和C/C++中全局变量有点类似。我们可以通过调用NewGlobalRef基于局部引用创建全局引用,释放全局引用前,你可以在多个本地方法调用过程和多线程中使用GlobalRef所引的对象。与局部引用类似,全局引用也能防止对象被GC(garbage collected, 垃圾回收)。全局引用与 局部引用不同的是,局部一般自动创建(返回值为jobject/jclass 等JNI函数),而 全局引用必须通过NewGlobalRef由程序员主动创建,具体的使用我们在JNI/NDK入门指南之JNI多线程回调Java方法也有过介绍。

2.2.1 全局引用的创建

下面我们来通过一个例子来举例说明全局引用的创建,如下:

jobject gJavaObj = NULL;//全局Jobject变量
JNIEXPORT void JNICALL Java_com_pax_api_thread_NativeThread_nativeInit
  (JNIEnv * env, jobject object)
{	..
	gJavaObj = env->NewGlobalRef(object);//创建全局引用
	...
}

2.2.2 全局引用的正确使用

通过JNI/NDK入门指南之JNI多线程回调Java方法我们知道全局引用可以跨本地方法,跨多线程使用,直到它被手动释放才会失效。同局部引用一样,也会阻止它所引用的对象被GC 回收。与局部引用创建方式不同的是,只能通过 NewGlobalRef 函数创建。下面我们将要演示怎么样使用一个全局引用来缓存局部引用即缓存String的class引用,从而达到创建NetString功能的。
JNI本地方法代码:

/*
 * Class:     com_pax_api_references_JNIReferences
 * Method:    accessCacheLocalRefernce
 * Signature: ([C)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_com_pax_api_references_JNIReferences_accessCacheLocalRefernce
  (JNIEnv * env, jobject object, jcharArray j_char_arr)
{
	jcharArray elemArray;
    jchar *chars = NULL;
    jstring object_str = NULL;
    static jclass cls_string = NULL;
    static jmethodID cid_string = NULL;
	jclass local_string = NULL;
    // 这里的cls_string的作用是全局引用,
    if (cls_string == NULL) {
		LOGE(TAG,"cls_string == NULL \n");
        local_string = env->FindClass("java/lang/String");
        if (local_string == NULL) {
            return NULL;
        }
    }else{
		LOGE(TAG,"cls_string has initialize\n");
	}

	//将java.lang.String类的Class引用缓存到全局引用当中
	if(local_string != NULL)
	{
		cls_string = (jclass)env->NewGlobalRef(local_string);
		// 删除局部引用
		env->DeleteLocalRef(local_string);
		//再次验证全局引用是否创建成功
		if(cls_string == NULL)
			return NULL;
	}

    // 缓存String的构造方法ID
    if (cid_string == NULL) {
        cid_string = env->GetMethodID(cls_string, "<init>", "([C)V");
        if (cid_string == NULL) {
            return NULL;
        }
    }

    int len = env->GetArrayLength(j_char_arr);
	LOGE(TAG,"The chars lenght : %d\n", len);
    // 创建一个字符数组
    elemArray = env->NewCharArray(len);
    if (elemArray == NULL) {
        return NULL;
    }
    // 获取数组的指针引用,注意:不能直接将jcharArray作为SetCharArrayRegion函数最后一个参数
    chars = env->GetCharArrayElements(j_char_arr,NULL);
    if (chars == NULL) {
        return NULL;
    }


    // 将Java字符数组中的内容复制指定长度到新的字符数组中
    env->SetCharArrayRegion(elemArray, 0, len, chars);

    // 调用String对象的构造方法,创建一个指定字符数组为内容的String对象
    object_str = (jstring)env->NewObject(cls_string, cid_string, elemArray);


    // 释放本地引用
    env->DeleteLocalRef(elemArray);
    return object_str;
}

上面的代码非常简单,通过 FindClass 返回一个对 java.lang.String 对象的局部引用,然后调用JNI函数以局部引用创建全局引用将其缓存在 cls_string中,这种 做法是正确的。然后我们在Java层调用该Native方法看看会有啥结果!

测试代码:

    private void opeateAccessCacheLocalRefernce(){
        JNIReferences mJniReferences = new JNIReferences();
        String str = mJniReferences.accessCacheLocalRefernce("Hello JNI".toCharArray());
        Log.e("REFERENCES","str : " + str);
        str = mJniReferences.accessCacheLocalRefernce("Hello JNI Again".toCharArray());
        Log.e("REFERENCES","str : " + str);
    }

运行演示:

I/REFERENCES( 5738): The chars lenght : 9
E/REFERENCES( 5738): str : Hello JNI
I/REFERENCES( 5738): The chars lenght : 15
E/REFERENCES( 5738): str : Hello JNI Again

I/REFERENCES( 6036): cls_string == NULL
I/REFERENCES( 6036): The chars lenght : 9
E/REFERENCES( 6036): str : Hello JNI
I/REFERENCES( 6036): cls_string has initialize
I/REFERENCES( 6036): The chars lenght : 15
E/REFERENCES( 6036): str : Hello JNI Again

代码分析:
在第一次调用Native方法accessCacheLocalRefernce返回后,JVM会释放该本地方法并且会释放在这个方法执行期间创建的所有局部引用,也包含对 String 的 Class 局部引用local_string,但是这里我们会以局部引用创建一个全局引用。当再次调用 newString 时,newString 所指向引用的全局引用内存空间依然有效,再访问这个指针的引用时,依然是可以的。这个也在前面章节JNI/NDK入门指南之JNI多线程回调Java方法也有相关的验证。

2.2.3 全局引用的正确释放

每个JNI引用引用被建立时,除了它所指向的 JVM 中对象的引用需要占用一定的内存空间外,引用本身也会消耗掉一个数量的内存空间,全局引用也不例外。作为一个合格的程序猿,我们应该对程序在一个给定的时间段内使用的引用数量要十分小心。短时间内创建大量而没有被立即回收的引用很可能就会导致内存溢出。 当我们的本地代码不再需要一个全局引用时,应该马上调用DeleteGlobalRef 来释放它。如果不手动调用这个函数,即使这个对象已经没用了,JVM 也不会回收这个全局引用所指向的对象。这就是古人所说的有始有终,方得始终啊。


2.3 弱全局引用

弱全局引用,顾名思义就是弱的全局的引用,比全局引用弱点(暂且这么理解吗!)。我们可以通过调用NewWeakGlobalRef基于局部引用创建弱全局引用,你可以在多个本地方法调用过程和多线程中使用弱全局所引的对象,但是该类型的引用不保证不被GC,引用不会自动释放,在 JVM 认为应该回收它的时候(比如内存紧张的时候)进行回收而被释放。弱全局引可以使用DeleteWeakGlobalRef 手动释放。 env->DeleteWeakGlobalRef()。

2.3.1 弱全局引用的创建

下面我们来通过一个例子来举例说明弱全局引用的创建,如下:

JNIEXPORT void JNICALL Java_com_pax_api_references_JNIReferences_accessWeakGlobalRefernce
  (JNIEnv * env, jobject object)
{		...
		jclass local_clazz = env->FindClass("com/pax/api/references/JavaClass");
		...
		gJni_JavaClass = (jclass)env->NewWeakGlobalRef(local_clazz);
		...
}

2.3.2 弱全局引用的正确使用

弱全局引用可以使用NewGlobalWeakRef和DeleteGlobalWeakRef进行创建和释放。并且与全局引用类似,弱引用可以跨方法、线程使用。但与全局引用很重要不同的一点是,弱引用不会阻止 GC 回收它引用的对象。accessCacheLocalRefernce这个函数中,我们也可以使用弱引用来存储 String 的 Class 引用,因为 java.lang.String这个类是系统类,永远不会被 GC 回收。当本地代码中缓存的引用不一定要阻止 GC 回收它所指向的对象时,弱引用就是一个最好的选择。假设现在有这么一个场景,一个本地方法accessWeakGlobalRefernce中需要缓存指向一个类JavaClass的引用,如果在弱引用中缓存的话,仍然允许JavaClass 这个类被卸载,因为弱引用不会阻止 GC 回收所引用的对象。下面来看我们的实现。

Java端JavaClass 的定义:

package com.pax.api.references;

public class JavaClass {
    private int mInt = 0;
    public JavaClass() {
    }
}

JNI端本地方法代码:

JNIEXPORT void JNICALL Java_com_pax_api_references_JNIReferences_accessWeakGlobalRefernce
  (JNIEnv * env, jobject object)
{
	static jclass gJni_JavaClass = NULL;
	
	//判断jni_JavaClass弱全局引用是否有效
	if(gJni_JavaClass == NULL)
	{
		jclass local_clazz = env->FindClass("com/pax/api/references/JavaClass");
		if(local_clazz == NULL)
		{
			LOGE(TAG,"can not find class JavaClass\n");
			return;
		}

		gJni_JavaClass = (jclass)env->NewWeakGlobalRef(local_clazz);
		if(gJni_JavaClass == NULL)
		{
			LOGE(TAG,"out of memory, can not NewWeakGlobalRef\n");
			return;
		}
		env->DeleteLocalRef(local_clazz);
	}else{
		LOGE(TAG,"gJni_JavaClass has init\n");
	}

	//判断gJni_JavaClass是否被GC
	if(env->IsSameObject(gJni_JavaClass, NULL))
		return;
		
	//使用gJni_JavaClass的引用
	jmethodID clazz_construct_method = env->GetMethodID(gJni_JavaClass, "<init>","()V");
	if(NULL == clazz_construct_method)
	{
		LOGE(TAG,"GetMethodID for construct failed\n");
	}

	//在JNI层创建JavaClass类的实例
	jobject object_JavaClass = env->NewObject(gJni_JavaClass, clazz_construct_method);
	if(NULL == object_JavaClass)
	{
		LOGE(TAG,"NewObject failed\n");
	}


	jfieldID mInt_fieldID = env->GetFieldID(gJni_JavaClass, "mInt", "I");
	if(mInt_fieldID == NULL)
	{
		LOGE(TAG,"GetFieldID  mInt failed\n");
		return;			
	}

	//7.获取JNIFieldClass实例变量mInt的值
	jint mInt = env->GetIntField(object_JavaClass, mInt_fieldID);
	LOGE(TAG,"The result : %d\n", mInt);
	env->DeleteLocalRef(object_JavaClass);	
}

Java端测试代码:

    private void operateaccessWeakGlobalRefernce(){
        JNIReferences mJniReferences = new JNIReferences();
        mJniReferences.accessWeakGlobalRefernce();
        mJniReferences.accessWeakGlobalRefernce();
    }

运行演示:

01-12 02:03:16.324  2941  2941 I REFERENCES: The result : 0
01-12 02:03:16.324  2941  2941 I REFERENCES: gJni_JavaClass has init
01-12 02:03:16.324  2941  2941 I REFERENCES: The result : 0

代码分析:
通过打印我们确实看到JavaClass的类引用被缓存了。下面让我们假设一下accessWeakGlobalRefernce和 gJni_JavaClass 有相同的生命周期(例如,他们可能被相同的类加载器加载),因为弱引用的存在,我们不必担心 accessWeakGlobalRefernce和它所在的本地代码在被使用时,gJni_JavaClass 这个类出现先被 卸载,后来又会 加载的情况。当然,如果真的发生这种情况时(accessWeakGlobalRefernce和 gJni_JavaClass 此时的生命周期不同),我们在使用弱引用时,必须先检查缓存过的弱引用是指向活动的类对象,还是指向一个已经被 GC 给 卸载的类对象。下面马上告诉你怎样检查弱引用是否活动,即引用的比较。

2.3.3 引用比较

给定两个引用(不管是全局、局部还是弱全局引用),我们只需要调用 IsSameObject 来判断它们两个是否指向相同的对象。例如: env->IsSameObject(obj1, obj2) ,如果 obj1 和 obj2 指向相同的对象,则返回 JNI_TRUE(或者 1),否则返回 JNI_FALSE(或者 0)。有一个特殊的引用需要注意:NULL,JNI 中的 NULL 引用指向 JVM 中的 null 对象。如果 obj 是一个局部或全局引用,使用 env->IsSameObject(obj, NULL) 或者 obj == NULL 来判断 obj 是否指向一个 null 对象即可。但需要注意的是,IsSameObject 用于弱全局引用与 NULL 比较时,返回值的意义是不同于局部引用和全局引用的,因为此时的弱全局引用可能指向了一个已经被GC的无效对象。

2.3.4 弱全局引用的正确释放

弱全局引用的释放和全局引用类似,但是有一个地方需要特别注意,就是当我们的本地代码不再需要个弱全局引用时,也应该调用 DeleteWeakGlobalRef 来主动释放它,如果不手动调用这个函数来释放所指向的对象,JVM 虚拟机仍会回收弱引用所指向的对象,但弱引用本身在引用表中所占的内存永远也不会被回收,即弱引用在引用表中还在,依然占着茅坑不拉屎(哈哈)。



三. 引用管理

通过前面的章节我们知道,当我们不正确的使用引用的时候造成的后果一般都是程序直接奔溃,不留下一点点眷恋的意思。那么在本章节我将要带领大家怎么用好引用缓存和怎么合理管理好引用。


3.1 正确使用引用缓存

当读者看到这个标题的时候,也许会想作者是不是短路了,前面说对于引用要用完最好立马释放,现在又说正确使用引用缓存。好吗,因为引用的释放是相对的,下面让我们看看我们为啥有时候要利用引用缓存。

3.1.1 为什么要缓存引用?

当我们在本地代码中要访问 Java 对象的字段或调用它们的方法时,本机代码必须调用 FindClass()、GetFieldID()、GetStaticFieldID、GetMethodID()和 GetStaticMethodID()。对于 GetFieldID()、GetStaticFieldID、GetMethodID() 和 GetStaticMethodID(),为特定类返回的 ID 不会在 JVM 进程的生存期内发生变化。但是,获取字段或方法的调用有时会需要在 JVM 中完成大量工作,因为字段和方法可能是从超类中继承而来的,这会让 JVM 向上遍历类层次结构来找到它们。由于 ID 对于特定类是相同的,因此只需要查找一次,然后便可重复使用。同样,查找类对象的开销也很大,因此也应该缓存它们。特别是在对性能要求很高的场景下,在交互频繁的 JNI 应用中是不能忍受的。因此在 本地方法里保存 Class引用和 Method ID ,Field ID引用是很有必要的,并且 Class引用和 Method ID, Field ID 引用在一定范围内是稳定的。所以在 JNI 开发中,合理的使用缓存技术能给程序提高极大的性能。缓存有两种,分别为使用时缓存和类静态初始化时缓存,区别主要在于缓存发生的时刻。

3.1.2 缓存引用的两种方式

下面让我们对使用时缓存和类静态初始化时缓存分别举几个栗子来说明一下,对于普通吃瓜群众来说有图才有真相,而对于我们伟大的程序原来说必须是有代码才真想。

3.1.2.1 使用时缓存

Java端Native方法的定义类:

package com.pax.api.references;

public class JNIReferences {

    private int mInt = 0;//字段ID
    public native void accessCacheField();//使用时缓存
    public native String errCacheLocalRefernce(char[] charArray);
    static {
        System.loadLibrary("references");
    }
}

JNI端本地方法代码:

JNIEXPORT void JNICALL Java_com_pax_api_references_JNIReferences_accessCacheField
  (JNIEnv * env, jobject object)
{
	/******进程第一次访问时将变量字段ID存到内存数据区,直到程序进程结束才会释放,可以起到缓存的作用*********/
	static jfieldID mField_int = NULL;
	jclass clazz_JNIReferences = NULL;


	//调用JNI函数GetObjectClass获取JNIReferences类引用
	clazz_JNIReferences = env->GetObjectClass(object);
	if(clazz_JNIReferences == NULL)
	{
		LOGE(TAG,"GetObjectClass failed\n");
		return;
	}

	//使用之前判断变量字段ID值mField_int是否已经缓存过,如果已经缓存过则直接使用
	if(mField_int == NULL)
	{
		//查找变量ID
		mField_int = env->GetFieldID(clazz_JNIReferences, "mInt", "I");

		//判断是否存在该变量ID
		if(mField_int == NULL)
			return;
		
	}else{
		LOGE(TAG, "mField_int has init\n");
	}

	//获取字段变量的值
	int field_result = env->GetIntField(object, mField_int);
	LOGE(TAG,"The field_result : %d\n", field_result);

	//修改字段变量的值
	env->SetIntField(object, mField_int, 10);

	//重新获取字段变量的值
	field_result = env->GetIntField(object, mField_int);
	LOGE(TAG,"The field_result : %d\n", field_result);

	//释放本地引用
	env->DeleteLocalRef(clazz_JNIReferences);		
}

JNIEXPORT jstring JNICALL Java_com_pax_api_references_JNIReferences_errCacheLocalRefernce
  (JNIEnv * env, jobject object, jcharArray j_char_arr)

{
	jcharArray elemArray;
    jchar *chars = NULL;
    jstring object_str = NULL;
    static jclass cls_string = NULL;
    static jmethodID cid_string = NULL;
    // 注意:这里缓存局部引用的做法是错误,这里做为一个反面教材提醒大家,下面会说到。
    if (cls_string == NULL) {
        cls_string = env->FindClass("java/lang/String");
        if (cls_string == NULL) {
            return NULL;
        }
    }else{
		LOGE(TAG,"cls_string has initialize\n");
	}

    // 缓存String的构造方法ID
    if (cid_string == NULL) {
        cid_string = env->GetMethodID(cls_string, "<init>", "([C)V");
        if (cid_string == NULL) {
            return NULL;
        }
    }

    int len = env->GetArrayLength(j_char_arr);
	LOGE(TAG,"The chars lenght : %d\n", len);
    // 创建一个字符数组
    elemArray = env->NewCharArray(len);
    if (elemArray == NULL) {
        return NULL;
    }
    // 获取数组的指针引用,注意:不能直接将jcharArray作为SetCharArrayRegion函数最后一个参数
    chars = env->GetCharArrayElements(j_char_arr,NULL);
    if (chars == NULL) {
        return NULL;
    }


    // 将Java字符数组中的内容复制指定长度到新的字符数组中
    env->SetCharArrayRegion(elemArray, 0, len, chars);

    // 调用String对象的构造方法,创建一个指定字符数组为内容的String对象
    object_str = (jstring)env->NewObject(cls_string, cid_string, elemArray);


    // 释放本地引用
    env->DeleteLocalRef(elemArray);
    return object_str;
}

Java端测试代码:

    private void operateaccessCacheField(){
        JNIReferences mJniReferences = new JNIReferences();
        mJniReferences.accessCacheField();
        mJniReferences.accessCacheField();
    }

运行演示:

I/REFERENCES(11455): The field_result : 0
I/REFERENCES(11455): The field_result : 10
I/REFERENCES(11455): mField_int has init
I/REFERENCES(11455): The field_result : 10
I/REFERENCES(11455): The field_result : 10

代码分析:

  • 通过打印看到确实引用被缓存起来,并且程序也运行OK了。关键在于利用了C中static关键字在第一次访问时将字段ID存到内存数据区,直到程序进程结束才会释放,并且字段ID的值在程序运行过程中但是不变的,这样最终才起到了缓存的作用。
  • 而errCacheLocalRefernce通过前面的章节我们知道,缓存cls_string是一个错误的示例,这是为什么呢,为啥同样的方法结局是不同的呢。因为cls_string与方法和字段ID不一样,局部引用在函数结束后会被VM自动释放掉,这时cls_string成为了一个野针(指向的内存空间已被释放,但变量的值仍然是被释放后的内存地址,此时cls_string的值不为NULL,只是此时原来的它已经不是那个它了),当下次再调用Java_com_pax_api_references_JNIReferences_errCacheLocalRefernce这个函数的时候,会试图访问一个无效的局部引用,从而导致非法的内存访问造成程序崩溃。所以在函数内用static缓存局部引用这种方式是错误的。
3.1.2.2 类静态初始化时缓存

Java端Native方法的定义类:
在调用一个类的方法或属性之前,Java 虚拟机会先检查该类是否已经加载到内存当中,如果没有则会先加载,然后紧接着会调用该类的静态初始化代码块,所以在静态初始化该类的过程当中计算并缓存该类当中的字段 ID 和方法 ID 也是个不错的选择。此时可以在JNI加载so库的JNI_OnLoad里面初始化,或者直接通新增一个本地方法也行(在Android Framework通常采用这种方法),这个要根据具体的场景,这里为了演示我们两种方法都使用一下,这里仅仅是演示实际中只需要一种即可。

package com.pax.api.references;
import android.util.Log;
public class JNIReferences {
    private int mInt = 0;//字段ID  
    private void nativeCallBack(){
        Log.e("REFERENCES","Hello Im form JNI");
    }
    
    public native void accessCacheJavaClass();    
    public native static void nativeInit();//初始化
    static {
        System.loadLibrary("references");
        nativeInit();
    }
}

JNI端本地方法代码:

typedef struct{
	jfieldID mInt;//字段ID值
	jmethodID callBack;//方法ID
}JNIReferences_t;

static JNIReferences_t mJNIReferences_t;

JNIEXPORT void JNICALL Java_com_pax_api_references_JNIReferences_accessCacheJavaClass
  (JNIEnv * env, jobject object)
{

	//获取变量值
	int mInt_result = env->GetIntField(object, mJNIReferences_t.mInt);
	LOGE(TAG,"mInt_result : %d\n", mInt_result);

	//调用方法
	env->CallVoidMethod(object, mJNIReferences_t.callBack);
}

static void register_JNIReferences_class(JNIEnv * env)
{
	jclass clazz_JNIReferences = NULL;
	//查找类引用
	clazz_JNIReferences = env->FindClass("com/pax/api/references/JNIReferences");
	if(clazz_JNIReferences == NULL)
	{
		LOGE(TAG,"FindClass failed\n");
		return;
	}

	//获取FiledID
	mJNIReferences_t.mInt = env->GetFieldID(clazz_JNIReferences, "mInt", "I");
	if(mJNIReferences_t.mInt == NULL)
	{
		LOGE(TAG,"GetFieldID failed\n");
		return;
	}

	//获取方法MethoID
	mJNIReferences_t.callBack = env->GetMethodID(clazz_JNIReferences, "nativeCallBack", "()V");
	if(mJNIReferences_t.callBack == NULL){
		LOGE(TAG,"GetMethodID failed\n");
		return;
	}


	//删除局部引用
	env->DeleteLocalRef(clazz_JNIReferences);
}


/*
 * Class:     com_pax_api_references_JNIReferences
 * Method:    nativeInit
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_com_pax_api_references_JNIReferences_nativeInit
  (JNIEnv * env, jclass clazz)
{
	LOGE(TAG,"Java_com_pax_api_references_JNIReferences_nativeInit\n");
	register_JNIReferences_class(env);
}


//SO动态库加载一定会在该流程

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
	LOGE(TAG,"JNI_OnLoad\n");
	JNIEnv* env = NULL;	
	//获取JNI_VERSION版本
	if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
		LOGE(TAG,"checkversion error\n");
		return -1;
	}

	register_JNIReferences_class(env);//


	//返回jni 的版本
	return JNI_VERSION_1_6;
}

Java测试代码:

    private void operateaccessCacheJavaClass(){
        JNIReferences mJniReferences = new JNIReferences();
        mJniReferences.accessCacheJavaClass();
        mJniReferences.accessCacheJavaClass();
    }

运行演示:

I/REFERENCES( 5672): JNI_OnLoad
I/REFERENCES( 5672): Java_com_pax_api_references_JNIReferences_nativeInit
I/REFERENCES( 5672): mInt_result : 0
E/REFERENCES( 5672): Hello Im form JNI
I/REFERENCES( 5672): mInt_result : 0
E/REFERENCES( 5672): Hello Im form JNI

代码分析:
这里有一点要注意,如果要在静态方法中加载本地方法,一定要在 System.loadLibrary之后,不然会报如下错误信息。

E/AndroidRuntime( 6194): java.lang.UnsatisfiedLinkError: No implementation found for void com.pax.api.references.JNIReferences.nativeInit() (tried Java_com_pax_api_references_JNIReferences_nativeInit and Java_com_pax_api_references_JNIReferences_nativeInit__)
E/AndroidRuntime( 6194):        at com.pax.api.references.JNIReferences.nativeInit(Native Method)
E/AndroidRuntime( 6194):        at com.pax.api.references.JNIReferences.<clinit>(JNIReferences.java:26)
E/AndroidRuntime( 6194):        at com.pax.jni.MainActivity.operateaccessCacheJavaClass(MainActivity.java:187)
E/AndroidRuntime( 6194):        at com.pax.jni.MainActivity.onCreate(MainActivity.java:39)
E/AndroidRuntime( 6194):        at android.app.Activity.performCreate(Activity.java:6033)
E/AndroidRuntime( 6194):        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1106)
E/AndroidRuntime( 6194):        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2279)
E/AndroidRuntime( 6194):        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2388)
E/AndroidRuntime( 6194):        at android.app.ActivityThread.access$800(ActivityThread.java:152)
E/AndroidRuntime( 6194):        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1304)
E/AndroidRuntime( 6194):        at android.os.Handler.dispatchMessage(Handler.java:102)
E/AndroidRuntime( 6194):        at android.os.Looper.loop(Looper.java:135)
E/AndroidRuntime( 6194):        at android.app.ActivityThread.main(ActivityThread.java:5259)
E/AndroidRuntime( 6194):        at java.lang.reflect.Method.invoke(Native Method)
E/AndroidRuntime( 6194):        at java.lang.reflect.Method.invoke(Method.java:372)
E/AndroidRuntime( 6194):        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:902)
E/AndroidRuntime( 6194):        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:697)

当JVM虚拟机 加载 JNIReferences.class 到内存当中之后,会调用该类的静态初始化代码块,即 static 代码块,先调用System.loadLibrary 加载动态库references到 JVM 中,紧接着调用 native 方法 nativeInit,会调用用到本地函数Java_com_pax_api_references_JNIReferences_nativeInit,在该函数中获取需要缓存的 ID,然后存入全局变量当中。下次需要用到这些 ID 的时候,直接使用全局变量当中的即可,调用 Java 的 nativeCallBack函数和字段ID。

3.1.3 两种引用缓存的比较

好了前面对两种引用缓存有了十分详细的介绍了,那么让我们总结一下这两种引用的使用场景和优缺点:

  • 使用时缓存使用前,每次都需要检查是否已经缓存该 ID 或 Class 引用,十分繁琐。
  • 类静态初始化时缓存,只需要在类被加载时候缓存一下,以后不必每次都检查和重复同样代码,这个是JNI规范中推荐,并且也是Android Framework也经常使用的。

3.2 管理引用需谨记

通过前面的篇章,我想读者对引用的使用,管理,缓存都有一个比较清晰的认识了。下面让我们总结一些关于引用管理方面的知识点,可以减少内存的使用和避免因为对象被引用不能释放而造成的内存浪费,甚至是错误使用引用而造成的一些系统奔溃。
通过前面的篇章我们知道,Native本地方法通常存在如下两种使用场景:

  • Java 的Native函数在本地方法的实现。对于这类本地方法我们要特别注意,不要造成全局引用和弱全局引用的累加,因为它们在函数返回后并不会自动释放。这里当然不是说对于局部引用可以任之不管。
  • 被用在任何环境下的工具函数。例如:方法调用、属性访问和异常处理的工具函数等。

所以在编写工具函数的本地代码时,要当心不要在函数的调用轨迹上遗漏任何的局部引用,因为工具函数被调用的场合和次数是不确定的,一量被大量调用,就很有可能造成内存溢出。所以在编写工具函数时,请遵守下面的规则:

  • 一个返回值为基本类型的工具函数被调用时,它决不能造成局部、全局、弱全局引用被回收的累加。
  • 当一个返回值为引用类型的工具函数被调用时,它除了返回的引用以外,它决不能造成其它局部、全局、弱引用的累加。


总结思考

通过前面的一个坑一个脚印的摸索,读者应该对引用烂熟于心了。老规矩还是总结一下,对于在JNI引用应该注意哪些地方:

  • 对于局部引用是不可以缓存起来,跨线程使用的,这个切记也是JNI面试中经常考到的
  • 对于弱全局引用,当不使用的时候应主动释放,防止虽然被GC单依然占据引用表,且在使用中也要判断是否被GC
  • 对于全局引用,我只能说是个不错的引用,但是也不能乱用


写在最后

  在最后麻烦读者朋友们,如果本篇对你有帮助,请关注和点赞一下,当然如果有错误和不足的地方也可以拍砖。

参阅
https://blog.csdn.net/xyang81/article/details/44657385

发布了89 篇原创文章 · 获赞 92 · 访问量 31万+

猜你喜欢

转载自blog.csdn.net/tkwxty/article/details/103823816