基于JvmTI的动态内存释放

1,142 阅读10分钟

介绍

内存泄漏是目前开发过程中非常头疼的问题,对于新功能代码,如果出现内存泄漏那可以通过重构代码来解决,但是对于老旧代码的内存泄漏处理起来却是非常棘手的。本文是作者关于如何使用JvmTi来处理老旧代码中出现的内存泄漏的一些思考和尝试实现介绍。

JvmTi简介

JvmTi(JVM Tool Interface):Java 虚拟机所提供的 native 编程接口。是Java虚拟机提供的一整套后门。通过这套后门可以对虚拟机方方面面进行监控,分析。甚至干预虚拟机的运行。

JvmTi 本质上是在JVM内部的许多事件进行了埋点。通过这些埋点可以给外部提供当前上下文的一些信息。甚至可以接受外部的命令来改变下一步的动作。外部程序一般利用C/C++实现一个JvmTiAgent,在Agent里面注册一些JVM事件的回调。当事件发生时JVMTI调用这些回调方法。Agent可以在回调方法里面实现自己的逻辑。JvmTiAgent是以动态链接库的形式被虚拟机加载的。

JvmTi 接口提供的监控及控制能力

提供的控制函数
功能函数名介绍
分配内存Allocate函数Allocate通过JVMTI的内存分配器分配一块内存区域,通过该函数分配的内存,需要通过函数Deallocate释放掉。
释放分配的内存Deallocate该函数通过JVMTI的内存分配器释放由参数mem指向的内存区域,特别的,应该专用于由JVMTI函数分配的内存区域。分配的内存都应该被释放掉,避免内存泄漏。
线程相关函数名介绍
获取线程状态GetThreadState返回(JVMTI_THREAD_STATE_ALIVEJVMTI_THREAD_STATE_RUNNABLE)等状态
获取当前线程GetCurrentThread该函数用于获取当前线程对象,这里获取的在Java代码中调用该函数时所在的线程。
获取所有的线程GetAllThreads用于获取所有存活的线程,注意,这里所说的是Java的线程,即所有连接到JVM的线程
暂停线程SuspendThread暂定目标线程。如果指定了目标线程,则会阻塞当前函数,直到其他线程对目标线程调用了函数ResumeThread。如果要暂停的是当前线程,则该函数啥也不干,返回错误
...
stack frame函数名介绍
获取某个线程当前的调用栈GetStackTrace该函数用于获取目标线程的栈信息。调用该函数时,无需挂起目标线程。
获取所有线程的调用栈GetAllStackTraces该函数用于获取所有存活线程的栈信息(包括JVMTI代理线程)。
弹出栈帧PopFrame该函数用于弹出线程栈帧。弹出顶层栈帧后,会将程序回到前一个栈帧。当线程恢复运行后,线程的执行状态会被置为调用当前方法之前的状态。
...
类属性相关函数
功能函数名介绍
设置/获取目标对象的标签SetTag/GetTag标签的值是一个长整型,一般用于存储一个唯一的ID值或是指向对象信息的指针。
获取指定对象的哈希值GetObjectHashCode哈希值可用于维护对象引用的哈希表,但是在某些JVM实现中,这可能会导致较大性能损耗,在大多数场景下,使用对象标签值来关联相应的数据是一个更有效率的方法。该函数保证了,对象的哈希值会在对象的整个生命周期内有效。
遍历堆中所有指定类的实例对象IterateOverInstancesOfClass直接继承和间接继承的,包括可达和不可达的。
获取局部变量GetLocalObject/SetLocalObject该函数用于获取/设置Object类型或其子类型的局部变量。
...

Android中使用

Android的Dalvik虚拟机或者是Art虚拟机都是基于Java虚拟机的,可是不幸的是Android系统一直并未提供类似的"后门",直到8.0系统开始才实现了 JvmTi 1.2,不过目前看来Android8.0以上手机应该占绝大部分市场。

在Android中 JvmTi 又被称为 ArtTi ,它增加了一些限制如(摘自官网说明):

