专栏名称: 开发者全社区
分享和推送Java/Android方向的技术和文章,让你成为这方面的大牛,让你每天都成长一点。同时,我们也会邀请BAT的大牛分享原创!
目录
相关文章推荐
郭霖  ·  Android Surface截图方法总结 ·  6 天前  
鸿洋  ·  Android ... ·  3 天前  
鸿洋  ·  Android系统native进程之我是in ... ·  5 天前  
鸿洋  ·  安卓应用跳转回流的统一和复用 ·  6 天前  
郭霖  ·  Android ... ·  1 周前  
51好读  ›  专栏  ›  开发者全社区

Android性能监控实现原理

开发者全社区  · 公众号  · android  · 2017-01-18 11:06

正文

相关阅读:

如何让你的App永远在后台存活:对Android进程守护、闹钟后台被杀死的研究

Android提升:高工必备技能!

[干货]2017已来,最全面试总结——这些Android面试题你一定需要


涉及知识点:APM, java Agent, plugin, bytecode, asm, InvocationHandler, smail

一. 背景介绍

APM : 应用程序性能管理。 2011年时国外的APM行业 NewRelic 和 APPDynamics 已经在该领域拔得头筹,国内近些年来也出现一些APM厂商,如: 听云, OneAPM, 博睿(bonree) 云智慧,阿里百川码力。 (据分析,国内android端方案都是抄袭NewRelic公司的,由于该公司的sdk未混淆,业界良心)

能做什么: crash监控,卡顿监控,内存监控,增加trace,网络性能监控,app页面自动埋点,等。

二. 方案介绍

性能监控其实就是hook 代码到项目代码中,从而做到各种监控。常规手段都是在项目中增加代码,但如何做到非侵入式的,即一个sdk即可。

1. 如何hook

切面编程-- AOP。
我们的方案是AOP的一种,通过修改app class字节码的形式将我们项目的class文件进行修改,从而做到嵌入我们的监控代码。


androidbuilder.jpg

通过查看Adnroid编译流程图,可以知道编译器会将所有class文件打包称dex文件,最终打包成apk。那么我们就需要在class编译成dex文件的时候进行代码注入。比如我想统计某个方法的执行时间,那我只需要在每个调用了这个方法的代码前后都加一个时间统计就可以了。关键点就在于编译dex文件时候注入代码,这个编译过程是由dx执行,具体类和方法为com.android.dx.command.dexer.Main#processClass。此方法的第二个参数就是class的byte数组,于是我们只需要在进入processClass方法的时候用ASM工具对class进行改造并替换掉第二个参数,最后生成的apk就是我们改造过后的了。

类:com.android.dx.command.dexer.Main

新的难点: 要让jvm在执行processClass之前先执行我们的代码,必须要对com.android.dx.command.dexer.Main(以下简称为dexer.Main)进行改造。如何才能达到这个目的?这时Instrumentation和VirtualMachine就登场了,参考第三节。

2. hook 到哪里

一期主要是网络性能监控。如何能截获到网络数据
通过调研发现目前有下面集中方案:

  • root手机,通过adb 命令进行截获。

  • 建立vpn,将所有网络请求进行截获。

  • 参考听云,newrelic等产品,针对特定库进行代理截获。

    也许还有其他的方式,需要继续调研。

    目前我们参考newrelic等公司产品,针对特定网络请求库进行代理的的方式进行网络数据截获。比如okhtt3, httpclient, 等网络库。

三. Java Agent

In general, a javaagent is a JVM “plugin”, a specially crafted .jar file, that utilizes the Instrumentation API that the JVM provides.

http://www.infoq.com/cn/articles/javaagent-illustrated/

由于我们要修改Dexer 的Main类, 而该类是在编译时期由java虚拟机启动的, 所以我们需要通过agent来修改dexer Main类。

javaagent的主要功能如下:

  • 可以在加载class文件之前作拦截,对字节码做修改

  • 可以在运行期对已加载类的字节码做变化

JVMTI:JVM Tool Interface,是JVM暴露出来的一些供用户扩展的接口集合。JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者扩展自己的逻辑。

instrument agent: javaagent功能就是它来实现的,另外instrument agent还有个别名叫JPLISAgent(Java Programming Language Instrumentation Services Agent),这个名字也完全体现了其最本质的功能:就是专门为Java语言编写的插桩服务提供支持的。

两种加载agent的方式:

  • 在启动时加载, 启动JVM时指定agent类。这种方式,Instrumentation的实例通过agent class的premain方法被传入。

  • 在运行时加载,JVM提供一种当JVM启动完成后开启agent机制。这种情况下,Instrumention实例通过agent代码中的的agentmain传入。

参考例子instrumentation 功能介绍(javaagent)

有了javaagent, 我们就可以在编译app时重新修改dex 的Main类,对应修改processClass方法。

4. Java Bytecode

如何修改class文件? 我们需要了解java字节码,然后需要了解ASM开发。通过ASM编程来修改字节码,从而修改class文件。(也可以使用javaassist来进行修改)

在介绍字节代码指令之前,有必要先来介绍 Java 虚拟机执行模型。我们知道,Java 代码是 在线程内部执行的。每个线程都有自己的执行栈,栈由帧组成。每个帧表示一个方法调用:每次 调用一个方法时,会将一个新帧压入当前线程的执行栈。当方法返回时,或者是正常返回,或者 是因为异常返回,会将这个帧从执行栈中弹出,执行过程在发出调用的方法中继续进行(这个方 法的帧现在位于栈的顶端)。

每一帧包括两部分:一个局部变量部分和一个操作数栈部分。局部变量部分包含可根据索引 以随机顺序访问的变量。由名字可以看出,操作数栈部分是一个栈,其中包含了供字节代码指令 用作操作数的值。

字节代码指令
字节代码指令由一个标识该指令的操作码和固定数目的参数组成:

  • 操作码是一个无符号字节值——即字节代码名

  • 参数是静态值,确定了精确的指令行为。它们紧跟在操作码之后给出.比如GOTO标记 指令(其操作码的值为 167)以一个指明下一条待执行指令的标记作为参数标记。不要 将指令参数与指令操作数相混淆:参数值是静态已知的,存储在编译后的代码中,而 操作数值来自操作数栈,只有到运行时才能知道。

参考: https://en.wikipedia.org/wiki/Java_bytecode_instruction_listings

常见指令:

  • const 将什么数据类型压入操作数栈。

  • push 表示将单字节或短整型的常量压入操作数栈。

  • ldc 表示将什么类型的数据从常量池中压入操作数栈。

  • load 将某类型的局部变量数据压入操作数栈顶。

  • store 将操作数栈顶的数据存入指定的局部变量中。

  • pop 从操作数栈顶弹出数据

  • dup 复制栈顶的数据并将复制的值也压入栈顶。

  • swap 互换栈顶的数据

  • invokeVirtual 调用实例方法

  • invokeSepcial 调用超类构造方法,实例初始化,私有方法等。

  • invokeStatic 调用静态方法

  • invokeInterface 调用接口

  • getStatic

  • getField

  • putStatic

  • putField

  • New

查看demo:
Java源代码

public static void print(String param) {    System.out.println("hello " + param);    new TestMain().sayHello(); }public void sayHello() {    System.out.println("hello agent"); }

字节码


5. ASM 开发

由于程序分析、生成和转换技术的用途众多,所以人们针对许多语言实现了许多用于分析、 生成和转换程序的工具,这些语言中就包括 Java 在内。ASM 就是为 Java 语言设计的工具之一, 用于进行运行时(也是脱机的)类生成与转换。于是,人们设计了 ASM1库,用于处理经过编译 的 Java 类。

ASM 并不是惟一可生成和转换已编译 Java 类的工具,但它是最新、最高效的工具之一,可 从 http://asm.objectweb.org 下载。其主要优点如下:

  • 有一个简单的模块API,设计完善、使用方便。

  • 文档齐全,拥有一个相关的Eclipse插件。

  • 支持最新的 Java 版本——Java 7。

  • 小而快、非常可靠。

  • 拥有庞大的用户社区,可以为新用户􏰁供支持。

  • 源许可开放,几乎允许任意使用。


ASM_transfer.png

核心类: ClassReader, ClassWriter, ClassVisitor

参考demo:

