background:在大型APP性能优化中,包体积一直都是个比较让人头疼的事,做到后期需要的技术难度极高,但是收益却微乎其微...言归正传,虽然在我们打包时经过ProGuard等工具的编译优化,将会去除掉绝大多不被使用到的代码,但总有些代码模块随着不断的迭代功能已经下线,但是代码还是被带到了线上,最终造成apk文件增大。现针对这个问题现提出方案:在运行时监控类的使用情况,基于线上大量用户真实情况,反推出现有的废弃代码及模块,最后下掉这些无用代码及模块,达到减少包体积的的终极目标。
一、覆盖率采集的常见方案
本文所说的代码覆盖率均指类维度的代码覆盖率。
- 基于插桩
插桩的方式:编译时期在每个类(通常过滤系统类)插入static代码块,用于标记该class被JVM加载(使用过)。字节开源的bytex插件平台中的coverag-plugin插件就是基于此种方式统计线上代码覆盖率。但这种方式有几个致命的弊端:1.插桩本身会增大包体积,测试时发现插入15万个类的cinit,将会增大约3MB的包体积。2.由于插桩的类在首次被加载的时候都会执行标记逻辑,这会导致APP的性能劣化,特别是启动时的性能劣化
- Hack classTable
高德和阿里互娱应该是采用的这种方案采集线上代码覆盖率。classTable变量是Android7之后art虚拟机在ClassLoader.java中加入的成员属性,对应的具体定义在art/runtime/class_table.h中,在Java层classTable对应的只是个内存地址,所有通过ClassLoader加载的Class将会被保存到classTable。通过hack这个classTable,通过其提供的Visit函数能够判断这个类是否被加载过。但这种方案也有一个弊端就是实现相对比较复杂
二、基于JVMTI做线上覆盖率采集
1. JVMTI简介(ART TI)
1)简介
JVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 Native 编程接口。通过这些接口,开发人员不仅可以调试在虚拟机上运行的 Java 程序,还能查看它们运行的状态、控制环境变量,甚至修改代码逻辑,从而帮助开发人员监控和优化程序性能。但在Android(ART)上被Google官方在Release模式中禁用掉了,如果需要使用则需要hack,同时对于系统版本有一定要求(>=Android8)。下图是Google官方对于ART TI的概念图:
- APP process:我们需要监控的APP进程
- Agent代理:需要我们自己实现定制的代理
- ART/Core:ART虚拟机
- 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提供的一个函数GetLoadedClasses
jint classCount;
jclass *classes;
mJvmtiEnv->GetLoadedClasses(&classCount, &classes);
继续追踪堆栈发现,这个函数其实也是访问classTable变量,只不过jvmti-plugin内部帮我们封装好了,所以我们使用只需要调用接口就行了
2. 在Android上使用JVMTI的技术难点
由于JVMTI功能十分强大,可能会有各种不同的功能需要用到JVMTI的功能,比如线上代码覆盖率、内存监控、崩溃堆栈分析等,所以我将JVMTI封装为一套SDK——>JVMTI-SDK,整体设计如下:
整个SDK大概分为4层:
- Native层 负责与JVMTI-Plugin直接通信,且同时向Core层提供相应hack、回调接口等相应能力
- Core层(Core) JVMTI核心基础层,下与Native通信,向上提供JVMTI基础能力,同时集成基础SDK
- 功能层(Ability) 主要提供特定方向的功能,如无用类检测,能够使用Core层提供的JVMTI基础能力实现特定方向的复杂功能
- 整合层(Integration) 直接被宿主依赖,暴露对外提供的接口。同时整合SDK功能,对各种功能进行统一控制
从代码结构来看,每一层都是一个module,本次的无用类检测属于功能层:
由于Google在ART虚拟机中对TI功能进行了限制,必须要Debug模式才能使用TI能力,但我们是需要在线上使用该功能,所以突破Google对于Release包TI的封锁是必然的。
attachAgent的源码流程如下(借用图):
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没有成功,我们继续分析源码
根据第一行系统log,找到对应的报错位置
最终跟到OpenjdkJvmTI.cc中的IsFullJvmtiAvailable
发现是
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)
4. 兼容性适配
由于涉及到虚拟机ART hook,再加上Android8才支持JVMTI 1.2版本,所以会有兼容性风险,事实也确实如此
- Android8+的系统才能使用,且Android8的Debug.java不提供attachAgent的方法,Android9才提供,Android8的系统需要反射调用
- Android各系统的
libart.so的文件目录会不同,这里需要适配,适配以下几个目录
- 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
libcore/dalvik/src/main/java/dalvik/system/VMDebug.java
art/runtime/native/dalvik_system_VMDebug.cc
这里发现IsJdwpAllowed这个hack点没有变化,继续去Runtime的AttachAgent
art/runtime/runtime.cc
看到这里发现已经变了,
isJavaDebuggable居然不是判断的is_java_debuggable_这个成员属性的值了,而是一个枚举类型,但是仍然看不出问题,因为这里并没有被拦截,继续追踪,就要浮出水面了
跟踪到plugin的Load函数里
art/openjdkjvmti/OpenjdkJvmTi.cc到JVMTI-Plugin的初始化函数
GetEnvHandler
IsFullJvmtiAvailable()
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失败的检查作为兜底,确保线上的稳定性。