JVMTI 的运用
文档
https://source.android.com/docs/core/runtime/art-ti?hl=zh-cn
JVMTI 可以做什么
一些重要的功能包括:
- 重新定义类。
- 跟踪对象分配和垃圾回收过程。
- 遵循对象的引用树,遍历堆中的所有对象。
- 检查 Java 调用堆栈。
- 暂停(和恢复)所有线程。
其实远远不止,详情查看文档 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 方式
步骤大致如下:
-
需要手动调用 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; }
-
注册对应回调函数,开启对应的事件监听
// 注册回调 jvmtiEnv→SentEventCallbacks // 锁相关回调为 MonitorContendedEnter 和 MonitorContendedEntered jvmtiEnv→SetEventNotificationMode
最终回调注册相关的代码
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 事件发生时,便会回调这两个对应方法
-
通过 jvmti 接口获取各种信息和各类其他信息,如堆栈信息(可以查阅 jvmti interface 文档)
这里其实没什么好说的,就是看文档,或者看别人怎么写的,用于在 onLockEnter 和 onLockEntered 中,获取线程信息、锁对象信息、调用堆栈等等。
具体可以看上面提到的开源的 adi_lib -
自己的逻辑处理
到了这一步就是 onLockEnter 和 onLockEntered 这两个方法的具体实现了。监听 lock 我这里的思路是,写入atrace
格式的日志到文件中。这样最终可以通过 perftto 可视化出一个调用堆栈。 -
数据可视化,通过 python 脚本转换为 html,perftto 可以查阅
这里其实是对于第四步的日志文件的再加工
可视化
使用 atrace 格式写入,再进一步处理既可以得到类似可视化堆栈信息:
当然,这也会有一个问题:
多线程情况下,可能一个线程在某短时间持有锁A并且等待锁B,所以可能会有堆栈交叉的问题。
所以目前最终的脚本是以某个线程为主视角,当这个线程出现了锁等待时,获取这个锁对象被哪个线程持有,而不去获取是否还有其他线程在等待,也不管持有锁的线程的相关信息
Release 开启 JVMTI 的细节
在上面提到的文章中,作者找到了整个判定是否可以运行 jvmti 的各个函数签名
_ZN3art7Runtime17SetJavaDebuggableEb
_ZN3art3Dbg14SetJdwpAllowedEb
他使用了 inlinehook 的方式实现,并且表示其实突破 android 高版本的 dlopen 的限制其实就可以了
因为其原理就是想调用一些 libart.so 的私有函数,去改变一些对于 debug/release 的判断逻辑的值。
那么如何调用一个 so 的函数呢?肯定是需要 dlopen 和 dlsym 获取到对应的函数入口地址,然后执行对应方法。
这里可以使用 xdl 实现对应功能。
核心问题有3个:
- libart.so 的绝对路径
可以使用 xdl_iterate_phdr 获取 - 获取对应两个方法的函数指针
xdl_open + xdl_sym - 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 代码就实现了