首先,提供代理接口 JVMTI 的代码作为运行时插件(而不是运行时的核心组件)来实现。插件加载可能会受到限制,这样可阻止代理找到任何接口点, 其次,ActivityManager 类和运行时进程只允许代理连接到可调试的应用。可调试应用由其开发者签核,以供分析和插桩,而不会分发给最终用户。Google Play 商店不允许发布可调试应用。这可确保普通应用(包括核心组件)无法遭到检测或操纵。

Android 中 JVMTI 和Agent的架构如图

flow-intercon-app.jpg

在Android中 使用JVMTI有两种方式

  1. 虚拟机启动时连接代理
  2. 运行时。将代理加载到当前进程中

基于JVMTI 实现性能监控中提出了如在debug中使用这里不做过多叙述。像上述作者所说的一样,由于jvmTi的强大,我们可以做的事太多,例如:

  • 基于获取所有线程堆栈的能力,使用采样的方式 生成函数调用火焰图
  • 基于 对象内存分配、释放函数实现 内存监控
  • 基于 MonitorContended 实现 锁等待监控
  • 基于 GarbageCollection 实现GC时长的监控

下面就介绍一下关于如何借助jvmTi来动态释放泄漏对象。

实现原理

在之前的文章中有介绍过目前主流的两个内存泄露监控框架,利用监控开源库,我们很容易得到内存泄露对象的引用链,不过能做的只有:修改已有代码,防止内存泄露。但是从上文中得知jvmti可以获取虚拟机中已有对象,并可以修改对象属性,那么我们是就可以动态的修改泄露引用链来释放无法释放的对象。

解析泄露引用链

不管是LeakCannary还是Koom都是可以通过解析hprof文件得到泄露引用链。如下图:

企业微信截图_54afff62-3167-42e6-9022-71c35527ac78.png

从引用链中可以得到被泄露对象ClassName,在虚拟机中,同一时刻,同一ClassName的对象可能有多个,要处理泄露对象,必须要准确的选中被泄露对象。才能保证其他对象不会被误伤。

获取虚拟机中泄露对象

在Java中,判断两个对象是否相等,我们可以通过hashcode值来比较,但是由于object中HashCode()方法可能存在被重写的可能,这样造成两个不同的对象具有相等hashcode值。所以hashcode()方法获取hashcode值来比较对象是不可取的。

但是在java虚拟机中,两个不同对象具有的hashCode值是绝对的不相等,因此可以通过获取虚拟机中对象的hashcode来确保对象的唯一性。

JvmTi提供了IterateOverInstancesOfClass()可以获取到某个ClassName当前在虚拟机中存在的所有实例对象。这样我们就可以通过遍历获取到的实例对象,并对比hashcode值(虚拟机中的hashCode值)来获取那个已经泄露的对象。


extern "C" JNIEXPORT jobjectArray findInstancesByClassImpl(JNIEnv *env,
                                                           jclass clazz, jclass clazz1) {
    if (!jvmtiInit || env == nullptr)
        return nullptr;

    jclass loadedObject = env->FindClass("java/lang/Object");
    localJvmtiEnv->IterateOverInstancesOfClass(clazz1, JVMTI_HEAP_OBJECT_EITHER, iterateMarkTag, 0);
    jint countObjs = 0;
    jobject *objects;
    jlong *tagResults;
    jlong idToQuery = 1;

    localJvmtiEnv->GetObjectsWithTags(iterateTag, &idToQuery, &countObjs, &objects, &tagResults);
    jobjectArray arrayReturn = env->NewObjectArray(countObjs, loadedObject, 0);

    for (int i = 0; i < countObjs; ++i) {
        env->SetObjectArrayElement(arrayReturn, i, objects[i]);
    }
    localJvmtiEnv->IterateOverInstancesOfClass(clazz1, JVMTI_HEAP_OBJECT_TAGGED, iterateCleanTag, 0);
    deallocate(tagResults);
    deallocate(objects);

    return arrayReturn;
}

HashCode的记录

上文中提到通过比较hashcode值来判断两个对象是否相等,所以我们可以利用LeakCanary来记录泄漏对象的hashcode值。

