澳门银河在线官方网址-银河国际登录网址-www.2949.com
做最好的网站

深入分析ClassLoader加载机制,搭建故障演练平台

原标题:去哪儿系统高可用之法:搭建故障演练平台

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

Classloader负责将Class加载到JVM中,并且确定由那个ClassLoader来加载(父优先的等级加载机制)。还有一个任务就是将Class字节码重新解释为JVM统一要求的格式

作者介绍

一. 背景介绍

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

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

1.Classloader类结构分析

王鹏,2017年加入去哪儿机票事业部,主要从事后端研发工作,目前在机票事业部负责行程单和故障演练平台以及公共服务ES、数据同步中间件等相关的研发工作。

二. 方案介绍

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

(1)主要由四个方法,分别是defineClass,findClass,loadClass,resolveClass
  • <1>defineClass(byte[] , int ,int) 将byte字节流解析为JVM能够识别的Class对象(直接调用这个方法生成的Class对象还没有resolve,这个resolve将会在这个对象真正实例化时resolve)

  • <2>findClass,通过类名去加载对应的Class对象。当我们实现自定义的classLoader通常是重写这个方法,根据传入的类名找到对应字节码的文件,并通过调用defineClass解析出Class独享

  • <3>loadClass运行时可以通过调用此方法加载一个类(由于类是动态加载进jvm,用多少加载多少的?)

  • <4>resolveClass手动调用这个使得被加到JVM的类被链接(解析resolve这个类?)

去哪儿网2005年成立至今,随着系统规模的逐步扩大,已经有成百上千个应用系统,这些系统之间的耦合度和链路的复杂度不断加强,对于我们构建分布式高可用的系统架构具有极大挑战。我们需要一个平台在运行期自动注入故障,检验故障预案是否起效——故障演练平台。

1. 如何hook

切面编程-- AOP。

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

图片 1

通过查看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)实现自定义ClassLoader一般会继承URLClassLoader类,因为这个类实现了大部分方法。

一、背景

2. hook 到哪里

一期主要是网络性能监控。如何能截获到网络数据

通过调研发现目前有下面集中方案:

  • root手机,通过adb 命令进行截获。
  • 建立vpn,将所有网络请求进行截获。
  • 参考听云,newrelic等产品,针对特定库进行代理截获。也许还有其他的方式,需要继续调研。目前我们参考newrelic等公司产品,针对特定网络请求库进行代理的的方式进行网络数据截获。比如okhtt3, httpclient, 等网络库。

2.ClassLoader的等级加载机制

这是某事业部的系统拓扑图:

三. Java Agent

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

由于我们要修改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方法。

(1)JVM平台提供三层的ClassLoader,这三层ClassLoader可以分为两类,分别是服务JVM自身的,和服务广大普通类的。分别是:
  • <1>BootstrapClassLoader:主要加载JVM自身工作所需要的类,该ClassLoader没有父类加载器和子类加载器

  • <2>ExtClassLoader:这个类加载器同样是JVM自身的一部分,但是不是由JVM实现,主要用于加载System.getProperty(“java.ext.dirs”)目录地下的类,如本机的值“D:javajdk7jrelibext;C:WindowsSunJavalibext”

  • <3>AppClassLoader:加载System.getProperty("java.class.path")(注意了在ide中运行程序时,该值通常是该项目的classes文件夹)中的类。所有的自定义类加载器不管直接实现ClassLoader,是继承自URLClassLoader或其子类,其父加载器(注意:父加载器与父类的分别)都是AppClassLoader,因为不管调用哪个父类的构造器,最终都将调用getSystemClassLoader作为父加载器,而该方法返回的正是AppClassLoader。(当应用程序中没有其他自定义的classLoader,那么除了System.getProperty(“java.ext.dirs”)目录中的类,其他类都由AppClassLoader加载)

图片 2

4. Java Bytecode

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

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

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

