热加载原理解析与实现

Posted 黄小斜

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了热加载原理解析与实现相关的知识,希望对你有一定的参考价值。

发布于2022-06-19 21:14:51阅读 5270

前言:

热加载可以在修改完代码后,不重启应用,实现类信息更新,以节省开发时等待启动时间。本文主要从热加载概念、原理、常见框架、实现等角度为你揭开热加载的层层面纱。

一. 热部署与热加载

概念:

  • 热部署(Hot Deploy):热部署针对的是容器或者是整个应用,包括运行需要使用到的各种文件(jar包、JS、CSS、html、配置文件),新的资源或者修改了一些代码,需要在不停机的情况下的重新加载整个应用;
  • 热加载(Hot Swap):热加载针对的是单个字节码文件,指的是重新编译后, 不需要停机 ,应用程序就可以加载使用新的class文件。

区别:

  • 热部署:针对整个应用,包括Jar包、class文件、配置文件等;会清空内存;热加载;
  • 热加载:一般只针对class文件(或者针对框架自定义一些重载逻辑);一般不会清空内存,有内存溢出风险;

但是,美团的远程热部署框架Sonic还区分了本地热部署、远程热部署,但感觉本质上还是属于热加载的范畴。

  • 本地热部署:则是能够在项目运行中感知到特定文件代码的修改而使项目不重新启动就能生效。
  • 远程热部署:则是本地代码改变之后,不用重新打包上传服务器重启项目就能生效,本地改变之后能够自动改变服务器上的项目代码。

实现:

热加载一般基于以下三方面技术实现:

  • 基于JVMTI接口。如 HotSwap,限制是只能修改方法体。
  • JRebel。使用基本无限制,并且能和大部分 IDE 以及框架融合。商用、收费。
  • 基于ClassLoader。如 OSGi、Tomcat。针对这种,考虑到 JVM 规范中单个类只能被 load/define 一次的约束,一般实现都是关闭旧的 ClassLoader 并创建新 ClassLoader 来实现。

二. 热加载原理

热加载是在不重启 Java 虚拟机的前提下,能自动侦测到 class 文件的变化,更新运行时 class 的行为。

Java 类是通过 Java 虚拟机加载的,某个类的 class 文件在被 classloader 加载后,会生成对应的 Class 对象,之后就可以创建该类的实例。默认的虚拟机行为只会在启动时加载类,如果后期有一个类需要更新的话,单纯替换编译的 class 文件,Java 虚拟机是不会更新正在运行的 class。

  • 方法1:最根本的方式是修改JVM的源代码,改变 classloader 的加载行为,使虚拟机能监听 class 文件的更新,重新加载 class 文件(JRebel和美团Sonic是使用这种方式的,但Sonic是使用了Dcevm)。
  • 方法2:创建自己的 classloader 来加载需要监听的 class,这样就能控制类加载的时机,从而实现热加载。

首先,需要了解一下 Java 虚拟机现有的加载机制,即双亲委派。系统在使用一个 classloader 来加载类时,会先询问当前 classloader 的父类是否有能力加载,如果父类无法实现加载操作,才会将任务下放到该 classloader 来加载。

这种自上而下的加载方式的好处是,让每个 classloader 执行自己的加载任务,不会重复加载类。但是,这种方式却使加载顺序非常难改变,让自定义 classloader 抢先加载需要监听改变的类成为了一个难点。

虽然,无法抢先加载该类,但是仍然可以用自定义 classloader 创建一个功能相同的类,让每次实例化的对象都指向这个新的类。当这个类的 class 文件发生改变的时候,再次创建一个更新的类,之后如果系统再次发出实例化请求,创建的对象讲指向这个全新的类。

如果需要兼容一些框架进行热加载,需要另外对框架中的文件进行监听并处理相应的重载的逻辑。

ClassLoader类加载(双亲委派模型)

Java中的类从被加载到内存中到卸载出内存为止,一共经历了七个阶段:加载、验证、准备、解析、初始化、使用、卸载。 在加载的阶段,虚拟机通过类加载器需要完成以下三件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流;
  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

官方定义的Java类加载器有BootstrapClassLoader、ExtClassLoader、AppClassLoader。这三个类加载器分别负责加载不同路径的类的加载,并形成一个父子结构。

默认情况下(即使用关键字new或者Class.forName)都是通过AppClassLoader类加载器来加载的。 如果要加载一个类,会优先将此类交给其父类进行加载(直至顶层的BootstrapClassLoader),如果父类都没有此类,那么才会将此类交给子类加载。因此,双亲委派模型能够保证类在内存中的唯一性。

其他

相关知识:Agent字节码增强ClassloaderJavassistSpring源码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:

  • 从1.4版本开始,JPDA引入HotSwap机制(JPDA Enhancements),实现Debug时的Method Body的动态性。
  • 1.5版本开始通过JVMTI实现的java.lang.instrument(Java Platform SE 8)的Premain方式,实现Agent方式的动态性(JVM启动时指定Agent);
  • 1.6版本又增加Agentmain方式,实现运行时动态性(通过The Attach API 绑定到具体VM)。基本实现是通过JVMTI的retransformClass/redefineClass进行method、body级的字节码更新,ASM、CGLib基本都是围绕这些在做动态性。但是针对Class的HotSwap一直没有动作(比如Class添加method、添加field、修改继承关系等等),为什么会这样呢?因为复杂度过高,且没有很高的回报。

三. 常见的热加载框架

spring-boot-devtools

spring-boot-devtools 是一个为开发者服务的一个模块。

原理: spring-boot-devtools会检测类路径的变化,当类路径内容发生变化后会自动重启应用程序。Spring Boot的重启技术通过使用两个类加载器。

  • Base Classloader (Base类加载器):加载不改变的Class,如第三方提供的jar包。
  • Restart Classloader(Restart类加载器):加载会更改的Class;代码更改的时候,原来的restart ClassLoader 被丢弃,重新创建一个restart ClassLoader,由于需要加载的类相比较少,所以实现了较快的重启时间(5秒以内)。

由于使用的是双类加载机制重启会非常快,如果启动较慢也可使用JRebel重加载技术。

对于resources目录下的HTML,CSS等静态资源的增加、修改不会导致应用重启(DevTools自动触发的),只有对Java类文件的增加、修改DevTools才会自动重启应用。

HotSwap

从JDK1.4提供的技术,运行开发人员在debug过程中能够立即重载修改后的class。 但是,这个技术也有限制:只允许修改方法体,不允许增加新的class、不允许新增字段、不允许新增方法、不允许修改方法签名,热加载后类的静态属性不能初始化,不支持spring、ibatis等常见框架。 漫谈JVM热加载技术(一)---目前常见的解决方案-阿里云开发者社区

JReble

JRebel可以当做HotSwap的增强版本,允许修改class结构:新增方法、字段、构造器、注解、新增class、修改配置文件。 JRebel通过Java Agent监控系统中的classes和resources文件在工作空间的变化,然后在运行的应用服务器上热加载这些变化,支持下面的这些类型的文件改变:

  • 改变Java classes文件;
  • 改变框架配置文件 (e.g. Spring XML files and annotations, Struts mappings, etc);
  • 任何静态资源文件 (e.g. JSPs, HTMLs, CSSs, XMLs, .properties, etc)。

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的区别:

  1. JRebel 加载的速度优于 devtools
  2. JRebel不仅仅局限于Spring Boot项目,可以用在任何的Java项目中。
  3. devtools 方式的热加载在功能上有限制,方法内的修改可以实现热加载,但新增的方法或者修改方法参数之后热加载是不生效的。 Java热加载(JRebel)与Devtools热部署 - 思凡念真 - 博客园

美团Sonic插件

由于JVM限制,JDK 7和JDK 8都不允许改类结构,比如新增字段,新增方法和修改类的父类等,这对于Spring项目来说是致命的。 Sonic使用的是Dcevm。Dcevm(DynamicCode Evolution Virtual Machine)是Java Hostspot的补丁(严格上来说是修改),允许(并非无限制)在运行环境下修改加载的类文件。当前虚拟机只允许修改方法体(Method,Body),而Decvm可以增加、删除类属性、方法,甚至改变一个类的父类, Sonic插件由4大部分组成,包括脚本端、插件端、Agent端,以及Sonic服务端。

  • 脚本端:负责自动化构建Sonic启动参数、服务启动等集成工作;
  • IDEA插件端:集成环境为开发者提供更便捷的热部署服务;
  • Agent端:随项目启动负责热部署的功能实现;
  • 服务端:负责收集热部署信息、失败上报等统计工作。

文件生命周期