LeakCannary判断对象泄漏的原理:LeakCannary在Activity/Fragment的生命周期结束时(OnDestroy方法执行)会将当前对象用WeakReference包装,这样在虚拟机GC的时候,会在ReferenceQueue中通过poll方法找到当前对象,也就意味这当前对象没有出现内存泄漏。如果不存在当前对象,那就意味这当前对象可能存在泄漏,在延迟10s后手动触发GC,还是没有获得当前对象的释放后,就认为此对象泄漏。

在LeakCannary中记录hashcode值也是虚拟机分配个对象的值,而不是object的Hashcode()方法获取的值。

SetTag替换HashCode

在JvmTi文档中提到,当我们获取某个对象虚拟机的hashcode值时是一件比较耗时的操作,而且对于hashcode值也容易引起误解,因此,我们可以将上文中记录hashcode值的时机,换成给对象SetTag,这样可以起到同样的作用。

extern "C" JNIEXPORT void JNICALL setTagImpl(JNIEnv *env,
                                             jclass clazz, jobject obj) {
    if (env == nullptr || localJvmtiEnv==nullptr)
        return;
    jint hashcode;
    // 此处给对象设置tag,使用hashcode值作为对象的tag,实则需要将java层面定义的tag值传递到native层
    localJvmtiEnv->GetObjectHashCode(obj, &hashcode);
    localJvmtiEnv->SetTag(obj, hashcode);

    jclass cls = env->FindClass("java/lang/Object");
    jmethodID mid_getName = env->GetMethodID(cls, "toString", "()Ljava/lang/String;");
    jstring name = static_cast<jstring>(env->CallObjectMethod(obj, mid_getName));
    const char *toString = env->GetStringUTFChars(name, JNI_FALSE);

    ALOGI("obj:{%s} set tag:{%d} success", toString, hashcode);
}

Tag的值需要开发人员来保证唯一性。

修改持有泄露对象引用指向

现在,假设泄露对象为A,持有A引用的对象为B。通过上文的引用链和对象A的Tag值。就可以找到持有当前泄漏对象A,那么也可以很容易找到对象B。那么释放泄露对象A最简单的方法就是将对象B指向对象A的引用设置为NULL:

extern "C" JNIEXPORT void
setObjectFieldValueImpl(JNIEnv *env, jclass thisClass, jobject obj, jobject field,
                        jclass fieldClass,
                        jboolean field_type, jobject newValue) {
    if (env == nullptr)
        return;

    jfieldID jfieldId = env->FromReflectedField(field);
    if (field_type)
        _setStaticFieldValue(env, env->GetObjectClass(obj), fieldClass, jfieldId, newValue);
    else
        _setFieldValue(env, obj, fieldClass, jfieldId, newValue); 
}

此处的newVlaue == null,但是有一些情况下并不能这么简单处理。比如老旧代码中已知的内存泄露,重构代码代价特别大,但是此对象又持有较大对象,如Bitmap\Handler等,为了节省内存空间,可以释泄露对象内部部分的持有。例如Activity的释放可分为以下几个等级:

  • NO_WINDOW : 没有window,当前Activity只持有Context。
  • NO_VIEW : 没有View, 当前Activity没有DecorView。
  • NO_CONTENT_VIEW : 没有ContentView, 当前Activity没有用户自定的View。
  • CUSTOM : 释放此Activity中的自定对象。

释放Activity中所有View的时候可以借助Activity的DecorView持有的ViewRootImpl,在ViewRootImpl中有持有一个Handler,当这个Handler执行sendEmptyMessage(3)时,会执行ViewRootImpl的doDie()方法,这个方法可以安全的移除所有View。

总结

使用JvmTi可以做的事情肯定不仅限于此,上述对于内存泄露的动态释放只是一个初步的尝试,最多算是一个demo,其中有很多不成熟的想法后期慢慢可以完善,要想用于工程肯定还有很长的路要走。希望有兴趣的小伙伴可以多多尝试和留言讨论!

参考文档

JvmTi Hepler

JvmTi oracle 官方文档

JvmTi开发文档

ART Ti

基于JVMTI 实现性能监控