(2)Jvm加载class文件到内存有两种方式,隐式加载和显示加载,通常这两种方式是混合使用的
  • <1>隐式加载:是通过JVM来自动加载需要的类到内存的方式,当某个类被使用时,JVM发现该类不在内存中,那么它就会自动加载该类到内存

  • <2>显示加载:通过调用this.getClasss.getClassLoader.loadClass(),Class.forName,自己实现的ClassLoader的findClass方法

系统之间的依赖非常复杂、调用链路很深、服务之间没有分层。在这种复杂的依赖下,系统发生了几起故障:

字节代码指令

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

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

参考:

常见指令:

  • const 将什么数据类型压入操作数栈。
  • push 表示将单字节或短整型的常量压入操作数栈。
  • ldc 表示将什么类型的数据从常量池中压入操作数栈。
  • load 将某类型的局部变量数据压入操作数栈顶。
  • store 将操作数栈顶的数据存入指定的局部变量中。
  • pop 从操作数栈顶弹出数据
  • dup 复制栈顶的数据并将复制的值也压入栈顶。
  • swap 互换栈顶的数据
  • invokeVirtual 调用实例方法
  • invokeSepcial 调用超类构造方法,实例初始化,私有方法等。
  • invokeStatic 调用静态方法
  • invokeInterface 调用接口
  • getStatic
  • getField
  • putStatic
  • putField
  • New

Java源代码

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

public void sayHello() {
    System.out.println("hello agent");
}

字节码

// access flags 0x9
  public static print(Ljava/lang/String;)V
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    NEW java/lang/StringBuilder
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    LDC "hello "
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ALOAD 0
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V

    NEW com/paic/agent/test/TestMain
    DUP
    INVOKESPECIAL com/paic/agent/test/TestMain.<init> ()V
    INVOKEVIRTUAL com/paic/agent/test/TestMain.sayHello ()V
    RETURN

public sayHello()V
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    LDC "hello agent"
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
    RETURN
(3)上级委托机制:当一个加载器加载类字时,先委托其父加载器加载,若加载成功则反馈给该加载器,若父加载器不能加载,则由该加载器加载
  • 弱依赖挂掉,主流程挂掉,修改报销凭证的支付状态,下单主流程失败;
  • 核心服务调用量陡增,某服务超时引起相关联的所有服务“雪崩”;
  • 机房网络或者某些机器挂掉,不能提供核心服务。

5. ASM 开发

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

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

  • 有一个简单的模块API,设计完善、使用方便。
  • 文档齐全,拥有一个相关的Eclipse插件。
  • 支持最新的 Java 版本——Java 7。
  • 小而快、非常可靠。
  • 拥有庞大的用户社区,可以为新用户提供支持。
  • 源许可开放,几乎允许任意使用。

图片 3

核心类: 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", "<init>", "()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", "<init>", "()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();
}

3.如何加载class文件:

分为三个步骤 加载字节码到内存、Linking、类字节初始化赋值

三个故障原因:

6. 实现原理

(1)加载字节码到内存:(这一步通常通过findclass()方法实现)

以URLClassLoader为例:该类的构造函数返现必须制定一个URL数据才能创建该对象,该类中包含一个URLClassPath对象,URLClassPath会判断传过来的URL是文件还是Jar包,创建相应的FileLoader或者JarLoader或者默认加载器,当jvm调用findclass时,这些加载器将class文件的字节码加载到内存中

  • 系统强弱依赖混乱、弱依赖无降级;
  • 系统流量陡增,系统容量不足,没有限流熔断机制;
  • 硬件资源网络出现问题影响系统运行,没有高可用的网络架构。

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< Project >接口,然后把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)Linking:验证与解析,包含3步:
  • <1>字节码验证

  • <2>类准备:准备代表每个类中定义的字段、方法和实现接口所需的数据结构

  • <3>解析:这个阶段类装入器转入类所应用的其他类

