Android-性能无损线上代码覆盖率采集-JVMTI方案

1,654 阅读11分钟

background:在大型APP性能优化中,包体积一直都是个比较让人头疼的事,做到后期需要的技术难度极高,但是收益却微乎其微...言归正传,虽然在我们打包时经过ProGuard等工具的编译优化,将会去除掉绝大多不被使用到的代码,但总有些代码模块随着不断的迭代功能已经下线,但是代码还是被带到了线上,最终造成apk文件增大。现针对这个问题现提出方案:在运行时监控类的使用情况,基于线上大量用户真实情况,反推出现有的废弃代码及模块,最后下掉这些无用代码及模块,达到减少包体积的的终极目标。

一、覆盖率采集的常见方案

本文所说的代码覆盖率均指类维度的代码覆盖率。

  1. 基于插桩

插桩的方式:编译时期在每个类(通常过滤系统类)插入static代码块,用于标记该class被JVM加载(使用过)。字节开源的bytex插件平台中的coverag-plugin插件就是基于此种方式统计线上代码覆盖率。但这种方式有几个致命的弊端:1.插桩本身会增大包体积,测试时发现插入15万个类的cinit,将会增大约3MB的包体积。2.由于插桩的类在首次被加载的时候都会执行标记逻辑,这会导致APP的性能劣化,特别是启动时的性能劣化

  1. Hack classTable

高德和阿里互娱应该是采用的这种方案采集线上代码覆盖率。classTable变量是Android7之后art虚拟机在ClassLoader.java中加入的成员属性,对应的具体定义在art/runtime/class_table.h中,在Java层classTable对应的只是个内存地址,所有通过ClassLoader加载的Class将会被保存到classTable。通过hack这个classTable,通过其提供的Visit函数能够判断这个类是否被加载过。但这种方案也有一个弊端就是实现相对比较复杂

blog.csdn.net/csdnnews/ar…

mp.weixin.qq.com/s/qCwBF-BNG…


二、基于JVMTI做线上覆盖率采集

1. JVMTI简介(ART TI)

1)简介

JVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 Native 编程接口。通过这些接口,开发人员不仅可以调试在虚拟机上运行的 Java 程序,还能查看它们运行的状态、控制环境变量,甚至修改代码逻辑,从而帮助开发人员监控和优化程序性能。但在Android(ART)上被Google官方在Release模式中禁用掉了,如果需要使用则需要hack,同时对于系统版本有一定要求(>=Android8)。下图是Google官方对于ART TI的概念图:

image.png

  1. APP process:我们需要监控的APP进程
  2. Agent代理:需要我们自己实现定制的代理
  3. ART/Core:ART虚拟机
  4. External process:外部进程eg:Android studio profiler

图中的JVMTI Plugin的具体实现是libopenjdkjvmti.so,Android SDK 30以上的手机在apex/com.android.art/lib64/目录下


2)JVMTI能干什么

下面大概介绍一下JVMTI能做哪些黑科技:

  • 重定义 Class
  • 跟踪对象分配和垃圾回收过程
  • 遵循对象的引用树,遍历堆中的所有对象
  • 检查 Java 调用堆栈
  • 暂停和恢复所有线程
  • 其他功能

JVMTI主要通过我们注册的一些回调监听通知相应信息,比如监听线程启动、结束。监听类加载的过程等,而在这些回调中,我们能够做一些黑科技操作,比如在ClassFileLoadHook回调时,我们能拿到将要加载进JVM的class并做出相应修改,实现插桩、类文件修改等操作。JVMTI能做的东西非常多,本次我们主要通过JVMTI实现线上无用类的采集功能。 能够注册的回调:

typedef struct {
                              /*   50 : VM Initialization Event */
    jvmtiEventVMInit VMInit;
                              /*   51 : VM Death Event */
    jvmtiEventVMDeath VMDeath;
                              /*   52 : Thread Start */
    jvmtiEventThreadStart ThreadStart;
                              /*   53 : Thread End */
    jvmtiEventThreadEnd ThreadEnd;
                              /*   54 : Class File Load Hook */
    jvmtiEventClassFileLoadHook ClassFileLoadHook;
                              /*   55 : Class Load */
    jvmtiEventClassLoad ClassLoad;
                              /*   56 : Class Prepare */
    jvmtiEventClassPrepare ClassPrepare;
                              /*   57 : VM Start Event */
    jvmtiEventVMStart VMStart;
                              /*   58 : Exception */
    jvmtiEventException Exception;
                              /*   59 : Exception Catch */
    jvmtiEventExceptionCatch ExceptionCatch;
                              /*   60 : Single Step */
    jvmtiEventSingleStep SingleStep;
                              /*   61 : Frame Pop */
    jvmtiEventFramePop FramePop;
                              /*   62 : Breakpoint */
    jvmtiEventBreakpoint Breakpoint;
                              /*   63 : Field Access */
    jvmtiEventFieldAccess FieldAccess;
                              /*   64 : Field Modification */
    jvmtiEventFieldModification FieldModification;
                              /*   65 : Method Entry */
    jvmtiEventMethodEntry MethodEntry;
                              /*   66 : Method Exit */
    jvmtiEventMethodExit MethodExit;
                              /*   67 : Native Method Bind */
    jvmtiEventNativeMethodBind NativeMethodBind;
                              /*   68 : Compiled Method Load */
    jvmtiEventCompiledMethodLoad CompiledMethodLoad;
                              /*   69 : Compiled Method Unload */
    jvmtiEventCompiledMethodUnload CompiledMethodUnload;
                              /*   70 : Dynamic Code Generated */
    jvmtiEventDynamicCodeGenerated DynamicCodeGenerated;
                              /*   71 : Data Dump Request */
    jvmtiEventDataDumpRequest DataDumpRequest;
                              /*   72 */
    jvmtiEventReserved reserved72;
                              /*   73 : Monitor Wait */
    jvmtiEventMonitorWait MonitorWait;
                              /*   74 : Monitor Waited */
    jvmtiEventMonitorWaited MonitorWaited;
                              /*   75 : Monitor Contended Enter */
    jvmtiEventMonitorContendedEnter MonitorContendedEnter;
                              /*   76 : Monitor Contended Entered */
    jvmtiEventMonitorContendedEntered MonitorContendedEntered;
                              /*   77 */
    jvmtiEventReserved reserved77;
                              /*   78 */
    jvmtiEventReserved reserved78;
                              /*   79 */
    jvmtiEventReserved reserved79;
                              /*   80 : Resource Exhausted */
    jvmtiEventResourceExhausted ResourceExhausted;
                              /*   81 : Garbage Collection Start */
    jvmtiEventGarbageCollectionStart GarbageCollectionStart;
                              /*   82 : Garbage Collection Finish */
    jvmtiEventGarbageCollectionFinish GarbageCollectionFinish;
                              /*   83 : Object Free */
    jvmtiEventObjectFree ObjectFree;
                              /*   84 : VM Object Allocation */
    jvmtiEventVMObjectAlloc VMObjectAlloc;
} jvmtiEventCallbacks;

jvmti官方文档

Google ART TI

本次的代码覆盖率采集功能使用jvmti提供的一个函数GetLoadedClasses

jint classCount;
jclass *classes;
mJvmtiEnv->GetLoadedClasses(&classCount, &classes);

继续追踪堆栈发现,这个函数其实也是访问classTable变量,只不过jvmti-plugin内部帮我们封装好了,所以我们使用只需要调用接口就行了

image.png


2. 在Android上使用JVMTI的技术难点

由于JVMTI功能十分强大,可能会有各种不同的功能需要用到JVMTI的功能,比如线上代码覆盖率、内存监控、崩溃堆栈分析等,所以我将JVMTI封装为一套SDK——>JVMTI-SDK,整体设计如下: test.png

整个SDK大概分为4层:

  • Native层 负责与JVMTI-Plugin直接通信,且同时向Core层提供相应hack、回调接口等相应能力
  • Core层(Core) JVMTI核心基础层,下与Native通信,向上提供JVMTI基础能力,同时集成基础SDK
  • 功能层(Ability) 主要提供特定方向的功能,如无用类检测,能够使用Core层提供的JVMTI基础能力实现特定方向的复杂功能
  • 整合层(Integration) 直接被宿主依赖,暴露对外提供的接口。同时整合SDK功能,对各种功能进行统一控制

