原标题:去哪儿系统高可用之法:搭建故障演练平台
Classloader负责将Class加载到JVM中,并且确定由那个ClassLoader来加载(父优先的等级加载机制)。还有一个任务就是将Class字节码重新解释为JVM统一要求的格式
在面向对象编程实践中,我们通过众多的类来组织一个复杂的系统,这些类之间相互关联、调用使他们的关系形成了一个复杂紧密的网络。当系统启动时,出于性能、资源利用多方面的考虑,我们不可能要求
JVM
一次性将全部的类都加载完成,而是只加载能够支持系统顺利启动和运行的类和资源即可。那么在系统运行过程中如果需要使用未在启动时加载的类或资源时该怎么办呢?这就要靠类加载器来完成了。
作者介绍
阿里妹导读:减少故障的最好方法就是让故障经常性的发生。通过不断重复失败过程,持续提升系统的容错和弹性能力。今天,阿里巴巴把六年来在故障演练领域的创意和实践汇浓缩而成的工具进行开源,它就是
“ChaosBlade”。如果你想要提升开发效率,不妨来了解一下。
1.Classloader类结构分析
什么是类加载器
类加载器(ClassLoader)就是在系统运行过程中动态的将字节码文件加载到 JVM
中的工具,基于这个工具的整套类加载流程,我们称作类加载机制。我们在 IDE
中编写的都是源代码文件,以后缀名 .java
的文件形式存在于磁盘上,通过编译后生成后缀名 .class
的字节码文件,ClassLoader 加载的就是这些字节码文件。
王鹏,2017年加入去哪儿机票事业部,主要从事后端研发工作,目前在机票事业部负责行程单和故障演练平台以及公共服务ES、数据同步中间件等相关的研发工作。
高可用架构是保障服务稳定性的核心。
(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这个类?)
有哪些类加载器
Java 默认提供了三个 ClassLoader,分别是
AppClassLoader、ExtClassLoader、BootStrapClassLoader,依次后者分别是前者的「父加载器」。父加载器不是「父类」,三者之间没有继承关系,只是因为类加载的流程使三者之间形成了父子关系,下文会详细讲述。
去哪儿网2005年成立至今,随着系统规模的逐步扩大,已经有成百上千个应用系统,这些系统之间的耦合度和链路的复杂度不断加强,对于我们构建分布式高可用的系统架构具有极大挑战。我们需要一个平台在运行期自动注入故障,检验故障预案是否起效——故障演练平台。
阿里巴巴在海量互联网服务以及历年双11场景的实践过程中,沉淀出了包括全链路压测、线上流量管控、故障演练等高可用核心技术,并通过开源和云上服务的形式对外输出,以帮助企业用户和开发者享受阿里巴巴的技术红利,提高开发效率,缩短业务的构建流程。
(2)实现自定义ClassLoader一般会继承URLClassLoader类,因为这个类实现了大部分方法。
BootStrapClassLoader
BootStrapClassLoader 也叫「根加载器」,它是脱离 Java 语言,使用 C/C++
编写的类加载器,所以当你尝试使用 ExtClassLoader 的实例调用 getParent()
方法获取其父加载器时会得到一个 null
值。
// 返回一个 AppClassLoader 的实例ClassLoader appClassLoader = this.getClass().getClassLoader();// 返回一个 ExtClassLoader 的实例ClassLoader extClassLoader = appClassLoader.getParent();// 返回 null,因为 BootStrapClassLoader 是 C/C++ 编写的,无法在 Java 中获得其实例ClassLoader bootstrapClassLoader = extClassLoader.getParent();
根加载器会默认加载系统变量 sun.boot.class.path
指定的类库(jar 文件和
.class 文件),默认是 $JRE_HOME/lib
下的类库,如 rt.jar、resources.jar
等,具体可以输出该环境变量的值来查看。
String bootClassPath = System.getProperty("sun.boot.class.path");String[] paths = bootClassPath.split(":");for (String path : paths) { System.out.println;}// output// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/resources.jar// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/rt.jar// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/sunrsasign.jar// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jsse.jar// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jce.jar// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/charsets.jar// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jfr.jar// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/classes
除了加载这些默认的类库外,也可以使用 JVM 参数 -Xbootclasspath/a
来追加额外需要让根加载器加载的类库。比如我们自定义一个
com.ganpengyu.boot.DateUtils
类来让根加载器加载。
package com.ganpengyu.boot;import java.text.SimpleDateFormat;import java.util.Date;public class DateUtils { public static void printNow() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println(sdf.format(new Date; }}
我们将其制作成一个名为 gpy-boot
的 jar 包放到 /Users/yu/Desktop/lib
下,然后写一个测试类去尝试加载 DateUtils。
public class Test { public static void main(String[] args) throws Exception { Class<?> clz = Class.forName("com.ganpengyu.boot.DateUtils"); ClassLoader loader = clz.getClassLoader(); System.out.println(loader == null); }}
运行这个测试类:
java -Xbootclasspath/a:/Users/yu/Desktop/lib/gpy-boot.jar -cp /Users/yu/Desktop/lib/gpy-boot.jar:. Test
可以看到输出为 true
,也就是说加载 com.ganpengyu.boot.DateUtils
的类加载器在 Java
中无法获得其引用,而任何类都必须通过类加载器加载才能被使用,所以推断出这个类是被
BootStrapClassLoader 加载的,也证明了 -Xbootclasspath/a
参数确实可以追加需要被根加载器额外加载的类库。
总之,对于 BootStrapClassLoader 这个根加载器我们需要知道三点:
- 根加载器使用 C/C++ 编写,我们无法在 Java 中获得其实例
- 根加载器默认加载系统变量
sun.boot.class.path
指定的类库 - 可以使用
-Xbootclasspath/a
参数追加根加载器的默认加载类库
一、背景
例如,借助阿里云性能测试 PTS,高效率构建全链路压测体系,通过开源组件
Sentinel 实现限流和降级功能。这一次,经历了 6
年时间的改进和实践,累计在线上执行演练场景达数万次,我们将阿里巴巴在故障演练领域的创意和实践,浓缩成一个混沌工程工具,并将其开源,命名为
ChaosBlade。
2.ClassLoader的等级加载机制
ExtClassLoader
ExtClassLoader 也叫「扩展类加载器」,它是一个使用 Java
实现的类加载器(sun.misc.Launcher.ExtClassLoader
),用于加载系统所需要的扩展类库。默认加载系统变量
java.ext.dirs
指定位置下的类库,通常是 $JRE_HOME/lib/ext
目录下的类库。
public static void main(String[] args) { String extClassPath = System.getProperty("java.ext.dirs"); String[] paths = extClassPath.split(":"); for (String path : paths) { System.out.println; }}// output// /Users/leon/Library/Java/Extensions// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/ext// /Library/Java/Extensions// /Network/Library/Java/Extensions// /System/Library/Java/Extensions// /usr/lib/java
我们可以在启动时修改java.ext.dirs
变量的值来修改扩展类加载器的默认类库加载目录,但通常并不建议这样做。如果我们真的有需要扩展类加载器在启动时加载的类库,可以将其放置在默认的加载目录下。总之,对于
ExtClassLoader 这个扩展类加载器我们需要知道两点:
- 扩展类加载器是使用 Java
实现的类加载器,我们可以在程序中获得它的实例并使用。 - 通常不建议修改
java.ext.dirs
参数的值来修改默认加载目录,如有需要,可以将要加载的类库放到这个默认目录下。
这是某事业部的系统拓扑图:
ChaosBlade 是什么?
ChaosBlade
是一款遵循混沌工程实验原理,提供丰富故障场景实现,帮助分布式系统提升容错性和可恢复性的混沌工程工具,可实现底层故障的注入,特点是操作简洁、无侵入、扩展性强。
ChaosBlade 基于 Apache License v2.0 开源协议,目前有 chaosblade 和
chaosblade-exe-jvm 两个仓库。
chaosblade 包含 CLI 和使用 Golang
实现的基础资源、容器相关的混沌实验实施执行模块。chaosblade-exe-jvm
是对运行在 JVM 上的应用实施混沌实验的执行器。
ChaosBlade 社区后续还会添加 C++、Node.js 等其他语言的混沌实验执行器。
(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加载)
AppClassLoader
AppClassLoader 也叫「应用类加载器」,它和 ExtClassLoader 一样,也是使用
Java
实现的类加载器(sun.misc.Launcher.AppClassLoader
)。它的作用是加载应用程序
classpath
下所有的类库。这是我们最常打交道的类加载器,我们在程序中调用的很多
getClassLoader()
方法返回的都是它的实例。在我们自定义类加载器时如果没有特别指定,那么我们自定义的类加载器的默认父加载器也是这个应用类加载器。总之,对于
AppClassLoader 这个应用类加载器我们需要知道两点:
- 应用类加载器是使用 Java 实现的类加载器,负责加载应用程序
classpath
下的类库。 - 应用类加载器是和我们最常打交道的类加载器。
- 没有特别指定的情况下,自定义类加载器的父加载器就是应用类加载器。
为什么要开源?
很多公司已经开始关注并探索混沌工程,渐渐成为测试系统高可用,构建对系统信息不可缺少的工具。但混沌工程领域目前还处于一个快速演进的阶段,最佳实践和工具框架没有统一标准。实施混沌工程可能会带来一些潜在的业务风险,经验和工具的缺失也将进一步阻止
DevOps 人员实施混沌工程。
混沌工程领域目前也有很多优秀的开源工具,分别覆盖某个领域,但这些工具的使用方式千差万别,其中有些工具上手难度大,学习成本高,混沌实验能力单一,使很多人对混沌工程领域望而却步。
阿里巴巴集团在混沌工程领域已经实践多年,将混沌实验工具 ChaosBlade
开源目的,我们希望:
- 让更多人了解并加入到混沌工程领域;
- 缩短构建混沌工程的路径;
- 同时依靠社区的力量,完善更多的混沌实验场景,共同推进混沌工程领域的发展。
(2)Jvm加载class文件到内存有两种方式,隐式加载和显示加载,通常这两种方式是混合使用的
-
<1>隐式加载:是通过JVM来自动加载需要的类到内存的方式,当某个类被使用时,JVM发现该类不在内存中,那么它就会自动加载该类到内存
-
<2>显示加载:通过调用this.getClasss.getClassLoader.loadClass(),Class.forName,自己实现的ClassLoader的findClass方法
自定义类加载器
除了上述三种 Java 默认提供的类加载器外,我们还可以通过继承
java.lang.ClassLoader
来自定义一个类加载器。如果在创建自定义类加载器时没有指定父加载器,那么默认使用
AppClassLoader
作为父加载器。关于自定义类加载器的创建和使用,我们会在后面的章节详细讲解。
系统之间的依赖非常复杂、调用链路很深、服务之间没有分层。在这种复杂的依赖下,系统发生了几起故障:
ChaosBlade 能解决哪些问题?
衡量微服务的容错能力
通过模拟调用延迟、服务不可用、机器资源满载等,查看发生故障的节点或实例是否被自动隔离、下线,流量调度是否正确,预案是否有效,同时观察系统整体的
QPS 或 RT
是否受影响。在此基础上可以缓慢增加故障节点范围,验证上游服务限流降级、熔断等是否有效。最终故障节点增加到请求服务超时,估算系统容错红线,衡量系统容错能力。
验证容器编排配置是否合理
通过模拟杀服务 Pod、杀节点、增大 Pod
资源负载,观察系统服务可用性,验证副本配置、资源限制配置以及 Pod
下部署的容器是否合理。
测试 PaaS 层是否健壮
通过模拟上层资源负载,验证调度系统的有效性;模拟依赖的分布式存储不可用,验证系统的容错能力;模拟调度节点不可用,测试调度任务是否自动迁移到可用节点;模拟主备节点故障,测试主备切换是否正常。
验证监控告警的时效性
通过对系统注入故障,验证监控指标是否准确,监控维度是否完善,告警阈值是否合理,告警是否快速,告警接收人是否正确,通知渠道是否可用等,提升监控告警的准确和时效性。
定位与解决问题的应急能力
通过故障突袭,随机对系统注入故障,考察相关人员对问题的应急能力,以及问题上报、处理流程是否合理,达到以战养战,锻炼人定位与解决问题的能力。
(3)上级委托机制:当一个加载器加载类字时,先委托其父加载器加载,若加载成功则反馈给该加载器,若父加载器不能加载,则由该加载器加载
类加载器的启动顺序
上文已经提到过 BootStrapClassLoader 是一个使用 C/C++
编写的类加载器,它已经嵌入到了 JVM 的内核之中。当 JVM
启动时,BootStrapClassLoader
也会随之启动并加载核心类库。当核心类库加载完成后,BootStrapClassLoader
会创建 ExtClassLoader 和 AppClassLoader 的实例,两个 Java
实现的类加载器将会加载自己负责路径下的类库,这个过程我们可以在
sun.misc.Launcher
中窥见。
- 弱依赖挂掉,主流程挂掉,修改报销凭证的支付状态,下单主流程失败;
- 核心服务调用量陡增,某服务超时引起相关联的所有服务“雪崩”;
- 机房网络或者某些机器挂掉,不能提供核心服务。
功能和特点
场景丰富度高
ChaosBlade 支持的混沌实验场景不仅覆盖基础资源,如 CPU 满载、磁盘 IO
高、网络延迟等,还包括运行在 JVM 上的应用实验场景,如 Dubbo
调用超时和调用异常、指定方法延迟或抛异常以及返回特定值等,同时涉及容器相关的实验,如杀容器、杀
Pod。后续会持续的增加实验场景。
使用简洁,易于理解
永利博六年打磨!阿里开源混沌工程工具 ChaosBlade。ChaosBlade 通过 CLI
方式执行,具有友好的命令提示功能,可以简单快速的上手使用。命令的书写遵循阿里巴巴集团内多年故障测试和演练实践抽象出的故障注入模型,层次清晰,易于阅读和理解,降低了混沌工程实施的门槛。
场景扩展方便
所有的 ChaosBlade
实验执行器同样遵循上述提到的故障注入模型,使实验场景模型统一,便于开发和维护。模型本身通俗易懂,学习成本低,可以依据模型方便快捷的扩展更多的混沌实验场景。
3.如何加载class文件:
分为三个步骤 加载字节码到内存、Linking、类字节初始化赋值
ExtClassLoader 的创建过程
我们将 Launcher 类的构造方法源码精简展示如下:
public Launcher() { // 创建 ExtClassLoader Launcher.ExtClassLoader var1; try { var1 = Launcher.ExtClassLoader.getExtClassLoader(); } catch (IOException var10) { throw new InternalError("Could not create extension class loader", var10); } // 创建 AppClassLoader try { this.loader = Launcher.AppClassLoader.getAppClassLoader; } catch (IOException var9) { throw new InternalError("Could not create application class loader", var9); } // 设置线程上下文类加载器 Thread.currentThread().setContextClassLoader(this.loader); // 创建 SecurityManager}
可以看到当 Launcher 被初始化时就会依次创建 ExtClassLoader 和
AppClassLoader。我们进入 getExtClassLoader()
方法并跟踪创建流程,发现这里又调用了 ExtClassLoader
的构造方法,在这个构造方法里调用了父类的构造方法,这便是 ExtClassLoader
创建的关键步骤,注意这里传入父类构造器的第二个参数为
null。接着我们去查看这个父类构造方法,它位于 java.net.URLClassLoader
类中:
URLClassLoader(URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory)
通过这个构造方法的签名和注释我们可以明确的知道,第二个参数 parent
表示的是当前要创建的类加载器的父加载器。结合前面我们提到的
ExtClassLoader 的父加载器是 JVM 内核中 C/C++ 开发的
BootStrapClassLoader,且无法在 Java
中获得这个类加载器的引用,同时每个类加载器又必然有一个父加载器,我们可以反证出,ExtClassLoader
的父加载器就是 BootStrapClassLoader。
三个故障原因:
ChaosBlade 的演进史
EOS(2012-2015):故障演练平台的早期版本,故障注入能力通过字节码增强方式实现,模拟常见的
RPC 故障,解决微服务的强弱依赖治理问题。
MonkeyKing(2016-2018):故障演练平台的升级版本,丰富了故障场景(如:资源、容器层场景),开始在生产环境进行一些规模化的演练。
AHAS(2018.9-至今):阿里云应用高可用服务,内置演练平台的全部功能,支持可编排演练、演练插件扩展等能力,并整合了架构感知和限流降级的功能。
ChaosBlade:是 MonkeyKing
平台底层故障注入的实现工具,通过对演练平台底层的故障注入能力进行抽象,定义了一套故障模型。配合用户友好的
CLI 工具进行开源,帮助云原生用户进行混沌工程测试。
(1)加载字节码到内存:(这一步通常通过findclass()方法实现)
以URLClassLoader为例:该类的构造函数返现必须制定一个URL数据才能创建该对象,该类中包含一个URLClassPath对象,URLClassPath会判断传过来的URL是文件还是Jar包,创建相应的FileLoader或者JarLoader或者默认加载器,当jvm调用findclass时,这些加载器将class文件的字节码加载到内存中
AppClassLoader 的创建过程
理清了 ExtClassLoader 的创建过程,我们来看 AppClassLoader
的创建过程就清晰很多了。跟踪 getAppClassLoader()
方法的调用过程,可以看到这个方法本身将 ExtClassLoader
的实例作为参数传入,最后还是调用了 java.net.URLClassLoader
的构造方法,将 ExtClassLoader 的实例作为父构造器 parent
参数值传入。所以这里我们又可以确定,AppClassLoader 的父构造器就是
ExtClassLoader。
- 系统强弱依赖混乱、弱依赖无降级;
- 系统流量陡增,系统容量不足,没有限流熔断机制;
- 硬件资源网络出现问题影响系统运行,没有高可用的网络架构。
近期规划
功能迭代:
- 增强 JVM 演练场景,支持更多的 Java 主流框架,如 Redis,GRPC
- 增强 Kubernetes 演练场景
- 增加对 C++、Node.js 等应用的支持
(2)Linking:验证与解析,包含3步:
-
<1>字节码验证
-
<2>类准备:准备代表每个类中定义的字段、方法和实现接口所需的数据结构
-
<3>解析:这个阶段类装入器转入类所应用的其他类
怎么加载一个类
将一个 .class
字节码文件加载到 JVM 中成为一个 java.lang.Class
实例需要加载这个类的类加载器及其所有的父级加载器共同参与完成,这主要是遵循「双亲委派原则」。
各种各样的问题,在这种复杂的依赖结构下被放大,一个依赖30个SOA服务的系统,每个服务99.99%可用。99.99%的30次方≈99.7%。0.3%意味着一亿次请求会有3,000,00次失败,换算成时间大约每月有2个小时服务不稳定。随着服务依赖数量的变多,服务不稳定的概率会呈指数性提高,这些问题最后都会转化为故障表现出来。
社区共建:
欢迎访问 ChaosBlade@GitHub,参与社区共建,包括但不限于:
- 架构设计
- 模块设计
- 代码实现
- Bug Fix
- Demo样例
- 文档、网站和翻译
本文作者:中亭
阅读原文
本文来自云栖社区合作伙伴“ 阿里技术”,如需转载请联系原作者。
(3)初始化class对象,执行静态初始化器并在这阶段末尾初始化静态字段为默认值
双亲委派
当我们要加载一个应用程序 classpath
下的自定义类时,AppClassLoader
会首先查看自己是否已经加载过这个类,如果已经加载过则直接返回类的实例,否则将加载任务委托给自己的父加载器
ExtClassLoader。同样,ExtClassLoader
也会先查看自己是否已经加载过这个类,如果已经加载过则直接返回类的实例,否则将加载任务委托给自己的父加载器
BootStrapClassLoader。
BootStrapClassLoader
收到类加载任务时,会首先检查自己是否已经加载过这个类,如果已经加载则直接返回类的实例,否则在自己负责的加载路径下搜索这个类并尝试加载。如果找到了这个类,则执行加载任务并返回类实例,否则将加载任务交给
ExtClassLoader 去执行。
ExtClassLoader
同样也在自己负责的加载路径下搜索这个类并尝试加载。如果找到了这个类,则执行加载任务并返回类实例,否则将加载任务交给
AppClassLoader 去执行。
由于自己的父加载器 ExtClassLoader 和 BootStrapClassLoader
都没能成功加载到这个类,所以最后由 AppClassLoader
来尝试加载。同样,AppClassLoader 会在 classpath
下所有的类库中查找这个类并尝试加载。如果最后还是没有找到这个类,则抛出
ClassNotFoundException
异常。
综上,当类加载器要加载一个类时,如果自己曾经没有加载过这个类,则层层向上委托给父加载器尝试加载。对于
AppClassLoader 而言,它上面有 ExtClassLoader 和
BootStrapClassLoader,所以我们称作「双亲委派」。但是如果我们是使用自定义类加载器来加载类,且这个自定义类加载器的默认父加载器是
AppClassLoader
时,它上面就有三个父加载器,这时再说「双亲」就不太合适了。当然,理解了加载一个类的整个流程,这些名字就无关痛痒了。
二、系统高可用的方法论
4.常见加载类错误分析
为什么需要双亲委派机制
「双亲委派机制」最大的好处是避免自定义类和核心类库冲突。比如我们大量使用的
java.lang.String
类,如果我们自己写的一个 String
类被加载成功,那对于应用系统来说完全是毁灭性的破坏。我们可以尝试着写一个自定义的
String 类,将其包也设置为 java.lang
:
package java.lang;public class String { private int n; public String { this.n = n; } public String toLowerCase() { return new String(this.n + 100); }}
我们将其制作成一个 jar 包,命名为 thief-jdk
,然后写一个测试类尝试加载
java.lang.String
并使用接收一个 int 类型参数的构造方法创建实例。
import java.lang.reflect.Constructor;public class Test { public static void main(String[] args) throws Exception { Class<?> clz = Class.forName("java.lang.String"); System.out.println(clz.getClassLoader() == null); Constructor<?> c = clz.getConstructor(int.class); String str = c.newInstance; str.toLowerCase(); }}
运行测试程序
java -cp /Users/yu/Desktop/lib/thief/thief-jdk.jar:. Test
程序抛出 NoSuchMethodException 异常,因为 JVM 不能够加载我们自定义的
java.lang.String
,而是从 BootStrapClassLoader
的缓存中返回了核心类库中的 java.lang.String
的实例,且核心类库中的
String 没有接收 int 类型参数的构造方法。同时我们也看到 Class
实例的类加载器是 null
,这也说明了我们拿到的 java.lang.String
的实例确实是由 BootStrapClassLoader 加载的。
总之,「双亲委派」机制的作用就是确保类的唯一性,最直接的例子就是避免我们自定义类和核心类库冲突。
如何构建一个高可用的系统呢?首先要分析一下不可用的因素都有哪些:
(1)ClassNotFoundException:
通常是jvm要加载一个文件的字节码到内存时,没有找到这些字节码(如forName,loadClass等方法)
JVM 怎么判断两个类是相同的
「双亲委派」机制用来保证类的唯一性,那么 JVM
通过什么条件来判断唯一性呢?其实很简单,只要两个类的全路径名称一致,且都是同一个类加载器加载,那么就判断这两个类是相同的。如果同一份字节码被不同的两个类加载器加载,那么它们就不会被
JVM 判断为同一个类。
Person 类
public class Person { private Person p; public void setPerson(Object obj) { this.p = obj; }}
setPerson(Object obj)
方法接收一个对象,并将其强制转换为 Person
类型赋值给变量 p。
测试类
import java.lang.reflect.Method;public class Test { public static void main(String[] args) { CustomClassLoader classLoader1 = new CustomClassLoader("/Users/yu/Desktop/lib"); CustomClassLoader classLoader2 = new CustomClassLoader("/Users/yu/Desktop/lib"); try { Class c1 = classLoader1.findClass("Person"); Object instance1 = c1.newInstance(); Class c2 = classLoader2.findClass("Person"); Object instance2 = c2.newInstance(); Method method = c1.getDeclaredMethod("setPerson", Object.class); method.invoke(instance1, instance2); } catch (Exception e) { e.printStackTrace(); } }}
CustomClassLoader
是一个自定义的类加载器,它将字节码文件加载为字符数组,然后调用
ClassLoader 的 defineClass()
方法创建类的实例,后文会详细讲解怎么自定义类加载器。在测试类中,我们创建了两个类加载器的实例,让他们分别去加载同一份字节码文件,即
Person 类的字节码。然后在实例一上调用 setPerson()
方法将实例二传入,将实例二强制转型为实例一。
运行程序会看到 JVM 抛出了 ClassCastException
异常,异常信息为
Person cannot be cast to Person
。从这我们就可以知道,同一份字节码文件,如果使用的类加载器不同,那么
JVM 就会判断他们是不同的类型。
(2)NoClassDefFoundError:
通常是使用new关键字,属性引用了某个类,继承了某个类或接口,但JVM加载这些类时发现这些类不存在的异常
全盘负责
「全盘负责」是类加载的另一个原则。它的意思是如果类 A 是被类加载器 X
加载的,那么在没有显示指定别的类加载器的情况下,类 A
引用的其他所有类都由类加载器 X
负责加载,加载过程遵循「双亲委派」原则。我们编写两个类来验证「全盘负责」原则。
Worker 类
package com.ganpengyu.full;import com.ganpengyu.boot.DateUtils;public class Worker { public Worker() { } public void say() { DateUtils dateUtils = new DateUtils(); System.out.println(dateUtils.getClass().getClassLoader() == null); dateUtils.printNow(); }}
DateUtils 类
package com.ganpengyu.boot;import java.text.SimpleDateFormat;import java.util.Date;public class DateUtils { public void printNow() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println(sdf.format(new Date; }}
测试类
import com.ganpengyu.full.Worker;import java.lang.reflect.Constructor;public class Test { public static void main(String[] args) throws Exception { Class<?> clz = Class.forName("com.ganpengyu.full.Worker"); System.out.println(clz.getClassLoader() == null); Worker worker = clz.newInstance(); worker.say(); }}
运行测试类
java -Xbootclasspath/a:/Users/yu/Desktop/lib/worker.jar Test
运行结果
truetrue2018-09-16 22:34:43
我们将 Worker 类和 DateUtils 类制作成名为worker
的 jar
包,将其设置为由根加载器加载,这样 Worker
类就必然是被根加载器加载的。然后在 Worker 类的 say()
方法中初始化了
DateUtils 类,然后判断 DateUtils
类是否由根加载器加载。从运行结果看到,Worker 和其引用的 DateUtils
类都被跟加载器加载,符合类加载的「全盘委托」原则。
「全盘委托」原则实际是为「双亲委派」原则提供了保证。如果不遵守「全盘委托」原则,那么同一份字节码可能会被
JVM
加载出多个不同的实例,这就会导致应用系统中对该类引用的混乱,具体可以参考上文「JVM
怎么判断两个类是相同的」这一节的示例。
高可用系统典型实践
(3)UnsatisfiedLinkErrpr:
如native的方法找不到本机的lib
自定义类加载器
除了使用 JVM 预定义的三种类加载器外,Java
还允许我们自定义类加载器以让我们系统的类加载方式更灵活。要自定义类加载器非常简单,通常只需要三个步骤:
- 继承
java.lang.ClassLoader
类,让 JVM 知道这是一个类加载器 - 重写
findClass(String name)
方法,告诉 JVM
在使用这个类加载器时应该按什么方式去寻找.class
文件 - 调用
defineClass(String name, byte[] b, int off, int len)
方法,让
JVM 加载上一步读取的.class
文件
import java.io.*;import java.lang.reflect.Constructor;import java.lang.reflect.Method;import java.nio.file.Files;import java.nio.file.Path;import java.nio.file.Paths;public class CustomClassLoader extends ClassLoader { private String classpath; public CustomClassLoader(String classpath) { this.classpath = classpath; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { String classFilePath = getClassFilePath; byte[] classData = readClassFile(classFilePath); return defineClass(name, classData, 0, classData.length); } public String getClassFilePath(String name) { if (name.lastIndexOf(".") == -1) { return classpath + "/" + name + ".class"; } else { name = name.replace(".", "/"); return classpath + "/" + name + ".class"; } } public byte[] readClassFile(String filepath) { Path path = Paths.get; if (!Files.exists { return null; } try { return Files.readAllBytes; } catch (IOException e) { throw new RuntimeException("Can not read class file into byte array"); } } public static void main(String[] args) { CustomClassLoader loader = new CustomClassLoader("/Users/leon/Desktop/lib"); try { Class<?> clz = loader.loadClass("com.ganpengyu.demo.Person"); System.out.println(clz.getClassLoader().toString; Constructor<?> c = clz.getConstructor(String.class); Object instance = c.newInstance("Leon"); Method method = clz.getDeclaredMethod("say", null); method.invoke(instance, null); } catch (Exception e) { e.printStackTrace(); } }}
示例中我们通过继承 java.lang.ClassLoader
创建了一个自定义类加载器,通过构造方法指定这个类加载器的类路径(classpath)。重写
findClass(String name)
方法自定义类加载的方式,其中
getClassFilePath(String filepath)
方法和
readClassFile(String filepath)
方法用于找到指定的 .class
文件并加载成一个字符数组。最后调用
defineClass(String name, byte[] b, int off, int len)
方法完成类的加载。
在 main()
方法中我们测试加载了一个 Person 类,通过
loadClass(String name)
方法加载一个 Person 类。我们自定义的
findClass(String name)
方法,就是在这里面调用的,我们把这个方法精简展示如下:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock { // 先检查是否已经加载过这个类 Class<?> c = findLoadedClass; if (c == null) { long t0 = System.nanoTime(); try { // 否则的话递归调用父加载器尝试加载 if (parent != null) { c = parent.loadClass(name, false); } else { // 所有父加载器都无法加载,使用根加载器尝试加载 c = findBootstrapClassOrNull; } } catch (ClassNotFoundException e) {} if (c == null) { // 所有父加载器和根加载器都无法加载 // 使用自定义的 findClass() 方法查找 .class 文件 c = findClass; } } return c; }}
可以看到 loadClass(String name)
方法内部是遵循「双亲委派」机制来完成类的加载。在「双亲」都没能成功加载类的情况下才调用我们自定义的
findClass(String name)
方法查找目标类执行加载。
理论上来说,当图中所有的事情都做完,我们就可以认为系统是一个真正的高可用系统。但真是这样吗?
5.常用classLoader(书本此处其实是对tom加载servlet使用的classLoader分析)
为什么需要自定义类加载器
自定义类加载器的用处有很多,这里简单列举一些常见的场景。
- 从任意位置加载类。JVM
预定义的三个类加载器都被限定了自己的类路径,我们可以通过自定义类加载器去加载其他任意位置的类。 - 解密类文件。比如我们可以对编译后的类文件进行加密,然后通过自定义类加载器进行解密。当然这种方法实际并没有太大的用处,因为自定义的类加载器也可以被反编译。
- 支持更灵活的内存管理。我们可以使用自定义类加载器在运行时卸载已加载的类,从而更高效的利用内存。
那么故障演练平台就隆重登场了。当上述的高可用实践都做完,利用故障演练平台做一次真正的故障演练,在系统运行期动态地注入一些故障,从而来验证下系统是否按照故障预案去执行相应的降级或者熔断策略。
(1)AppClassLoader:
加载jvm的classpath中的类和tomcat的核心类
就这样吧
类加载器是 Java
中非常核心的技术,本文仅对类加载器进行了较为粗浅的分析,如果需要深入更底层则需要我们打开
JVM 的源码进行研读。「Java 有路勤为径,JVM 无涯苦作舟」,与君共勉。
三、故障演练平台
(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中加密后再加载
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的事件模型
如图所示,事件模型主要可分为三类事件:
BEFORE在方法执行前事件、THROWS抛出异常事件、RETURN返回事件。这三类事件可以在方法执行前、返回和抛出异常这三种情况做字节码编织。
如下代码:
// BEFORE
try {
/*
* do something…
*/
foo();
// RETURN
return;
} catch (Throwable e) {
// THROWS
}
事件模型可以完成三个功能:
- 在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行;
- 在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常;
- 在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回。
Agent如何防止“类污染”
在开发Agent的时候,第一个应用是故障演练平台,那么这个时候其实我们并不需要Agent执行的过程中有自定义结果对象的返回,所以第一个版本的Agent采用硬编码的方式进行动态织入:
故障类加载模型
首先介绍下几个类加载器:
- 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为例说明下如何注入故障和解除故障:
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后可以通过反射的方式来调用具体的事件实现。
类加载模型
- 在BootstrapClassLoader里面注入Drill类作为通信类;
- Agent会接受命令,根据事件类型对InvocationHandler做字节码变形,注入到目标APP;
- 在目标APP调用的时候,调用Drill.invoke(targetJavaClass,targetJavaMethod,
targetThis,
args)传递过来几个参数(目标类、方法、实例、本身参数等); - Drill类通过反射的方式调用AppClassLoader里面的具体事件实现,比如BEFORE事件的执行代码,来完成注入后的逻辑执行。
Agent的整体架构
Agent的整体架构如图所示:
- 支持不同的模块的加入,比如Mock、流量录制、故障演练等;
- 支持QSSO的权限验证;
- 支持测试和仿真环境的无成本接入;
- 支持自动部署不需要人工介入;
- 支持各种故障命令的发布和执行、 超时 、异常以及数据的返回;
- 支持方法级别的编织以及代码执行流程的编织;
- 支持在任意的Web容器执行Agent代理。
四、如何使用
使用的好处是很明显的:
- 零成本接入,无需申请任何资源;
- 故障注入解除,无需重启服务;
- 可以提供所有集群的拓扑结构。
但是如何才能正确使用呢?如下图所示:
使用方法
步骤一、输入AppCode;
步骤二、选择故障方法;
步骤三、指定机器;
步骤四、注入故障。
五、总结
故障演练平台最核心的就是Agent组件——字节码编织框架,这个框架是纯Java的基于Instrumentation-API的AOP解决方案。它可以方便研发人员对于字节码插桩拆桩操作,可以很容易的实现故障演练、流量录制以及其他的应用模块。
作者:王鹏
来源:Qunar技术沙龙订阅号(ID:QunarTL)
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn返回搜狐,查看更多
责任编辑: