基于 si-optimize 模块的 GC 对象回收监控实践,总结 Android 上使用 JVMTI 的完整接入流程和踩坑记录。
一、背景
JVMTI(Java Virtual Machine Tool Interface)是 JVM 提供的调试接口,Android ART 从 API 26(Android 8.0) 开始支持。可用于监控对象分配/回收、方法调用、线程事件等。
限制: 仅在 android:debuggable="true" 的应用中可用(即 debug 构建)。
二、整体架构
scss
┌─────────────────────────────────────────────────────────┐
│ Kotlin / Java 层 │
│ │
│ 1. ClassLoader.findLibrary("si-optimize") │
│ → 找到 .so 真实路径 │
│ 2. Files.copy → filesDir/jvmti/libsi-optimize.so │
│ → 拷贝到不含 '=' 的路径 │
│ 3. Debug.attachJvmtiAgent(copiedPath, ptrFilePath, cl) │
│ → ART 以 agent 模式加载拷贝的 .so │
│ 4. 读文件拿 jvmtiEnv 指针 │
│ 5. nativeInitJvmti(ptr) │
│ → 原始 .so 完成回调注册 │
└────────────┬────────────────────────────────────────────┘
│
┌────────────▼────────────────────────────────────────────┐
│ Native 层(StartUpOptimize.cpp) │
│ │
│ ┌─ 拷贝的 .so(Agent 实例)────────────────────┐ │
│ │ Agent_OnAttach(vm, options, reserved) │ │
│ │ → GetEnv(JVMTI_VERSION) 获取 jvmtiEnv* │ │
│ │ → fwrite(jvmtiEnv*, options 指定的文件) │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─ 原始 .so(JNI 实例,System.loadLibrary)────┐ │
│ │ nativeInitJvmti(ptr) │ │
│ │ → setupJvmtiTrackerWithEnv(jvmtiEnv*) │ │
│ │ → 注册 VMObjectAlloc / ObjectFree 回调 │ │
│ │ │ │
│ │ gcMonitorProxy() ← ShadowHook │ │
│ │ → 调原始 CollectGarbageInternal │ │
│ │ → dumpAndResetFreedStats() 输出 Top-20 │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
三、踩过的坑(按时间顺序)
坑 1:GetEnv(JVMTI_VERSION) 返回 -3 (JNI_EVERSION)
现象: 在 JNI_OnLoad 或普通 JNI 方法中调 JavaVM::GetEnv(JVMTI_VERSION_1_0) 返回 -3。
原因: ART 只对以 agent 模式 加载的 .so 授权 JVMTI。通过 System.loadLibrary() 加载的普通 JNI 库无法获取 jvmtiEnv。
解决: 必须通过 Debug.attachJvmtiAgent() 加载 .so,ART 会调用其中的 Agent_OnAttach 入口,此时 GetEnv(JVMTI_VERSION) 才能成功。
坑 2:jvmti.h 头文件不在 Android NDK 中
现象: #include <jvmti.h> 编译报错,NDK sysroot 中找不到该头文件。
原因: Android NDK(23/26/27 均如此)不包含 jvmti.h,该文件在本地 JDK 的 include/ 目录下。
解决:
- 从本地 JDK 拷贝
jvmti.h到项目cpp/目录 - 关键: 将
jvmti.h中的#include "jni.h"改为#include <jni.h>,否则会引入 JDK 的jni.h导致类型冲突 - 源文件中用
#include "jvmti.h"(引号,找本地文件)
坑 3:Debug.attachJvmtiAgent() 抛 IllegalArgumentException
现象: Preconditions.checkArgument 失败,无错误消息。
原因: Android 12+ 的 APK 安装路径包含 = 号:
bash
/data/app/~~9OsyW1hGD99IoUWFHOjUPg==/com.shein.sim-j7ptKdFeeKNziREwJ58HVw==/lib/arm64/libsi-optimize.so
而 Debug.attachJvmtiAgent 源码有校验:
java
Preconditions.checkArgument(!library.contains("="));
解决:
- 通过
ClassLoader.findLibrary("si-optimize")获取 .so 真实路径 - 拷贝到
context.filesDir/jvmti/(路径不含=) - 用拷贝后的路径调
attachJvmtiAgent
kotlin
val findLibrary = ClassLoader::class.java.getDeclaredMethod("findLibrary", String::class.java)
val srcPath = findLibrary.invoke(context.classLoader, "si-optimize") as String
Files.copy(Paths.get(srcPath), Paths.get(agentSo.absolutePath))
Debug.attachJvmtiAgent(agentSo.absolutePath, options, context.classLoader)
坑 4:FindClass 在 Agent_OnAttach 中抛 NoClassDefFoundError
现象: env->FindClass("com/shein/sim/...") 失败,boot class loader 找不到 app 类。
原因: Agent_OnAttach 运行在 agent 加载上下文,JNI 的 FindClass 使用 boot class loader,无法加载应用类。
解决: 不在 Agent_OnAttach 中做 JNI 回调,改用文件传递 jvmtiEnv 指针:
- Kotlin 通过
options参数传入文件路径 Agent_OnAttach用fopen/fprintf写指针值到文件- Kotlin 读文件拿到指针,调
nativeInitJvmti(ptr)
坑 5:拷贝的 .so 与原始 .so 是两个独立实例
现象: Agent_OnAttach 设置的 g_jvmti 全局变量,在 startGCMonitor 的 JNI 方法中看到是 null。
原因: System.loadLibrary("si-optimize") 从 nativeLibraryDir 加载了原始 .so,attachJvmtiAgent 又从 filesDir/jvmti/ 加载了拷贝 .so。两次 dlopen 路径不同 → 两个独立实例 → 各自有独立的全局变量。
解决:
Agent_OnAttach(拷贝 .so)只负责获取jvmtiEnv*并写文件nativeInitJvmti(原始 .so 的 JNI 方法)接收指针并完成回调注册jvmtiEnv*是进程级资源,跨 .so 使用完全合法
四、接入步骤(通用指南)
Step 1: 准备 jvmti.h
bash
# 从本地 JDK 拷贝
cp $JAVA_HOME/include/jvmti.h your_project/src/main/cpp/
# 修改 include 方式(避免引入 JDK 的 jni.h)
sed -i 's/#include "jni.h"/#include <jni.h>/' your_project/src/main/cpp/jvmti.h
Step 2: Native 层实现
cpp
#include "jvmti.h"
// ========== 全局变量 ==========
static jvmtiEnv *g_jvmti = nullptr;
// ========== Agent_OnAttach ==========
// ART 以 agent 模式加载时回调,获取 jvmtiEnv 并通过文件传递指针
extern "C" JNIEXPORT jint JNICALL
Agent_OnAttach(JavaVM *vm, char *options, void *reserved) {
jvmtiEnv *jvmti = nullptr;
if (vm->GetEnv((void **) &jvmti, JVMTI_VERSION_1_0) != JNI_OK) {
return JNI_ERR;
}
// 将指针写入 options 指定的文件路径
if (options && options[0]) {
FILE *f = fopen(options, "w");
if (f) { fprintf(f, "%ld", (long)(intptr_t)jvmti); fclose(f); }
}
return JNI_OK;
}
// ========== 由原始 .so 调用,初始化 JVMTI 回调 ==========
extern "C" JNIEXPORT jboolean JNICALL
Java_com_example_NativeLib_nativeInitJvmti(JNIEnv *, jclass, jlong ptr) {
if (ptr == 0) return JNI_FALSE;
g_jvmti = reinterpret_cast<jvmtiEnv *>(ptr);
// 申请能力
jvmtiCapabilities caps = {};
caps.can_generate_vm_object_alloc_events = 1;
caps.can_generate_object_free_events = 1;
caps.can_tag_objects = 1;
g_jvmti->AddCapabilities(&caps);
// 注册回调
jvmtiEventCallbacks cbs = {};
cbs.VMObjectAlloc = onObjectAlloc; // 你的分配回调
cbs.ObjectFree = onObjectFree; // 你的回收回调
g_jvmti->SetEventCallbacks(&cbs, sizeof(cbs));
// 开启事件
g_jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_VM_OBJECT_ALLOC, nullptr);
g_jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_OBJECT_FREE, nullptr);
return JNI_TRUE;
}
Step 3: Kotlin 层实现
kotlin
companion object {
init { System.loadLibrary("your-lib") } // 常规 JNI 加载
external fun nativeInitJvmti(ptr: Long): Boolean
fun attachJvmtiAgent(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return try {
// 1. 查找 so 路径
val findLibrary = ClassLoader::class.java
.getDeclaredMethod("findLibrary", String::class.java)
val srcPath = findLibrary.invoke(context.classLoader, "your-lib") as? String
?: return false
// 2. 拷贝到 filesDir(避免路径含 '=')
val dir = File(context.filesDir, "jvmti").apply { mkdirs() }
val agent = File(dir, "libyour-lib.so")
if (agent.exists()) agent.delete()
Files.copy(Paths.get(srcPath), Paths.get(agent.absolutePath))
// 3. 准备指针文件
val ptrFile = File(dir, ".ptr")
if (ptrFile.exists()) ptrFile.delete()
// 4. Attach agent(options 传文件路径)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Debug.attachJvmtiAgent(agent.absolutePath, ptrFile.absolutePath, context.classLoader)
} else {
val cls = Class.forName("dalvik.system.VMDebug")
val m = cls.getMethod("attachAgent", String::class.java)
m.isAccessible = true
m.invoke(null, "${agent.absolutePath}=${ptrFile.absolutePath}")
}
// 5. 读指针,初始化原始 so 的回调
val ptr = ptrFile.readText().trim().toLongOrNull() ?: 0L
if (ptr != 0L) nativeInitJvmti(ptr) else false
} catch (e: Throwable) {
false
}
}
}
Step 4: 调用时机
kotlin
// debug 构建中,在合适时机调用(如 DoKit 按钮 / 启动任务)
val ok = SiOptimizeNativeLib.attachJvmtiAgent(context)
// ok == true 后,JVMTI 回调已注册,后续 GC 事件会自动触发
五、关键原理总结
| 问题 | 根因 | 解决方案 |
|---|---|---|
GetEnv(JVMTI) 返回 -3 |
ART 只对 agent 模式加载的 .so 授权 JVMTI | 用 Debug.attachJvmtiAgent() 加载 |
attachJvmtiAgent 抛 IllegalArgumentException |
APK 安装路径含 =,框架层校验 !library.contains("=") |
拷贝 .so 到 filesDir(路径无 =) |
FindClass 失败 |
Agent 上下文使用 boot class loader | 改用文件传递 jvmtiEnv* 指针 |
| 回调在错误的 .so 实例中 | dlopen 对不同路径会加载两个独立实例 |
Agent 只传指针,原始 .so 做回调注册 |
jvmti.h 编译报错 |
NDK 不含此头文件;JDK 的 jni.h 与 NDK 冲突 |
拷贝 + 改 #include "jni.h" → <jni.h> |
六、注意事项
- 仅限 debug 构建 ---
Debug.attachJvmtiAgent在非 debuggable 应用中会抛SecurityException - API 26+ --- JVMTI 在 Android 8.0 以下不可用
- 性能开销 ---
VMObjectAlloc回调在每次对象分配时触发,高频场景下有明显开销,建议仅在调试时开启 jvmtiEnv*是进程级资源 --- 在 agent .so 中获取后,可安全传递给同进程的其他 .so 使用- API 28 分界线 --- API 28+ 用
Debug.attachJvmtiAgent();API 26-27 用反射调VMDebug.attachAgent()