前言:
热加载可以在修改完代码后,不重启应用,实现类信息更新,以节省开发时等待启动时间。本文主要从热加载概念、原理、常见框架、实现等角度为你揭开热加载的层层面纱。
但是,美团的远程热部署框架Sonic还区分了本地热部署、远程热部署,但感觉本质上还是属于热加载的范畴。
热加载一般基于以下三方面技术实现:
热加载是在不重启 Java 虚拟机的前提下,能自动侦测到 class 文件的变化,更新运行时 class 的行为。
Java 类是通过 Java 虚拟机加载的,某个类的 class 文件在被 classloader 加载后,会生成对应的 Class 对象,之后就可以创建该类的实例。默认的虚拟机行为只会在启动时加载类,如果后期有一个类需要更新的话,单纯替换编译的 class 文件,Java 虚拟机是不会更新正在运行的 class。
首先,需要了解一下 Java 虚拟机现有的加载机制,即双亲委派。系统在使用一个 classloader 来加载类时,会先询问当前 classloader 的父类是否有能力加载,如果父类无法实现加载操作,才会将任务下放到该 classloader 来加载。
这种自上而下的加载方式的好处是,让每个 classloader 执行自己的加载任务,不会重复加载类。但是,这种方式却使加载顺序非常难改变,让自定义 classloader 抢先加载需要监听改变的类成为了一个难点。
虽然,无法抢先加载该类,但是仍然可以用自定义 classloader 创建一个功能相同的类,让每次实例化的对象都指向这个新的类。当这个类的 class 文件发生改变的时候,再次创建一个更新的类,之后如果系统再次发出实例化请求,创建的对象讲指向这个全新的类。
如果需要兼容一些框架进行热加载,需要另外对框架中的文件进行监听并处理相应的重载的逻辑。
Java中的类从被加载到内存中到卸载出内存为止,一共经历了七个阶段:加载、验证、准备、解析、初始化、使用、卸载。 在加载的阶段,虚拟机通过类加载器需要完成以下三件事:
官方定义的Java类加载器有BootstrapClassLoader、ExtClassLoader、AppClassLoader。这三个类加载器分别负责加载不同路径的类的加载,并形成一个父子结构。
默认情况下(即使用关键字new或者Class.forName)都是通过AppClassLoader类加载器来加载的。 如果要加载一个类,会优先将此类交给其父类进行加载(直至顶层的BootstrapClassLoader),如果父类都没有此类,那么才会将此类交给子类加载。因此,双亲委派模型能够保证类在内存中的唯一性。
相关知识:Agent字节码增强、Classloader、Javassist、Spring源码、Spring MVC 源码 、Spring Boot源码等。
ASM:ASM修改class文件流程(责任链模式):首先使用一个 ClassReader 读入字节码,然后利用 ClassVisitor 做个性化的修改,最后利用 ClassWriter 输出修改后的字节码。
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassReader cr = null;
String enhancedClassName = classSource.getEnhancedName();
try {
cr = new ClassReader(new FileInputStream(classSource.getFile()));
} catch (IOException e) {
e.printStackTrace();
return null;
}
ClassVisitor cv = new EnhancedModifier(cw,
className.replace(".", "/"),
enhancedClassName.replace(".", "/"));
cr.accept(cv, 0);
JavaAgent:
spring-boot-devtools 是一个为开发者服务的一个模块。
原理: spring-boot-devtools会检测类路径的变化,当类路径内容发生变化后会自动重启应用程序。Spring Boot的重启技术通过使用两个类加载器。
由于使用的是双类加载机制重启会非常快,如果启动较慢也可使用JRebel重加载技术。
对于resources目录下的HTML,CSS等静态资源的增加、修改不会导致应用重启(DevTools自动触发的),只有对Java类文件的增加、修改DevTools才会自动重启应用。
从JDK1.4提供的技术,运行开发人员在debug过程中能够立即重载修改后的class。 但是,这个技术也有限制:只允许修改方法体,不允许增加新的class、不允许新增字段、不允许新增方法、不允许修改方法签名,热加载后类的静态属性不能初始化,不支持spring、ibatis等常见框架。 https://developer.aliyun.com/article/65023
JRebel可以当做HotSwap的增强版本,允许修改class结构:新增方法、字段、构造器、注解、新增class、修改配置文件。 JRebel通过Java Agent监控系统中的classes和resources文件在工作空间的变化,然后在运行的应用服务器上热加载这些变化,支持下面的这些类型的文件改变:
JRebel在Classloader级别上整合到JVM上,JRebel并没有在自定义Classloader,它只是很暴力的修改了JVM中Classloader的一些方法体逻辑,通过ASM和JDK instrumentation的机制把ClassLoader的部分方法(包括native方法)的逻辑重写,使之能够管理重载的class文件。JRebel能够对应用中的任何class起作用,也不会导致任何和Classloader相关的问题。
当一个class需要被加载,JRebel会在classpath或者rebel.xml配置指定的路径中试图查找相应的class文件。如果找到class文件,JRebel通过agent机制instrument这个class,并且维护class和class文件的关联关系。当应用中已经加载的class对应的class文件的修改时间变动后,扩展的Classloader就会被触发来加载新的class(Classloader并不会主动加载,而是在每次使用这个class的时候,check timestamp决定是否要加载class文件)。
此外,JRebel同样能够监控rebel.xml上配置的JARs中的class文件。
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the JRebel configuration file. It maps the running application to your IDE workspace, enabling JRebel reloading for this project.
Refer to https://manuals.jrebel.com/jrebel/standalone/config.html for more information.
-->
<application generated-by="intellij" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.zeroturnaround.com" xsi:schemaLocation="http://www.zeroturnaround.com http://update.zeroturnaround.com/jrebel/rebel-2_3.xsd">
<id>distribute-sale-service</id>
<classpath>
<dir name="${rebel.projectpath.distribute-sale-service}/target/classes">
</dir>
</classpath>
</application>
spring-boot-devtools与JRebel的区别:
JRebel
加载的速度优于 devtools
devtools
方式的热加载在功能上有限制,方法内的修改可以实现热加载,但新增的方法或者修改方法参数之后热加载是不生效的。
https://www.cnblogs.com/sfnz/p/14157833.html由于JVM限制,JDK 7和JDK 8都不允许改类结构,比如新增字段,新增方法和修改类的父类等,这对于Spring项目来说是致命的。 Sonic使用的是Dcevm。Dcevm(DynamicCode Evolution Virtual Machine)是Java Hostspot的补丁(严格上来说是修改),允许(并非无限制)在运行环境下修改加载的类文件。当前虚拟机只允许修改方法体(Method,Body),而Decvm可以增加、删除类属性、方法,甚至改变一个类的父类, Sonic插件由4大部分组成,包括脚本端、插件端、Agent端,以及Sonic服务端。
Sonic通过NIO监听本地文件(Class文件、XML文件)变更,通过IDEA插件来部署到远程/本地,触发文件变更事件,例如Class新增、Class修改、Spring Bean重载等事件流程。
单个文件的生命周期:
当监听本地文件(Class文件、XML文件)变更时,会触发文件变更事件,主要有类重载、Spring Bean重载、Spring XML重载、MyBatis重载等事件。
前置知识:Agent字节码增强、Javassist、Classloader
Urlclasspath为当前项目的lib文件件下,用于存放新增或修改的Class,以便类加载器可以正确的找到上传的Class。
如何增强类加载器?
JAVA的类加载器维护了一组URL,这些URL可以是jar包,也可以是File目录,当运行期间需要用到Class时, JVM会从对应的类加载器中按照顺序来遍历这些URL在前面的URL优先级最高,Sonic通过在用户代码启动期间通过插桩的方式来将自定义的目录放到对应的类加载器中URL的首位, RD修改的文件都会在这里,这样 JVM查找加载Class都会优先从修改的文件中查找。
前置知识:Spring源码、Spring MVC 源码 、Spring Boot源码 为什么要重载Spring Bean?
通过JVM HotSwap修改Class字节码之后,仅仅只是修改了字节码本身,而对现存于堆中已经实例化好的对象本身而言确没有任何变化,在Spring中大量使用@AutoWired等等在启动期间初始化的Bean是旧的实例,需要重新加载它们,以保证被Spring持有的 Bean是最新的。
修改ClassB之后,通过JVM热部署Class。但此时仅仅修改了在方法区内的类结构,对Spring框架来说,BeanA持有的BeanB没有任何变化,如果此时不对SpringA进行重载,那么通过SpringA拿到的B还是最先持有的对象,此时C一定是空的。
如何进行重载?
如果没有维护父子上下文的对应关系,当修改Java Class D时,通过Spring ClasspathScan扫描校验当前修改的Bean是否Sprin Bean(注解校验),然后触发销毁流程(BeanDefinitionRegistry.removeBeanDefinition),此方法会将当前Spring上下文中的Bean D和依赖Spring Bean D的Bean C一并销毁,但是作用范围仅仅在当前Spring上下文。如果C被子上下文中的Bean B依赖,就无法更新子上下文中的依赖关系,当有系统请求时,Bean B中关联的Bean C还是热部署之前的对象,所以热部署失败。
因此,在Spring初始化过程中,需要维护父子上下文的对应关系,当子上下文变时若变更范围涉及到Bean B时,需要重新更新子上下文中的依赖关系,当有多上下文关联时需要维护多上下文环境,且当前上下文环境入口需要Reload。 对不同的流量入口,采用不同的Reload策略:
当用户修改/新增Spring XML时,需要对XML中所有Bean进行重载。
重新Reload之后,将Spring销毁后重启。需要注意的是:XML修改方式改动较大,可能涉及到全局的AOP的配置以及前置和后置处理器相关的内容,影响范围为全局,所以目前只放开普通的XML Bean标签的新增/修改。
前置知识: MyBatis源码解析 Spring MyBatis热部署的主要处理流程:
整个项目分为三个部分,idea插件,测试机上的hotreload-web,和一个hotreload-agent。 idea插件负责编译修改完的代码,把修改后的class上传给测试的hotreload-web,然后hotreload-web会动态链接到你的目标Java进程。 动态链接使用的是java attach模块的功能,链接的同时会加载hotreload-agent。 链接上之后,hotreload-agent启动可以获得一个Instrumentation
对象,通过 Instrumentation
对象的 retransformClasses
便可以实现类的重定义,也就是热更新了。 Github:https://github.com/liuzhengyang/lets-hotfix
(1) 自定义类加载器
继承 ClassLoader
并重写里面 findClass
的方法。类加载器是通过 双亲委派模型
实现(除了一个最顶层的类加载器之外,每个类加载器都要有父加载器,而加载时,会先询问父加载器能否加载,如果父加载器不能加载,则会自己尝试加载),所以还需要指定父加载器。
public class MyClasslLoader extends ClassLoader {
/** 要加载的 Java 类的 classpath 路径 */
private String classpath;
public MyClasslLoader(String classpath) {
//指定父加载器
super(ClassLoader.getSystemClassLoader());
this.classpath = classpath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = this.loadClassData(name);
return this.defineClass(name, data, 0, data.length);
}
/**
* 加载 class 文件中的内容
*/
private byte[] loadClassData(String name) {
try {
//传进来是带包名的
name = name.replace(".", "//");
FileInputStream inputStream = new FileInputStream(new File(classpath + name + ".class"));
//定义字节数组输出流
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int b = 0;
while ((b = inputStream.read()) != -1) {
baos.write(b);
}
inputStream.close();
return baos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
(2) 定义要热加载的类
接口:
public interface BaseManager {
public void logic();
}
实现类:
public class MyManager implements BaseManager {
@Override
public void logic() {
System.out.println(LocalTime.now() + ": Java类的热加载");
}
}
类的热加载只有在类的信息被更改然后重新编译之后才重新加载,为了避免重复加载,因此,需要一个类用来记录某个类对应的某个类加载器以及上次加载的 class 的修改时间。
封装加载类信息的类:
@Data
public class LoadInfo {
/** 自定义的类加载器 */
private MyClasslLoader myClasslLoader;
/** 记录要加载的类的时间戳-->加载的时间 */
private long loadTime;
/** 需要被热加载的类 */
private BaseManager manager;
public LoadInfo(MyClasslLoader myClasslLoader, long loadTime) {
this.myClasslLoader = myClasslLoader;
this.loadTime = loadTime;
}
}
(3 )热加载获取类信息
使用一个简单的工厂模式检查类是否被更新,以及是否需要重新加载。
JVM判断两个类对象是否相同的依据:一是类全称;一个是类加载器。也就是说,同一个类加载器无法同时加载两个相同名称的类。 这种方式是通过每次都new一个新的自定类加载器的方式避免类相同。
public class ManagerFactory {
/** 记录热加载类的加载信息 */
//key-classNmae value-LoadInfo
private static final Map<String, LoadInfo> loadTimeMap = new HashMap<>();
/** 要加载的类的完整路径 classpath */
public static final String CLASS_PATH = "/Users/sinxu/Documents/project/hot-deployment/target/classes/";
/** 实现热加载的类的全名称(包名+类名 ) */
public static final String MY_MANAGER = "com.test.classloader.MyManager";
/** 通过类名获取类信息,没修改取缓存,新增或修改则加载并缓存 **/
public static BaseManager getManager(String className) {
File loadFile = new File(CLASS_PATH + className.replaceAll("\\.", "/") + ".class");
// 获取最后一次修改时间
long lastModified = loadFile.lastModified();
System.out.println("当前的类时间:" + lastModified);
// loadTimeMap 不包含 ClassName 为 key 的信息,证明这个类没有被加载,要加载到 JVM
if (loadTimeMap.get(className) == null) {
load(className, lastModified);
} // 加载类的时间戳变化了,我们同样要重新加载这个类到 JVM。
else if (loadTimeMap.get(className).getLoadTime() != lastModified) {
load(className, lastModified);
}
return loadTimeMap.get(className).getManager();
}
/**
* 加载 class ,缓存到 loadTimeMap
*/
private static void load(String className, long lastModified) {
MyClasslLoader myClasslLoader = new MyClasslLoader(className);
Class loadClass = null;
// 加载
try {
loadClass = myClasslLoader.loadClass(className);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
BaseManager manager = newInstance(loadClass);
LoadInfo loadInfo = new LoadInfo(myClasslLoader, lastModified);
loadInfo.setManager(manager);
loadTimeMap.put(className, loadInfo);
}
/**
* 以反射的方式创建 BaseManager 的子类对象
*/
private static BaseManager newInstance(Class loadClass) {
try {
return (BaseManager)loadClass.getConstructor(new Class[] {}).newInstance(new Object[] {});
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
return null;
}
}
(4) 监控class文件
/**
* 后台启动一条线程,不断检测是否要刷新重新加载,实现了热加载的类
*/
public class MsgHandle implements Runnable {
@Override
public void run() {
while (true) {
BaseManager manager = ManagerFactory.getManager(ManagerFactory.MY_MANAGER);
manager.logic();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
(5) 测试
public class ClassLoadTest {
public static void main(String[] args) {
new Thread(new MsgHandle()).start();
}
}
可优化点 | 优化措施 |
---|---|
每个类都要写一个接口,然后通过反射生成对象 | 使用ASM修改class文件;重定义原始类,先将原来的类变成接口 |
每次类变更,需要重新new一个类加载器,开销太大 | 使用ASM修改class文件;让每次加载的类都保存成一个带有版本信息的 class,比如加载 Test.class 时,保存在内存中的类是 Test_v1.class,当类发生改变时,重新加载的类名是 Test_v2.class。 |
改变 JDK classloader的加载行为,使它指向自定义加载器的加载行为,对代码侵略性太强 | 使用Java Agen;在 JVM 启动之后,应用启动之前,拦截默认加载器,使用自定义类加载进行加载,替换默认加载的class文件。 |
(1) 重定义原始类,先将原来的类变成接口
将读取的 class 文件的类名做一些修改,加载成一个全新名字的派生类
public Class<?> redefineClass(String className){
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassReader cr = null;
ClassSource cs = classFiles.get(className);
if(cs==null){
return null;
}
try {
//load 原始类的 class 文件
cr = new ClassReader(new FileInputStream(cs.getFile()));
} catch (IOException e) {
e.printStackTrace();
return null;
}
//增强组件 ClassModifier,作用是修改原始类的类型,将它转换成接口,原始类的所有方法逻辑都会被去掉
ClassModifier cm = new ClassModifier(cw);
cr.accept(cm, 0);
byte[] code = cw.toByteArray();
return defineClass(className, code, 0, code.length);
}
(2) 定义子类,生成的子类都实现这个接口,即原始类,并且复制原始类中的所有方法逻辑
之后如果该类需要更新,会生成一个新的派生类,也会实现这个接口。这样做的目的是不论如何修改,同一个 class 的派生类都实现一个共同的接口,他们之间的转换变得对外不透明。
为什么要改变原有的类名?
JVM判断两个类对象是否相同的依据:一是类全称;一个是类加载器。
通过修改类名,避免类加载时出现类对象相同的问题(比如,让每次加载的类都保存成一个带有版本信息的 class)。
// 在 class 文件发生改变时重新定义这个类
private Class<?> redefineClass(String className, ClassSource classSource){
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassReader cr = null;
classSource.update();
String enhancedClassName = classSource.getEnhancedName();
try {
cr = new ClassReader(new FileInputStream(classSource.getFile()));
} catch (IOException e) {
e.printStackTrace();
return null;
}
//EnhancedModifier,这个增强组件的作用是改变原有的类名
EnhancedModifier em = new EnhancedModifier(cw, className.replace(".", "/"),
enhancedClassName.replace(".", "/"));
//ExtendModifier,改变原有类的父类,让这个修改后的派生类能够实现同一个原始类(此时原始类已经转成接口了)
ExtendModifier exm = new ExtendModifier(em, className.replace(".", "/"),
enhancedClassName.replace(".", "/"));
cr.accept(exm, 0);
byte[] code = cw.toByteArray();
classSource.setByteCopy(code);
Class<?> clazz = defineClass(enhancedClassName, code, 0, code.length);
classSource.setClassCopy(clazz);
return clazz;
}
自定义 classloader 还有一个作用是监听会发生改变的 class 文件,classloader 会管理一个定时器,定时依次扫描这些 class 文件是否改变。
(3) 改变创建对象的行为
Java 虚拟机常见的创建对象的方法有两种,一种是静态创建,直接 new 一个对象,一种是动态创建,通过反射的方法,创建对象。 由于已经在自定义加载器中更改了原有类的类型,把它从类改成了接口,所以这两种创建方法都无法成立。我们要做的是将实例化原始类的行为变成实例化派生类。 对于第一种方法,需要做的是将静态创建,变为通过 classloader 获取 class,然后动态创建该对象。
ITestClass testClass = (ITestClass)MyClassLoader.getInstance().
findClass("com.example.TestClass").newInstance();
对于第二种创建方法,需要通过修改 Class.forName()和 ClassLoader.findClass()的行为,使他们通过自定义加载器加载类。
因此,这里需要用到 ASM 来修改 class 文件了,查找到所有 new 对象的语句,替换成通过 classloader 的形式来获取对象的形式。
@Override
public void visitTypeInsn(int opcode, String type) {
if (opcode==Opcodes.NEW && type.equals(className)) {
List<LocalVariableNode> variables = node.localVariables;
String compileType = null;
for(int i = 0; i < variables.size(); i++){
LocalVariableNode localVariable = variables.get(i);
compileType = formType(localVariable.desc);
if(matchType(compileType) && !valiableIndexUsed[i]) {
valiableIndexUsed[i] = true;
break;
}
}
mv.visitMethodInsn(Opcodes.INVOKESTATIC, CLASSLOAD_TYPE, "getInstance", "()L" + CLASSLOAD_TYPE + ";");
mv.visitLdcInsn(type.replace("/", "."));
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, CLASSLOAD_TYPE, "findClass", "(Ljava/lang/String;)Ljava/lang/Class;");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", "newInstance", "()Ljava/lang/Object;");
mv.visitTypeInsn(Opcodes.CHECKCAST, compileType);
flag = true;
} else {
mv.visitTypeInsn(opcode, type);
}
}
(4) 使用 JavaAgent 拦截默认加载器的行为
之前实现的类加载器已经解决了热加载所需要的功能。可是 JVM 启动时,默认加载程序的是AppClassLoader,并不会用自定义的加载器加载 classpath下的所有 class 文件,如果之后用自定义加载器重新加载已经加载的 class,有可能会出现 LinkageError 的 exception。所以必须在应用启动之前,重新替换已经加载的 class。
java.lang.LinkageError
attempted duplicate class definition
如果在 jdk1.4 之前,能使用的方法只有一种,改变 jdk 中 classloader 的加载行为,使它指向自定义加载器的加载行为。 但在 jdk5.0 之后,有了另一种侵略性更小的办法,即 Java Agent 方法,Java Agent 可以在 JVM 启动之后,应用启动之前的短暂间隙,提供空间给用户做一些特殊行为。比较常见的应用,是利用 JavaAgent 做面向切面的编程,在方法间加入监控日志等。利用 JavaAgent替换原始字节码,阻止原始字节码被 Java 虚拟机加载。
编写Agent
public class ReloadAgent {
public static void premain(String agentArgs, Instrumentation inst){
GeneralTransformer trans = new GeneralTransformer();
inst.addTransformer(trans);
}
}
然后,再编写一个 manifest 文件,将 Premain-Class属性设置成定义一个拥有 premain方法的类名即可。 生成一个包含这个 manifest 文件的 jar 包。
manifest-Version: 1.0
Premain-Class: com.example.ReloadAgent
Can-Redefine-Classes: true
在执行应用的参数中增加 -javaagent参数 , 加入这个 jar。这样在执行应用的之前,会优先执行 premain方法中的逻辑,并且预解析需要加载的 class。
(5) 替换 class
虽然,无法抢先加载该类,可以利用 JavaAgent拦截默认加载器,使用自定义 classloader 创建一个功能相同的类,替换默认加载的class文件,让每次实例化的对象都指向这个新的类。 只需要实现 一个 ClassFileTransformer的接口,利用这个实现类完成 class 替换的功能。
@Override
public byte [] transform(ClassLoader paramClassLoader, String paramString,
Class<?> paramClass, ProtectionDomain paramProtectionDomain,
byte [] paramArrayOfByte) throws IllegalClassFormatException {
String className = paramString.replace("/", ".");
if(className.equals("com.example.TestClass")){
MyClassLoader cl = MyClassLoader.getInstance();
cl.redefineClass(className);
return cl.getByteCode(className);
}
return null;
}
其他优化:
https://www.jianshu.com/p/90f149d6cf95
https://younghz.github.io/java-hot-load
https://mp.weixin.qq.com/s/TTnWr-rHqFZl14FSeWX9sg
https://github.com/niumoo/lab-notes/
https://github.com/liuzhengyang/lets-hotfix
https://blog.csdn.net/zyq1084577627/article/details/105710844
https://blog.csdn.net/a724888/article/details/121798784
https://blog.krybot.com/a?ID=01500-3e0fb2cf-7663-45db-92e6-116a24c800d3
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。