各种各样的问题,在这种复杂的依赖结构下被放大,一个依赖30个SOA服务的系统,每个服务99.99%可用。99.99%的30次方≈99.7%。0.3%意味着一亿次请求会有3,000,00次失败,换算成时间大约每月有2个小时服务不稳定。随着服务依赖数量的变多,服务不稳定的概率会呈指数性提高,这些问题最后都会转化为故障表现出来。

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)初始化class对象,执行静态初始化器并在这阶段末尾初始化静态字段为默认值

二、系统高可用的方法论

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.常见加载类错误分析

如何构建一个高可用的系统呢?首先要分析一下不可用的因素都有哪些:

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实例了。(详见:核心代码例子)

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

层次 加载器
上层 BootStrapClassLoader ProcessBuilder
下层 AppClassLoader ProcessBuilderMethodVisitor操作的自定义类

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

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

层次 加载器
上层 BootStrapClassLoader Looger
下层 AppClassLoader InvocationDispatcher

这句话的理解: 这里我们可以看到自定义类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 的区别。

(1)ClassNotFoundException:

通常是jvm要加载一个文件的字节码到内存时,没有找到这些字节码(如forName,loadClass等方法)

图片 4

5. CallSiteReplace 和 WrapReturn

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

(2)NoClassDefFoundError:

通常是使用new关键字,属性引用了某个类,继承了某个类或接口,但JVM加载这些类时发现这些类不存在的异常

高可用系统典型实践

7. 核心代码

ProcessBuilderMethodVisitor
DexClassTransformer#createDexerMainClassAdapter
InvocationDispatcher
BytecodeBuilder

public BytecodeBuilder loadInvocationDispatcher() {
        this.adapter.visitLdcInsn(Type.getType(TransformConstant.INVOCATION_DISPATCHER_CLASS));
        this.adapter.visitLdcInsn(TransformConstant.INVOCATION_DISPATCHER_FILED_NAME);
        this.adapter.invokeVirtual(Type.getType(Class.class), new Method("getDeclaredField", "(Ljava/lang/String;)Ljava/lang/reflect/Field;"));
        this.adapter.dup();
        this.adapter.visitInsn(Opcodes.ICONST_1);
        this.adapter.invokeVirtual(Type.getType(Field.class), new Method("setAccessible", "(Z)V"));
        this.adapter.visitInsn(Opcodes.ACONST_NULL);
        this.adapter.invokeVirtual(Type.getType(Field.class), new Method("get", "(Ljava/lang/Object;)Ljava/lang/Object;"));
        return this;
    }

解析

顺序 指令 描述
8 InvocationDispatcher object invokeVirtual 调用get方法返回具体实例对象
7 null ACONST_NULL null 入栈
6 Field object invokeVirtual 调用setAccessible,改为可访问的,目前栈中只剩一个对象
5 true ICONST_1 1 即为true,入栈
4 Field object dup 拷贝一份,目前栈中只剩两个对象
3 Field object invokeVirtual 调用getDeclaredField 获取treeLock存储的Field
2 treelock ldc treelock 入栈
1 Logger.class Type ldc Logger.class type 入栈

WrapMethodClassVisitor#MethodWrapMethodVisitor