Sonic通过NIO监听本地文件(Class文件、XML文件)变更,通过IDEA插件来部署到远程/本地,触发文件变更事件,例如Class新增、Class修改、Spring Bean重载等事件流程。

单个文件的生命周期:

文件变更事件

当监听本地文件(Class文件、XML文件)变更时,会触发文件变更事件,主要有类重载、Spring Bean重载、Spring XML重载、MyBatis重载等事件。

1.类重载

前置知识:Agent字节码增强JavassistClassloader

  • 修改class:通过新的字节码二进制流和旧的Class对象生成ClassDefinition定义,instrumentation.redefineClasses(definitions),来触发JVM重载,重载过后将触发初始化时Spring插件注册的Transfrom。
  • 新增class:自定义用户ClassLoader(UserClassLoader),通过反射的方式来获取Classloader中的元素Classpath,其中ClassPath中的URL就是当前项目加载Class时需要的所有运行时Class环境,并且包括三方的JAR包依赖等。Sonic获取到URL数组,把Sonic自定义的拓展Classpath目录加入到URL数组首位,这样当有新增Class时,Sonic只需要将Class文件复制到拓展Classpath对应的包目录下面即可,当有其他Bean依赖新增的Class时,会从当前目录下面查找类文件

Urlclasspath为当前项目的lib文件件下,用于存放新增或修改的Class,以便类加载器可以正确的找到上传的Class。

如何增强类加载器?

JAVA的类加载器维护了一组URL,这些URL可以是jar包,也可以是File目录,当运行期间需要用到Class时, JVM会从对应的类加载器中按照顺序来遍历这些URL在前面的URL优先级最高,Sonic通过在用户代码启动期间通过插桩的方式来将自定义的目录放到对应的类加载器中URL的首位, RD修改的文件都会在这里,这样 JVM查找加载Class都会优先从修改的文件中查找

2.Spring Bean重载

前置知识: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策略:

  • RPC框架入口主要操作为解绑注册中心、重新注册、重新加载启动流程等等
  • Spring MVC Controller,主要是解绑和注册URL Mappping来实现流量入口类的变化切换。

3.Spring XML重载

当用户修改/新增Spring XML时,需要对XML中所有Bean进行重载。

重新Reload之后,将Spring销毁后重启。需要注意的是:XML修改方式改动较大,可能涉及到全局的AOP的配置以及前置和后置处理器相关的内容,影响范围为全局,所以目前只放开普通的XML Bean标签的新增/修改。

4.MyBatis重载

前置知识: MyBatis源码解析 Spring MyBatis热部署的主要处理流程:

  1. 启动时:获取所有Configuration路径,并维护它和Spring Context的对应关系;
  2. 启动完成:将Configuration进行归类,缓存XML及注解的资源;
  3. 运行时:匹配Configuration,清空已加载的资源,重新加载Configuration;

Lest-hotfix

整个项目分为三个部分,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. 实现自己的类加载器。
  2. 从自己的类加载器中加载要热加载的类。
  3. 不断轮训要热加载的类 class 文件是否有更新。
  4. 如果有更新,重新加载。

实现思路

(1) 自定义类加载器

继承 ClassLoader 并重写里面 findClass 的方法。类加载器是通过 双亲委派模型 实现(除了一个最顶层的类加载器之外,每个类加载器都要有父加载器,而加载时,会先询问父加载器能否加载,如果父加载器不能加载,则会自己尝试加载),所以还需要指定父加载器。

  • 通过loadClass在指定的路径下查找文件。
  • 通过findClass方法解析class字节流,并实例化class对象。
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

Java 热加载/热部署技术 · 1907 🕥

帐号已迁移

https://github.com/niumoo/lab-notes/

https://github.com/liuzhengyang/lets-hotfix

自定义类加载实现_小怪兽你干啥的博客-CSDN博客

从Java的类加载机制谈起:聊聊Java中如何实现热部署(热加载)_黄小斜的博客-CSDN博客_java 模块化 热加载

In-depth exploration of Java hot deployment-turn - Krybot

Java系列 | 远程热部署在美团的落地实践

深入分析Java类加载器原理 - 掘金

以上是关于热加载原理解析与实现的主要内容,如果未能解决你的问题,请参考以下文章

热加载原理解析与实现

Tomcat实现热部署热加载原理解析

java服务器热部署的原理

Java:ClassLoader实现热加载原理的解析

ClassLoader热加载的简单实现

发生热模块重新加载时,故事书故事消失