从代码结构来看,每一层都是一个module,本次的无用类检测属于功能层: image.png

由于Google在ART虚拟机中对TI功能进行了限制,必须要Debug模式才能使用TI能力,但我们是需要在线上使用该功能,所以突破Google对于Release包TI的封锁是必然的。 attachAgent的源码流程如下(借用图):

image.png

1)hack IsJdwpAllowed

当我们尝试在Release模式直接加载Agent代理时,程序会发生崩溃抛出异常java.lang.SecurityException: Can't attach agent, process is not debuggable. 在dalvik_system_VMDebug.cc中的void VMDebug_nativeAttachAgent发现首次被拦截的地方

static void VMDebug_nativeAttachAgent(JNIEnv* env, jclass, jstring agent, jobject classloader) {
  if (agent == nullptr) {
    ScopedObjectAccess soa(env);
    ThrowNullPointerException("agent is null");
    return;
  }

  if (!Dbg::IsJdwpAllowed()) {
    ScopedObjectAccess soa(env);
    ThrowSecurityException("Can't attach agent, process is not debuggable.");
    return;
  }

  std::string filename;
  {
    ScopedUtfChars chars(env, agent);
    if (env->ExceptionCheck()) {
      return;
    }
    filename = chars.c_str();
  }

  Runtime::Current()->AttachAgent(env, filename, classloader);
}

IsJdwpAllowed这个值是debugger.cc的一个静态变量,VMDebug_nativeAttachAgent函数是通过debugger.cc中国的gJdwpAllowed变量来判断,这里首先我们需要hack这个值,由于是静态变量,所以相对比较简单,查询Dbg::setJdwoAllowed的符号:_ZN3art3Dbg14SetJdwpAllowedEb(64位),由于Android7以上对于系统库的dlopen等函数做了限制,所以这里也需要绕过,如dlfunctions、xdl


2)hack is_java_debuggable_属性

上面对于gJdwpAllowed的hack完成之后,发现程序运行不崩溃了,但是还是有问题,c层Agent中拿到的jvmtiEnv指针为nullptr,attachAgent没有成功,我们继续分析源码

image.png

根据第一行系统log,找到对应的报错位置

image.png

最终跟到OpenjdkJvmTI.cc中的IsFullJvmtiAvailable

image.png 发现是is_java_debuggable_runtime的成员属性,hack这个值,需要拿到runtime对象,我们在Agent中可以通过JVM拿到唯一的runtime对象,然后跟上面一样的方式查SetJavaDebuggable函数符号_ZN3art7Runtime17SetJavaDebuggableEb(64位),拿到引用后调用函数修改is_java_debuggable_的值

class Runtime {

public:
    ...
    
    // Whether Java code needs to be debuggable.
    bool is_java_debuggable_;
    bool IsJavaDebuggable() const {
    return is_java_debuggable_;
    }
    void SetJavaDebuggable(bool value);

    ...
}

经过如上操作Agent就能够attach成功了,能够如愿通过函数GetLoadedClasses拿到JVM中已经加载过的类信息,然后将这些类信息透传到Java层做处理(过滤、压缩等),然后上报后台


3)数据上传

数据的上传也大有讲究,线上全量后数据是非常大的,尽管是本地做缓存,上报压缩过滤后的增量数据,这块的成本也是非常大的,一个可行的方案是,在测试阶段将数据采集后上报后台,后台根据版本信息将这部分信息记录下来,在正式版本上线后通过CDN方式拉取这个缓存文件,且固定一个时间同步这个文件(从新拉取),这样的话,数据至少能减少80%,因为测试阶段基本会覆盖APP主链路,用户就不用从新上报这部分数据。本文暂不深究这块


3. 性能影响

使用JVMTI采集和上报数据,由于对于类的采集不是通过注册监听的方式,而是使用jvmti直接提供的接口,我们主动调用,还能控制采集间隔,所以理论上性能是几乎无影响的,下图是使用该方案在大型APP上的性能分析(Google pixel 4xl debug包):由此可见该方案的对于性能的影响可以称得上是无损方案(CPU的使用率增大其实是上报数据部分做本地diff导致,这部分可以优化,也建议使用idelHandler)