private boolean tryReplaceCallSite(int opcode, String owner, String name, String desc, boolean itf) {
            Collection<ClassMethod> replacementMethods = this.context.getCallSiteReplacements(owner, name, desc);
            if (replacementMethods.isEmpty()) {
                return false;
            }
            ClassMethod method = new ClassMethod(owner, name, desc);
            Iterator<ClassMethod> it = replacementMethods.iterator();
            if (it.hasNext()) {
                ClassMethod replacementMethod = it.next();
                boolean isSuperCallInOverride = (opcode == Opcodes.INVOKESPECIAL) && !owner.equals(this.context.getClassName())
                        && this.name.equals(name) && this.desc.equals(desc);
                //override 方法
                if (isSuperCallInOverride) {
                    this.log.info(MessageFormat.format("[{0}] skipping call site replacement for super call in overriden method : {1}:{2}",
                            this.context.getFriendlyClassName(), this.name, this.desc));
                    return false;
                }

                Method originMethod = new Method(name, desc);
                //处理init方法, 构造对象, 调用替换的静态方法来替换init。
                if (opcode == Opcodes.INVOKESPECIAL && name.equals("<init>")) {
                    //调用父类构造方法
                    if (this.context.getSuperClassName() != null && this.context.getSuperClassName().equals(owner)) {
                        this.log.info(MessageFormat.format("[{0}] skipping call site replacement for class extending {1}",
                                this.context.getFriendlyClassName(), this.context.getFriendlySuperClassName()));
                        return false;
                    }
                    this.log.info(MessageFormat.format("[{0}] tracing constructor call to {1} - {2}", this.context.getFriendlyClassName(), method.toString(), owner));
                    //开始处理创建对象的逻辑
                    //保存参数到本地
                    int[] arguments = new int[originMethod.getArgumentTypes().length];
                    for (int i = arguments.length -1 ; i >= 0; i--) {
                        arguments[i] = this.newLocal(originMethod.getArgumentTypes()[i]);
                        this.storeLocal(arguments[i]);
                    }
                    //由于init 之前会有一次dup,及创建一次, dup一次, 此时如果执行了new 和 dup 操作树栈中会有两个对象。
                    this.visitInsn(Opcodes.POP);
                    if (this.newInstructionFound && this.dupInstructionFound) {
                        this.visitInsn(Opcodes.POP);
                    }
                    //载入参数到操作数栈
                    for (int arg : arguments) {
                        this.loadLocal(arg);
                    }
                    //使用要替换的方法,执行静态方法进行对象创建
                    super.visitMethodInsn(Opcodes.INVOKESTATIC, replacementMethod.getClassName(), replacementMethod.getMethodName(), replacementMethod.getMethodDesc(), false);
                    //如果此时才调用了dup,也需要pop, (这一部分的场景暂时还没有构造出来, 上面的逻辑为通用的)
                    if (this.newInstructionFound && !this.dupInstructionFound) {
                        this.visitInsn(Opcodes.POP);
                    }
                } else if (opcode == Opcodes.INVOKESTATIC) {
                    //替换静态方法
                    this.log.info(MessageFormat.format("[{0}] replacing call to {1} with {2}", this.context.getFriendlyClassName(), method.toString(), replacementMethod.toString()));
                    super.visitMethodInsn(Opcodes.INVOKESTATIC, replacementMethod.getClassName(), replacementMethod.getMethodName(), replacementMethod.getMethodDesc(), false);
                } else {
                    // 其他方法调用, 使用新方法替换旧方法的调用。 先判断创建的对象是否为null,
                    Method newMethod = new Method(replacementMethod.getMethodName(), replacementMethod.getMethodDesc());
                    this.log.info(MessageFormat.format("[{0}] replacing call to {1} with {2}", this.context.getFriendlyClassName(), method.toString(), replacementMethod.toString()));
                    //从操作数栈上取原始参数类型到本地变量中
                    int[] originArgs = new int[originMethod.getArgumentTypes().length];
                    for (int i = originArgs.length -1 ; i >= 0; i--) {
                        originArgs[i] = this.newLocal(originMethod.getArgumentTypes()[i]);
                        this.storeLocal(originArgs[i]);
                    }
                    //操作数栈中只剩操作对象了, 需要dup, 拷贝一份作为检查新method的第一个参数。
                    this.dup();
                    //检查操作数栈顶对象类型是否和新method的第一个参数一致。
                    this.instanceOf(newMethod.getArgumentTypes()[0]);

                    Label isInstanceOfLabel = new Label();
                    //instanceof 结果不等于0 则跳转到 isInstanceofLabel,执行替换调用
                    this.visitJumpInsn(Opcodes.IFNE, isInstanceOfLabel);
                    //否则执行原始调用
                    for (int arg : originArgs) {
                        this.loadLocal(arg);
                    }
                    super.visitMethodInsn(opcode, owner, name, desc, itf);

                    Label endLabel  = new Label();
                    //跳转到结束label
                    this.visitJumpInsn(Opcodes.GOTO, endLabel);

                    this.visitLabel(isInstanceOfLabel);
                    //处理替换的逻辑
                    //load 参数, 第一个为 obj, 后面的为原始参数
                    this.checkCast(newMethod.getArgumentTypes()[0]);
                    for (int arg: originArgs) {
                        this.loadLocal(arg);
                    }
                    super.visitMethodInsn(Opcodes.INVOKESTATIC, replacementMethod.getClassName(), replacementMethod.getMethodName(), replacementMethod.getMethodDesc(), false);
                    //结束
                    this.visitLabel(endLabel);
                }
                this.context.markModified();
                return true;
            }
            return false;
        }

解析

详细见tryReplaceCallSite注释即可。

(3)UnsatisfiedLinkErrpr:

如native的方法找不到本机的lib

理论上来说,当图中所有的事情都做完,我们就可以认为系统是一个真正的高可用系统。但真是这样吗?

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指令参考

.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;-><init>()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;-><init>()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;-><init>(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;

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

5.常用classLoader(书本此处其实是对tom加载servlet使用的classLoader分析)

那么故障演练平台就隆重登场了。当上述的高可用实践都做完,利用故障演练平台做一次真正的故障演练,在系统运行期动态地注入一些故障,从而来验证下系统是否按照故障预案去执行相应的降级或者熔断策略。

(1)AppClassLoader:

加载jvm的classpath中的类和tomcat的核心类

三、故障演练平台

(2)StandardClassLoader:

加载tomcat容器的classLoader,另外webAppClassLoader在loadclass时,发现类不在JVM的classPath下,在PackageTriggers(是一个字符串数组,包含一组不能使用webAppClassLoader加载的类的包名字符串)下的话,将由该加载器加载(注意:StandardClassLoader并没有覆盖loadclass方法,所以其加载的类和AppClassLoader加载没什么分别,并且使用getClassLoader返回的也是AppClassLoader)(另外,如果web应用直接放在tomcat的webapp目录下该应用就会通过StandardClassLoader加载,估计是因为webapp目录在PackageTriggers中?)

故障演练平台:检验故障预案是否真正的起作用的平台。

(3)webAppClassLoader如:

Servlet等web应用中的类的加载(loadclass方法的规则详见P169)

故障类型:主要包括运行期异常、超时等等。通过对系统某些服务动态地注入运行期异常来达到模拟故障的目的,系统按照预案执行相应的策略验证系统是否是真正的高可用。

6.自定义的classloader

1、故障演练平台的整体架构

(1)需要使用自定义classloader的情况
  • <1>不在System.getProperty("java.class.path")中的类文件不可以被AppClassLoader找到(LoaderClass方法只会去classpath下加载特定类名的类),当class文件的字节码不在ClassPath就需要自定义classloader

  • <2>对加载的某些类需要作特殊处理

  • <3>定义类的实效机制,对已经修改的类重新加载,实现热部署

故障演练平台架构主要分为四部分:

(2)加载自定义路径中的class文件
  • <1>加载特定来源的某些类:重写find方法,使特定类或者特定来源的字节码 通过defineClass获得class类并返回(应该符合jvm的类加载规范,其他类仍使用父加载器加载)

  • <2>加载自顶一个是的class文件(如经过网络传来的经过加密的class文件字节码):findclass中加密后再加载

图片 5

7.实现类的热部署:

  • (1)同一个classLoader的两个实例加载同一个类,JVM也会识别为两个

  • (2)不能重复加载同一个类(全名相同,并使用同一个类加载器),会报错

  • (3)不应该动态加载类,因为对象呗引用后,对象的属性结构被修改会引发问题

注意:使用不同classLoader加载的同一个类文件得到的类,JVM将当作是两个不同类,使用单例模式,强制类型转换时都可能因为这个原因出问题。

  • 前台展示系统(WEB):展示系统之间的拓扑关系以及每个AppCode对应的集群和方法,可以选择具体的方法进行故障的注入和解除;
  • 发布系统(Deploy):这个系统主要用于将故障演练平台的Agent和Binder包发布到目标APP的机器上并且启动执行。前台展示系统会传递给发布平台要进行故障注入的AppCode以及目标APP的IP地址,通过这两个参数发布系统可以找到相应的机器进行Jar包的下载和启动;
  • 服务和命令分发系统(Server):这个系统主要是用于命令的分发、注入故障的状态记录、故障注入和解除操作的逻辑、权限校验以及相关的Agent的返回信息接收功能。前台页面已经接入QSSO会对当前人可以操作的IP列表做故障注入,防范风险。后端命令分发的模块会和部署在目标APP上的Agent进行通信,将命令推送到Agent上执行字节码编织,Agent执行命令后返回的内容通过Server和Agent的长连接传回Server端;
  • Agent和Binder程序:Agent负责对目标APP做代理并且做字节码增强,具体代理的方法可以通过传输的命令来控制,代理方法后对方法做动态的字节码增强,这种字节码增强具有无侵入、实时生效、动态可插拔的特点。Binder程序主要是通过发布系统传递过来的AppCode和启动端口(ServerPort)找到目标APP的JVM进程,之后执行动态绑定,完成运行期代码增强的功能。

原书链接

以上内容只是个人笔记纪录,更多完整内容请购买作者原书籍查看。《深入分析JavaWeb技术内幕》

2、 Agent整体架构

目前AOP的实现有两种方式:

  • 静态编织:静态编织发生在字节码生成时根据一定框架的规则提前将AOP字节码插入到目标类和方法中;
  • 动态编织:在JVM运行期对指定的方法完成AOP字节码增强。常见的方法大多数采用重命名原有方法,再新建一个同名方法做代理的工作模式来完成。

静态编织的问题是如果想改变字节码必须重启,这给开发和测试过程造成了很大的不便。动态的方式虽然可以在运行期注入字节码实现动态增强,但没有统一的API很容易操作错误。基于此,我们采用动态编织的方式、规范的API来规范字节码的生成——Agent组件。

Agent组件:通过JDK所提供的Instrumentation-API实现了利用HotSwap技术在不重启JVM的情况下实现对任意方法的增强,无论我们是做故障演练、调用链追踪(QTrace)、流量录制平台(Ares)以及动态增加日志输出BTrace,都需要一个具有无侵入、实时生效、动态可插拔的字节码增强组件。

Agent的事件模型

如图所示,事件模型主要可分为三类事件:

图片 6

BEFORE在方法执行前事件、THROWS抛出异常事件、RETURN返回事件。这三类事件可以在方法执行前、返回和抛出异常这三种情况做字节码编织。

如下代码:

// BEFORE

try {

/*

* do something...

*/

foo();

// RETURN

return;

} catch (Throwable e) {

// THROWS

}

事件模型可以完成三个功能:

  • 在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行;
  • 在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常;
  • 在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回。

Agent如何防止“类污染”

在开发Agent的时候,第一个应用是故障演练平台,那么这个时候其实我们并不需要Agent执行的过程中有自定义结果对象的返回,所以第一个版本的Agent采用硬编码的方式进行动态织入:

图片 7

故障类加载模型

首先介绍下几个类加载器:

  • BootstrapClassLoader引导类加载器加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分;
  • ExtClassLoader它负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库;
  • AppClassLoader它负责加载系统类路径java-classpath或-D java.class.path指定路径下的类库,也就是我们经常用到的classpath路径;
  • CommonClassLoader以及下边的都是Tomcat定义的ClassLoader。

Agent和相关的lib会放到AppClassLoader这一层去加载,利用Javasist做字节码的织入,所以Javasist的加载器就是AppClassLoader。

但是想改变的是Tomcat WebClassLoader所加载的com.xxx.InvocationHandler这个类的Invoke方法,不同的ClassLoader之间的类是不能相互访问的,做字节码的变换并不需要这个类的实例,也不需要返回结果,所以可以通过Instrument API拿到这个类加载器,并且可以根据类名称获取到这个类的字节码进行字节码变换。故障类Drill.class和变形后的com.xxx.InvocationHandler.class重新load到JVM中,完成了插桩操作。

以Dubbo为例说明下如何注入故障和解除故障:

图片 8

Dubbo调用的注入过程

  • 服务A调用服务B在Client端的Proxy层做AOP;
  • 启动Agent并且生成一个Drill类invoke方法,抛出一个运行期异常;
  • 字节码变形:在代码第一行之前增加Drill.invoke();
  • 如果想变换异常类型,改变Drill类即可,换成Sleep 3s ClassRedifine之后会重新load到JVM完成故障类型的转化或者清除。

遇到的问题

上边的方式貌似很完美的解决了问题,但是随着平台的使用业务线要对很多接口和方法同时进行故障演练,那么我们生成的Drill类里面就会有各种:

if method==业务线定义方法

do xxx

而且很容易拼接出错并且难以调试,只能把生成的类输出为文件,查看自己写的字节码编译成class文件是否正确,简直太痛苦了!

怎么解决?

新的架构需要解决三个问题:

  • 类隔离的问题:不要污染原生APP;
  • 事件的实现是可编译的;
  • 支持返回自定义的结果。

下一版本的Agent实现就产生了,把所有Agent的类和实现的功能抽象出来,放到一个自定义的AgentClassLoader里面,字节码注入到目标APP后可以通过反射的方式来调用具体的事件实现。

图片 9

类加载模型

  • 在BootstrapClassLoader里面注入Drill类作为通信类;
  • Agent会接受命令,根据事件类型对InvocationHandler做字节码变形,注入到目标APP;
  • 在目标APP调用的时候,调用Drill.invoke(targetJavaClass,targetJavaMethod, targetThis, args)传递过来几个参数(目标类、方法、实例、本身参数等);
  • Drill类通过反射的方式调用AppClassLoader里面的具体事件实现,比如BEFORE事件的执行代码,来完成注入后的逻辑执行。

Agent的整体架构

Agent的整体架构如图所示:

图片 10

  • 支持不同的模块的加入,比如Mock、流量录制、故障演练等;
  • 支持QSSO的权限验证;
  • 支持测试和仿真环境的无成本接入;
  • 支持自动部署不需要人工介入;
  • 支持各种故障命令的发布和执行、 超时 、异常以及数据的返回;
  • 支持方法级别的编织以及代码执行流程的编织;
  • 支持在任意的Web容器执行Agent代理。

四、如何使用

使用的好处是很明显的:

  • 零成本接入,无需申请任何资源;
  • 故障注入解除,无需重启服务;
  • 可以提供所有集群的拓扑结构。

但是如何才能正确使用呢?如下图所示:

图片 11

使用方法

步骤一、输入AppCode;

步骤二、选择故障方法;

步骤三、指定机器;

步骤四、注入故障。

五、总结

故障演练平台最核心的就是Agent组件——字节码编织框架,这个框架是纯Java的基于Instrumentation-API的AOP解决方案。它可以方便研发人员对于字节码插桩拆桩操作,可以很容易的实现故障演练、流量录制以及其他的应用模块。

作者:王鹏

来源:Qunar技术沙龙订阅号(ID:QunarTL)

dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn返回搜狐,查看更多

责任编辑:

本文由澳门银河在线官方网址发布于互联网,转载请注明出处:深入分析ClassLoader加载机制,搭建故障演练平台

相关阅读