Android JVMTI 接入流程

基于 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/ 目录下。

解决:

  1. 从本地 JDK 拷贝 jvmti.h 到项目 cpp/ 目录
  2. 关键:jvmti.h 中的 #include "jni.h" 改为 #include <jni.h>,否则会引入 JDK 的 jni.h 导致类型冲突
  3. 源文件中用 #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("="));

解决:

  1. 通过 ClassLoader.findLibrary("si-optimize") 获取 .so 真实路径
  2. 拷贝到 context.filesDir/jvmti/(路径不含 =
  3. 用拷贝后的路径调 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:FindClassAgent_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_OnAttachfopen/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() 加载
attachJvmtiAgentIllegalArgumentException 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>

六、注意事项

  1. 仅限 debug 构建 --- Debug.attachJvmtiAgent 在非 debuggable 应用中会抛 SecurityException
  2. API 26+ --- JVMTI 在 Android 8.0 以下不可用
  3. 性能开销 --- VMObjectAlloc 回调在每次对象分配时触发,高频场景下有明显开销,建议仅在调试时开启
  4. jvmtiEnv* 是进程级资源 --- 在 agent .so 中获取后,可安全传递给同进程的其他 .so 使用
  5. API 28 分界线 --- API 28+ 用 Debug.attachJvmtiAgent();API 26-27 用反射调 VMDebug.attachAgent()
相关推荐
2501_915909062 小时前
iOS 抓包不越狱,代理抓包 和 数据线直连抓包两种实现方式
android·ios·小程序·https·uni-app·iphone·webview
城东米粉儿3 小时前
Android VCL 和 NAL笔记
android
常利兵3 小时前
从0到1,解锁Android WebView混合开发新姿势
android·华为·harmonyos
背包客(wyq)3 小时前
基于Android手机的语音数据采集系统(语音数据自动上传至电脑端)
android·网络
不止二进制3 小时前
从 0 到 1 理解 LinearLayout:Android 布局入门实战
android
不止二进制3 小时前
Android |FrameLayout 帧布局实战 ——NeonLamp 霓虹灯效果详解
android
stevenzqzq3 小时前
Kotlin 进阶指南:中缀函数 (Infix Function)
android·kotlin·compose
●VON3 小时前
Flutter组件深度解析:从基础到高级的完整指南
android·javascript·flutter·harmonyos·von