image.png


4. 兼容性适配

由于涉及到虚拟机ART hook,再加上Android8才支持JVMTI 1.2版本,所以会有兼容性风险,事实也确实如此

  1. Android8+的系统才能使用,且Android8的Debug.java不提供attachAgent的方法,Android9才提供,Android8的系统需要反射调用
  2. Android各系统的libart.so的文件目录会不同,这里需要适配,适配以下几个目录

image.png

  1. RuntimeDebugState 适配

上述方案在线上灰度的时候,发现三星的某类机型、OPPO的某类机型会在Android13有崩溃情况,首先怀疑是厂商定制了ROM,但是后来在Google pixel的Android14上也有相应崩溃,基本排除厂商定制ROM,排查发现是jvmtiEnv指针为空导致的,进一步排查发现是由于hack is_java_debuggable_这个Runtime成员属性的时候失败了。考虑到之前的版本没有问题,但是Android14上有问题,所以猜测可能是Android14更改了Runtime结构,导致hack失效。追踪最新的Android14源码:(不想跟着追源码直接看最后的结论)

frameworks/base/core/java/android/os/Debug.java

image.png

libcore/dalvik/src/main/java/dalvik/system/VMDebug.java

image.png

art/runtime/native/dalvik_system_VMDebug.cc

image.png

这里发现IsJdwpAllowed这个hack点没有变化,继续去Runtime的AttachAgent art/runtime/runtime.cc

image.png

image.png

image.png 看到这里发现已经变了,isJavaDebuggable居然不是判断的is_java_debuggable_这个成员属性的值了,而是一个枚举类型,但是仍然看不出问题,因为这里并没有被拦截,继续追踪,就要浮出水面了

image.png 跟踪到plugin的Load函数里

image.png

art/openjdkjvmti/OpenjdkJvmTi.cc到JVMTI-Plugin的初始化函数

image.png

GetEnvHandler

image.png

IsFullJvmtiAvailable()

image.png

image.png OK了大功告成

结论:Android高系统版本更改了Runtime结构,删除了is_java_debuggable_属性,所以上面对于此的hack将在Android高版本失效,增加了RuntimeDebugState枚举类型来代替之前的bool类型,其实是增加了一种状态,之前的bool表示不了3种状态,将之前的hack在高版本替换为kJavaDebuggableAtInit即可,看runtime的初始化代码发现,默认值为kNonJavaDebuggable

class Runtime {
 public:
 ...
  enum class RuntimeDebugState {
    // This doesn't support any debug features / method tracing. This is the expected state usually.
    kNonJavaDebuggable,
    // This supports method tracing and a restricted set of debug features (for ex: redefinition
    // isn't supported). We transition to this state when method tracing has started or when the
    // debugger was attached and transition back to NonDebuggable once the tracing has stopped /
    // the debugger agent has detached..
    kJavaDebuggable,
    // The runtime was started as a debuggable runtime. This allows us to support the extended set
    // of debug features (for ex: redefinition). We never transition out of this state.
    kJavaDebuggableAtInit
  };
  
  // Whether Java code needs to be debuggable.
  RuntimeDebugState runtime_debug_state_;
  
  bool IsJavaDebuggable() const {
    return runtime_debug_state_ == RuntimeDebugState::kJavaDebuggable ||
           runtime_debug_state_ == RuntimeDebugState::kJavaDebuggableAtInit;
  }
  
  bool IsJavaDebuggableAtInit() const {
    return runtime_debug_state_ == RuntimeDebugState::kJavaDebuggableAtInit;
  }
  
  EXPORT void SetRuntimeDebugState(RuntimeDebugState state);
  ...
}

跟上面的hack方式类似,直接拿到高系统版本的libart.so文件,查SetRuntimeDebugState这个函数的符号:_ZN3art7Runtime20SetRuntimeDebugStateENS0_17RuntimeDebugStateE(64位),在AttachAgent之前将runtime中的这个值设置为kJavaDebuggableAtInit。OK适配结束,为了防止后续版本继续改runtime,导致之前的hack失效引发线上crash,建议增加hack失败的检查作为兜底,确保线上的稳定性。


三、Reference