{      // print 方法的ASM代码    mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "print", "(Ljava/lang/String;)V", null, null);    mv.visitCode();    mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");    mv.visitTypeInsn(NEW, "java/lang/StringBuilder");    mv.visitInsn(DUP);    mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "", "()V", false);    mv.visitLdcInsn("hello ");    mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);    mv.visitVarInsn(ALOAD, 0);    mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);    mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);    mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);    mv.visitTypeInsn(NEW, "com/paic/agent/test/TestMain");    mv.visitInsn(DUP);    mv.visitMethodInsn(INVOKESPECIAL, "com/paic/agent/test/TestMain", "", "()V", false);    mv.visitMethodInsn(INVOKEVIRTUAL, "com/paic/agent/test/TestMain", "sayHello", "()V", false);    mv.visitInsn(RETURN);    mv.visitEnd();} {   //sayHello 的ASM代码    mv = cw.visitMethod(ACC_PUBLIC, "sayHello", "()V", null, null);    mv.visitCode();    mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");    mv.visitLdcInsn("hello agent");    mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);    mv.visitInsn(RETURN);    mv.visitEnd();}

6. 实现原理

1. Instrumentation和VirtualMachine

VirtualMachine有个loadAgent方法,它指定的agent会在main方法前启动,并调用agent的agentMain方法,agentMain的第二个参数是Instrumentation,这样我们就能够给Instrumentation设置ClassFileTransformer来实现对dexer.Main的改造,同样也可以用ASM来实现。一般来说,APM工具包括三个部分,plugin、agent和具体的业务jar包。这个agent就是我们说的由VirtualMachine启动的代理。而plugin要做的事情就是调用loadAgent方法。对于Android Studio而言,plugin就是一个Gradle插件。 实现gradle插件可以用intellij创建一个gradle工程并实现Plugin接口,然后把tools.jar(在jdk的lib目录下)和agent.jar加入到Libraries中。在META-INF/gradle-plugins目录下创建一个properties文件,并在文件中加入一行内容“implementation-class=插件类的全限定名“。artifacs配置把源码和META-INF加上,但不能加tools.jar和agent.jar。(tools.jar 在 jdk中, 不过一般需要自己拷贝到工程目录中的, agent.jar开发完成后放到plugin工程中用于获取jar包路径)。

2. ClassFileTransformer

agent的实现相对plugin则复杂很多,首先需要提供agentmain(String args, Instrumentation inst)方法,并给Instrumentation设置ClassFileTransformer,然后在transformer里改造dexer.Main。当jvm成功执行到我们设置的transformer时,就会发现传进来的class根本就没有dexer.Main。坑爹呢这是。。。前面提到了,执行dexer.Main的是dx.bat,也就是说,它和plugin根本不在一个进程里。

3. ProcessBuilder

dx.bat其实是由ProcessBuilder的start方法启动的,ProcessBuilder有一个command成员,保存的是启动目标进程携带的参数,只要我们给dx.bat带上-javaagent参数就能给dx.bat所在进程指定我们的agent了。于是我们可以在执行start方法前,调用command方法获取command,并往其中插入-javaagent参数。参数的值是agent.jar所在的路径,可以使用agent.jar其中一个class类实例的getProtectionDomain().getCodeSource().getLocation().toURI().getPath()获得。可是到了这里我们的程序可能还是无法正确改造class。如果我们把改造类的代码单独放到一个类中,然后用ASM生成字节码调用这个类的方法来对command参数进行修改,就会发现抛出了ClassDefNotFoundError错误。这里涉及到了ClassLoader的知识。

4. ClassLoader和InvocationHandler

关于ClassLoader的介绍很多,这里不再赘述。ProcessBuilder类是由Bootstrap ClassLoader加载的,而我们自定义的类则是由AppClassLoader加载的。Bootstrap ClassLoader处于AppClassLoader的上层,我们知道,上层类加载器所加载的类是无法直接引用下层类加载器所加载的类的。但如果下层类加载器加载的类实现或继承了上层类加载器加载的类或接口,上层类加载器加载的类获取到下层类加载的类的实例就可以将其强制转型为父类,并调用父类的方法。这个上层类加载器加载的接口,部分APM使用InvocationHandler。还有一个问题,ProcessBuilder怎么才能获取到InvocationHandler子类的实例呢?有一个比较巧妙的做法,在agent启动的时候,创建InvocationHandler实例,并把它赋值给Logger的treeLock成员。treeLock是一个Object对象,并且只是用来加锁的,没有别的用途。但treeLock是一个final成员,所以记得要修改其修饰,去掉final。Logger同样也是由Bootstrap ClassLoader加载,这样ProcessBuilder就能通过反射的方式来获取InvocationHandler实例了。(详见:核心代码例子)

上层类加载器所加载的类是无法直接引用下层类加载器所加载的类的


这一句话的理解: 我们的目的是通过ProcessBuilderMethodVisitor将我们的代码(自定义修改类)写入ProcessBuilder.class中去让BootStrapClassLoader类加载器进行加载,而此时, BootStrapClassLoader是无法引用到我们自定义的类的,因为我们自定义的类是AppClassLoader加载的。

但如果下层类加载器加载的类实现或继承了上层类加载器加载的类或接口,上层类加载器加载的类获取到下层类加载的类的实例就可以将其强制转型为父类,并调用父类的方法。


这句话的理解: 这里我们可以看到自定义类InvocationDispatcher是由AppClassLoader加载的, 我们在运行RewriterAgent(AppClassLoader加载)类时,通过反射的方式将InvocationDispatcher对象放入Looger(由于引用了Looger.class,所以此时logger已经被BootStrapClassLoader加载)类的treelock对象中,即下层类加载器加载的类实现了上层类加载器加载的类;当我们通过ProcessBuilderMethodVisitor类处理ProcessBuilder.class文件时,可以通过Logger提取成员变量,插入对应的调用逻辑。当运行到ProcessBuilder时,再通过这段代码动态代理的方式调用对应的业务。可以将其强制转型为父类,并调用父类的方法 ,请参考http://stackoverflow.com/questions/1504633/what-is-the-point-of-invokeinterface, 这里详细介绍了invokeInterface 和 invokeVirtual 的区别。

5. CallSiteReplace 和 WrapReturn

实现上我们目前主要做这两种, 一种是代码调用替换, 另一种是代码包裹返回。主要是提前写好对应规则的替换代码, 生成配置文件表, 在agent中visit每一个class代码, 遇到对应匹配调用时将进行代码替换。

7. 核心代码


解析


WrapMethodClassVisitor#MethodWrapMethodVisitor

解析
详细见tryReplaceCallSite注释即可。

8. 验证

将生成的apk反编译,查看class 字节码。我们一般会通过JD-GUI来查看。我们来查看一下sample生成的结果:

private void testOkhttpCall()  {    OkHttpClient localOkHttpClient = new OkHttpClient.Builder().build();    Object localObject = new Request.Builder().url("https://test3-fbtoam.pingan.com.cn:15443/btoa/portal/common/getPublicKey");    if (!(localObject instanceof Request.Builder))    {      localObject = ((Request.Builder)localObject).build();      if ((localOkHttpClient instanceof OkHttpClient)) {        break label75;      }    }    label75:    for (localObject = localOkHttpClient.newCall((Request)localObject);; localObject = OkHttp3Instrumentation.newCall((OkHttpClient)localOkHttpClient, (Request)localObject))    {      ((Call)localObject).enqueue(new Callback()      {        public void onFailure(Call paramAnonymousCall, IOException paramAnonymousIOException)        {        }        public void onResponse(Call paramAnonymousCall, Response paramAnonymousResponse)          throws IOException        {        }      });      return;      localObject = OkHttp3Instrumentation.build((Request.Builder)localObject);      break;    }  }

上面的代码估计没有几个人能够看懂, 尤其for循环里面的逻辑。其实是由于不同的反编译工具造成的解析问题导致的,所以看起来逻辑混乱,无法符合预期。

想用查看真实的结果, 我们来看下反编译后的smail。
详细smail指令参考http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html

.method private testOkhttpCall()V    .locals 6    .prologue    .line 35   const-string v3, "https://test3-fbtoam.pingan.com.cn:15443/btoa/portal/common/getPublicKey"    .line 36    .local v3, "url":Ljava/lang/String;   new-instance v4, Lokhttp3/OkHttpClient$Builder;   invoke-direct {v4}, Lokhttp3/OkHttpClient$Builder;->()V   invoke-virtual {v4}, Lokhttp3/OkHttpClient$Builder;->build()Lokhttp3/OkHttpClient;   move-result-object v1 //new OkHttpClient.Builder().build(); 即为okhttpclient,放到 v1 中    .line 37    .local v1, "okHttpClient":Lokhttp3/OkHttpClient;   new-instance v4, Lokhttp3/Request$Builder;   invoke-direct {v4}, Lokhttp3/Request$Builder;->()V   invoke-virtual {v4, v3}, Lokhttp3/Request$Builder;->url(Ljava/lang/String;)Lokhttp3/Request$Builder;   move-result-object v4    //new Request.Builder().url(url)执行了这一段语句,将结果放到了v4中。   instance-of v5, v4, Lokhttp3/Request$Builder;   if-nez v5, :cond_0   invoke-virtual {v4}, Lokhttp3/Request$Builder;->build()Lokhttp3/Request;   move-result-object v2    .line 38    .local v2, "request":Lokhttp3/Request;    //判断v4中存储的是否为Request.Builder类型,如果是则跳转到cond_0, 否则执行Request.Builder.build()方法,将结果放到v2中.   :goto_0   instance-of v4, v1, Lokhttp3/OkHttpClient;   if-nez v4, :cond_1   invoke-virtual {v1, v2}, Lokhttp3/OkHttpClient;->newCall(Lokhttp3/Request;)Lokhttp3/Call;   move-result-object v0    .line 39    .end local v1    # "okHttpClient":Lokhttp3/OkHttpClient;    .local v0, "call":Lokhttp3/Call;    //goto_0 标签:判断v1 中的值是否为 OKHttpclient 类型, 如果是跳转为cond_1 , 否则调用OKHttpclient.newCall, 并将结果放到v0 中。   :goto_1   new-instance v4, Lcom/paic/apm/sample/MainActivity$1;   invoke-direct {v4, p0}, Lcom/paic/apm/sample/MainActivity$1;->(Lcom/paic/apm/sample/MainActivity;)V   invoke-interface {v0, v4}, Lokhttp3/Call;->enqueue(Lokhttp3/Callback;)V    .line 51   return-void    //goto_1 标签: 执行 v0.enqueue(new Callback());并return;    .line 37    .end local v0    # "call":Lokhttp3/Call;    .end local v2    # "request":Lokhttp3/Request;    .restart local v1    # "okHttpClient":Lokhttp3/OkHttpClient;   :cond_0   check-cast v4, Lokhttp3/Request$Builder;   invoke-static {v4}, Lcom/paic/agent/android/instrumentation/okhttp3/OkHttp3Instrumentation;->build(Lokhttp3/Request$Builder;)Lokhttp3/Request;   move-result-object v2   goto :goto_0    //cond_0:标签: 执行com.paic.agent.android.instrumentation.okhttp3.OkHttp3Instrumentation.build(v4), 并将结果放到v2中,并goto 到 goto_0    .line 38    .restart local v2    # "request":Lokhttp3/Request;   :cond_1   check-cast v1, Lokhttp3/OkHttpClient;    .end local v1    # "okHttpClient":Lokhttp3/OkHttpClient;   invoke-static {v1, v2}, Lcom/paic/agent/android/instrumentation/okhttp3/OkHttp3Instrumentation;->newCall(Lokhttp3/OkHttpClient;Lokhttp3/Request;)Lokhttp3/Call;   move-result-object v0   goto :goto_1    //cond_1 标签: 执行com.paic.agent.android.instrumentation.okhttp3.OkHttp3Instrumentation.newCall(v1, v2), 并将结果放到v0中, goto 到goto_1 .end method

解析后的伪代码

String v3 = "https://test3-fbtoam.pingan.com.cn:15443/btoa/portal/common/getPublicKey"; object v1 = new OkhttpClient.Builder().build(); object v4 = new Reqeust.Builder().url(v3); object v2 ; object v0 ;if (v4 instanceof Request.Builder) {    cond_0:    v2 = com.paic.agent.android.instrumentation.okhttp3.OkHttp3Instrumentation.build(v4); } else {    v2 = (Request.Builder)v4.build(); } goto_0:if (v1 instanceof OkHttpClient) {    cond_1:    v0 = com.paic.agent.android.instrumentation.okhttp3.OkHttp3Instrumentation.newCall(v1, v2); } else {    v0 = v1.newCall(v2); // v0 is Call } goto_1: v4 = new Callback(); v0.enqueue(v4); return;

查看伪代码, 符合预期结果。验证完毕。

原文:http://www.jianshu.com/p/9c07323dc7e5


Java和Android大牛频道

欢迎关注我们,一起讨论技术,扫描和长按下方的二维码可快速关注我们。搜索微信公众号:JANiubility。

公众号:JANiubility