目录

JVMTI 的运用

文档

https://source.android.com/docs/core/runtime/art-ti?hl=zh-cn

/img/in-post/artti.png

JVMTI 可以做什么

一些重要的功能包括:

  1. 重新定义类。
  2. 跟踪对象分配和垃圾回收过程。
  3. 遵循对象的引用树,遍历堆中的所有对象。
  4. 检查 Java 调用堆栈。
  5. 暂停(和恢复)所有线程。

其实远远不止,详情查看文档 https://docs.oracle.com/javase/8/docs/platform/jvmti/jvmti.html#architecture

一个 Lock 检测的例子

大部分借鉴于这两篇文章,当然这个 lib 监听了很多东西,但是其实都是对于 jvmti 的一些方法的运用而已。

https://github.com/zkwlx/ADI/tree/master/adi_lib

https://juejin.cn/post/6942782366993612813

最终的代码和 lib 中的略有不同,不同点在于最终数据的可视化、写入文件格式和 release 开启的 hook 方式

步骤大致如下:

  1. 需要手动调用 Debug.attachJvmtiAgent,传递一个我们使用 jvmti.h 开发的 native so 路径

    注意:只能再 Debug 模式下开启,且 so 需要 copy 到应用目录下,使用绝对路径加载,否则加载不成功

    native 层需要通过 Agent_OnAttach 获取 jvmtiEnv

    Agent_OnAttach(JavaVM *vm, char *options, void *reserved) {
        jint result = vm->GetEnv((void **) &sJVMTIEnv, JVMTI_VERSION_1_2);
        if (result != JNI_OK) {
            LOGI("create jvmti error!");
            return result;
        }
        jvmtiCapabilities caps;
        sJVMTIEnv->GetPotentialCapabilities(&caps);
        sJVMTIEnv->AddCapabilities(&caps);
        return JNI_OK;
    }
    
  2. 注册对应回调函数,开启对应的事件监听

    // 注册回调
    jvmtiEnvSentEventCallbacks
    // 锁相关回调为 MonitorContendedEnter 和 MonitorContendedEntered
    jvmtiEnvSetEventNotificationMode
    

    最终回调注册相关的代码

    jvmtiEventCallbacks callbacks;
    memset(&callbacks, 0, sizeof(callbacks));
    callbacks.MonitorContendedEnter = &onLockEnter;
    callbacks.MonitorContendedEntered = &onLockEntered;
    sJVMTIEnv->SetEventCallbacks(&callbacks, sizeof(callbacks));
    // 最终的线程参数传递 null,表明监听所有线程
    sJVMTIEnv->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTER, nullptr);
    sJVMTIEnv->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED,
                                        nullptr);
    

    onLockEnter 和 onLockEntered 为我们自己的方法,这样当有 lock 事件发生时,便会回调这两个对应方法

  3. 通过 jvmti 接口获取各种信息和各类其他信息,如堆栈信息(可以查阅 jvmti interface 文档)
    这里其实没什么好说的,就是看文档,或者看别人怎么写的,用于在 onLockEnter 和 onLockEntered 中,获取线程信息、锁对象信息、调用堆栈等等。
    具体可以看上面提到的开源的 adi_lib

  4. 自己的逻辑处理
    到了这一步就是 onLockEnter 和 onLockEntered 这两个方法的具体实现了。监听 lock 我这里的思路是,写入 atrace 格式的日志到文件中。这样最终可以通过 perftto 可视化出一个调用堆栈。

  5. 数据可视化,通过 python 脚本转换为 html,perftto 可以查阅
    这里其实是对于第四步的日志文件的再加工

可视化

使用 atrace 格式写入,再进一步处理既可以得到类似可视化堆栈信息:

/img/in-post/perftto_preview.jpg

当然,这也会有一个问题:

多线程情况下,可能一个线程在某短时间持有锁A并且等待锁B,所以可能会有堆栈交叉的问题。

所以目前最终的脚本是以某个线程为主视角,当这个线程出现了锁等待时,获取这个锁对象被哪个线程持有,而不去获取是否还有其他线程在等待,也不管持有锁的线程的相关信息

Release 开启 JVMTI 的细节

在上面提到的文章中,作者找到了整个判定是否可以运行 jvmti 的各个函数签名

_ZN3art7Runtime17SetJavaDebuggableEb
_ZN3art3Dbg14SetJdwpAllowedEb

他使用了 inlinehook 的方式实现,并且表示其实突破 android 高版本的 dlopen 的限制其实就可以了

因为其原理就是想调用一些 libart.so 的私有函数,去改变一些对于 debug/release 的判断逻辑的值。

那么如何调用一个 so 的函数呢?肯定是需要 dlopen 和 dlsym 获取到对应的函数入口地址,然后执行对应方法。

这里可以使用 xdl 实现对应功能。

核心问题有3个:

  1. libart.so 的绝对路径
    可以使用 xdl_iterate_phdr 获取
  2. 获取对应两个方法的函数指针
    xdl_open + xdl_sym
  3. setJavaDebuggable 的 runtime 参数
    这里没有细看和 java 层的 runtime 之间的关系,如果一致,那么其实可以反调 java 获取,我这里直接找到了对应的方法签名 _ZN3art7Runtime9instance_E,直接调用获取

代码大致如下(需要依赖 XDL):

// dl_iterate_callback 中可以通过 dl_phdr_info.name 拿到绝对路径,赋值给 libart 即可
xdl_iterate_phdr(dl_iterate_callback, nullptr, XDL_FULL_PATHNAME);
void *dl = xdl_open(libart, XDL_ALWAYS_FORCE_LOAD);
if (dl != nullptr) {
    auto (*SetJdwpAllowed)(bool) = reinterpret_cast<void (*)(bool)>(xdl_sym(dl,
                                                                            "_ZN3art3Dbg14SetJdwpAllowedEb",
                                                                            nullptr));
    if (SetJdwpAllowed != nullptr) {
        SetJdwpAllowed(true);
    } else {
        LOGE("SetJdwpAllowed failed!");
    }
    auto (*setJavaDebuggable)(void *, bool) = reinterpret_cast<void (*)(void *, bool)>(xdl_sym(dl,
                                                                            "_ZN3art7Runtime17SetJavaDebuggableEb",
                                                                            nullptr));
    void **instance_ = static_cast<void **>(xdl_sym(dl, "_ZN3art7Runtime9instance_E", nullptr));
    if (setJavaDebuggable != nullptr) {
        setJavaDebuggable(*instance_, true);
    }else {
        LOGE("setJavaDebuggable failed!");
    }
    xdl_close(dl);
}else {
    LOGE("open libart.so failed!");
}

这样就可以在 release 模式下开启 jvmti 了!但是不要搞到线上去,因为 jvmti 还是有一些性能损耗的!可以用于线下的测试

Atrace 文件格式细节

如何将我们记录到的锁相关的堆栈信息可视化呢?

我这里是使用 atrace 格式来展示,上面也有具体的展示图。

atrace 是一种什么格式呢?

在 systrace 还是 perfetto 的代码中有如下正则匹配(忘记了从哪里找到的了):

s*(.+)-(\d+)\s+\(\s*(\d+|-+)\)\s\[(\d+)\]\s+[dX.][Nnp.][Hhs.][0-9a-f.]\s+(\d+\.\d+):\s+(\S+):\s(.*)$

再来看看标准的一个 atrace

com.xxx.xxx-9702  ( 9702) [001] ...1 1743497.015596: tracing_mark_write: B|9702|Intrinsics:checkParameterIsNotNull
com.xxx.xxx-9702  ( 9702) [001] ...1 1743497.015612: tracing_mark_write: E|9702|Intrinsics:checkParameterIsNotNull

刚好对得上这个正则匹配

所以我们只需要凑出一个这种格式,然后将获取到的调用栈,展开成 B| funcA, E| funcB 的样式即可

B 表示一个方法开始,E 表示一个方法结束,中间的六位小数的数值是一个时间数值,[001] …1 tracing_mark_write 等值原封不动即可

然后我们只需要找到一个模板 html 文件,将其中间的 atrace 内容替换为我们抓到的 lock 堆栈内容即可

详见

https://github.com/PTrainbow/perftto_template/blob/main/template.html

从 10364行 开始,插入你的标准的 atrace 格式的日志即可

至于怎么将堆栈转换为 atrace 格式,仁者见仁了,我是通过简单的不到100行的 python 代码就实现了