JAVA热部署,通过agent进行代码增量热替换!!!

 前言

在前说明:好久没有更新博客了,这一年在公司做了好多事情,包括代码分析和热部署替换等黑科技,一直没有时间来进行落地写出一些一文章来,甚是可惜,趁着中午睡觉的时间补一篇介绍性的文章吧。

首先热部署的场景是这样的,公司的项目非常多,整个BU事业部的项目加起来大约上几百个项目了,有一些项目本地无法正常启动,所以一些同学在修改完代码,或者是在普通的常规任务开发过程中都是盲改,然后去公司的代码平台进行发布,恶心的事情就在这里,有的一些项目从构建到发布运行大约30分钟,所以每次修改代码到代码见效需要30分钟的周期,这个极大的降低了公司的开发效率,一旦惰性成习惯,改变起来将十分的困难,所以我们极需要一个在本地修改完代码之后,可以秒级在服务端生效的神器,这样,我们的热部署插件就诞生了。

热部署在业界本身就是一个难啃的骨头,属于逆向编程的范畴,JVM有类加载,那么热部署就要去做卸载后重新加载,Spring有上下文注册,spring Bean执行初始化生命周期,热部署就要去做类的销毁,重新初始化,里面设计到的细节点非常之多,业界的几款热部署的处理方式也不尽相同,由于需要巨大的底层细节需要处理,所以目前上想找到一个完全覆盖所有功能的热部署插件是几乎不可能的,一般大家听到的热部署插件主要是国外的一些项目比如商业版本的jrebel,开源版的springloaded,以及比较粗暴的spring dev tools。当前这些项目都是现成的复杂开源项目或者是闭包的商业项目,想去自行修改匹配自己公司的项目,难度是非常之大。闲话少说,进入正文

前言一:什么是热部署

所谓热部署,就是在应用正在运行的时候升级软件,却不需要重新启动应用。对于Java应用程序来说,热部署就是在运行时更新Java类文件,同时触发spring的一些列重新加载过程。在这个过程中不需要重新启动,并且修改的代码实时生效

前言二:为什么我们需要热部署

RD每天本地重启服务5-12次,单次大概3-8分钟,每天向Cargo部署3-5次,单次时长20-45分钟,部署频繁频次高、耗时长。插件提供的本地和远程热部署功能可让将代码变更秒级生效,RD日常工作主要分为开发自测和联调两个场景,下面分别介绍热部署在每个场景中发挥的作用:

 

 

 

前言三:热部署难在哪,为什么业界没有好用的开源工具

热部署不等同于热重启,像tomcat或者spring boot tool dev这种热重启相当于直接加载项目,性能较差,增量文件热部署难度很大,需要兼容各种中间件和用户写法,技术门槛高,需要对JPDA(Java Platform Debugger Architecture)、java agent、字节码增强、classloader、spring框架、Mybatis框架等集成解决方案等各种技术原理深入了解才能全面支持各种框架,另外需要IDEA插件开发能力,形成整体的产品解决方案。现在有了热部署,代码就是任人打扮的小姑娘!

 

1、整体设计方案

 

 

2、走进agent

instrument 规范:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html?is-external=true

Class VirtualMachine:https://docs.oracle.com/javase/8/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html#loadAgent-java.lang.String-

Interface ClassFileTransformer:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html

2.1、JVM启动前静态Instrument

Javaagent是java命令的一个参数。参数 javaagent 可以用于指定一个 jar 包,并且对该 java 包有2个要求:

  1. 这个 jar 包的 MANIFEST.MF 文件必须指定 Premain-Class 项。

  2. Premain-Class 指定的那个类必须实现 premain() 方法。

premain 方法,从字面上理解,就是运行在 main 函数之前的的类。当Java 虚拟机启动时,在执行 main 函数之前,JVM 会先运行-javaagent所指定 jar 包内 Premain-Class 这个类的 premain 方法 。

在命令行输入 java可以看到相应的参数,其中有 和 java agent相关的:

-agentlib:<libname>[=<选项>] 加载本机代理库 <libname>, 例如 -agentlib:hprof
	另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
	按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
	加载 Java 编程语言代理, 请参阅 java.lang.instrument

  

该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型。其中,使用该软件包的一个关键组件就是 Javaagent。从名字上看,似乎是个 Java 代理之类的,而实际上,他的功能更像是一个Class 类型的转换器,他可以在运行时接受重新外部请求,对Class类型进行修改。

agent加载时序图

从本质上讲,Java Agent 是一个遵循一组严格约定的常规 Java 类。 上面说到 javaagent命令要求指定的类中必须要有premain()方法,并且对premain方法的签名也有要求,签名必须满足以下两种格式:

public static void premain(String agentArgs, Instrumentation inst)
    
public static void premain(String agentArgs)

JVM 会优先加载 带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。这个逻辑在sun.instrument.InstrumentationImpl

 

2.2、Instrumentation类常用API

public interface Instrumentation {

    //增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
    void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

    //在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,
     如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。
     对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
    void addTransformer(ClassFileTransformer transformer);

    //删除一个类转换器
    boolean removeTransformer(ClassFileTransformer transformer);
    
    //是否允许对class retransform
    boolean isRetransformClassesSupported();

    //在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
   
    //是否允许对class重新定义
    boolean isRedefineClassesSupported();

    //此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。
    //在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。
    //该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
    void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;

    //获取已经被JVM加载的class,有className可能重复(可能存在多个classloader)
    @SuppressWarnings("rawtypes")
    Class[] getAllLoadedClasses();
}

  

2.3、instrument原理:

instrument的底层实现依赖于JVMTI(JVM Tool Interface),它是JVM暴露出来的一些供用户扩展的接口集合,JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。JVMTIAgent是一个利用JVMTI暴露出来的接口提供了代理启动时加载(agent on load)、代理通过attach形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。而instrument agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是专门为java语言编写的插桩服务提供支持的代理。

2.3.1、启动时加载instrument agent过程:
  1. 创建并初始化 JPLISAgent;

  2. 监听 VMInit 事件,在 JVM 初始化完成之后做下面的事情:

    1. 创建 InstrumentationImpl 对象 ;

    2. 监听 ClassFileLoadHook 事件 ;

    3. 调用 InstrumentationImpl 的loadClassAndCallPremain方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法 ;

  3. 解析 javaagent 中 MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容。

2.3.2、运行时加载instrument agent过程:

通过 JVM 的attach机制来请求目标 JVM 加载对应的agent,过程大致如下:

  1. 创建并初始化JPLISAgent;

  2. 解析 javaagent 里 MANIFEST.MF 里的参数;

  3. 创建 InstrumentationImpl 对象;

  4. 监听 ClassFileLoadHook 事件;

  5. 调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class类的agentmain方法。

2.3.3、Instrumentation的局限性

大多数情况下,我们使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,但是有以下的局限性:

  1. premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。

  2. 类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:

    1. 新类和老类的父类必须相同;

    2. 新类和老类实现的接口数也要相同,并且是相同的接口;

    3. 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;

    4. 新类和老类新增或删除的方法必须是private static/final修饰的;

    5. 可以修改方法体。

除了上面的方式,如果想要重新定义一个类,可以考虑基于类加载器隔离的方式:创建一个新的自定义类加载器去通过新的字节码去定义一个全新的类,不过也存在只能通过反射调用该全新类的局限性。

2.4、那些年JVM和Hotswap之间的相爱相杀

围绕着method body的hotSwap JVM一直在进行改进

1.4开始JPDA引入了hotSwap机制(JPDA Enhancements),实现了debug时的method body的动态性

参照:https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/enhancements1.4.html

1.5开始通过JVMTI实现的java.lang.instrument (Java Platform SE 8 ) 的premain方式,实现了agent方式的动态性(JVM启动时指定agent)

参照:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html

1.6又增加了agentmain方式,实现了运行时动态性(通过The Attach API 绑定到具体VM)。

参照:https://blogs.oracle.com/corejavatechtips/the-attach-api

其基本实现是通过JVMTI的retransformClass/redefineClass进行method body级的字节码更新,ASM、CGLib之类基本都是围绕这些在做动态性。

但是针对Class的hotSwap一直没有动作(比如Class添加method,添加field,修改继承关系等等),为什么?因为复杂度高并且没有太高的回报。

2.5、如何解决Instrumentation的局限性

由于JVM限制,JDK7和JDK8都不允许都改类结构,比如新增字段,新增方法和修改类的父类等,这对于spring项目来说是致命的,假设小龚同学想修改一个spring bean,新增了一个@Autowired字段,这种场景在实际应用时很多,所以我们对这种场景的支持必不可少。

那么我们是如何做到的呢,下面有请大名鼎鼎的dcevm,dcevm(DynamicCode Evolution Virtual Machine)是java hostspot的补丁(严格上来说是修改),允许(并非无限制)在运行环境下修改加载的类文件.当前虚拟机只允许修改方法体(method bodies),decvm,可以增加 删除类属性、方法,甚至改变一个类的父类、dcevm 是一个开源项目,遵从GPL 2.0、更多关于dcevm的介绍:

https://www.cnblogs.com/redcreen/archive/2011/06/03/2071169.html

https://www.slideshare.net/wangscu/hotspot-hotswap-who-and-who-are-best-freinds

https://www.cnblogs.com/redcreen/archive/2011/06/14/2080718.html

https://dl.acm.org/doi/10.1145/2076021.2048129

http://ssw.jku.at/Research/Papers/Wuerthinger11PhD/

http://ssw.jku.at/Research/Papers/Wuerthinger10a/

https://dl.acm.org/doi/10.1145/1868294.1868312

https://dl.acm.org/doi/10.1145/1890683.1890688

3、热部署技术解析

3.1、文件监听

热部署启动时首先会在本地和远程预定义两个目录,/var/tmp/xxx/extraClasspath和/var/tmp/xxx/classes,extraClasspath为我们自定义的拓展classpath url,classes为我们监听的目录,当有文件变更时,通过idea插件来部署到远程/本地,触发agent的监听目录,来继续下面的热加载逻辑,为什么我们不直接替换用户的classPath下面的资源文件呢,因为业务方考虑到war包的api项目,和spring boot项目,都是以jar包来启动的,这样我们是无法直接修改用户的class文件的,即使是用户项目我们可以修改,直接操作用户的class,也会带来一系列的安全问题,所以我们采用了拓展classPath url来实现文件的修改和新增,并且有这么一个场景,多个业务侧的项目引入了相同的jar包,在jar里面配置了mybatis的xml和注解,这种情况我们没有办法直接来修改jar包中源文件,通过拓展路径的方式可以不需要关注jar包来修改jar包中某一文件和xml,是不是很炫酷,同理这种方法可以进行整个jar包的热替换(方案设计中)。下面简单介绍一下核心监听器,

3.2、jvm class reload

JVM的字节码批量重载逻辑,通过新的字节码二进制流和旧的class对象生成ClassDefinition定义,instrumentation.redefineClasses(definitions),来触发JVM重载,重载过后将触发初始化时spring插件注册的transfrom,下一章我们简单讲解一下spring是怎么重载的。

新增class我们如何保证可以加载到classloader上下文中?由于项目在远程执行,所以运行环境复杂,有可能是jar包方式启动(spring boot),也有可能是普通项目,也有可能是war web项目,针对这种情况我们做了一层classloader url拓展

User classLoader是框架自定义的classLoader统称,例如Jetty项目是WebAppclassLoader,其中Urlclasspath为当前项目的lib文件件下,例如spring boot项目也是从当前项目中BOOT-INF/lib/,等等,不同框架的自定义位置稍有不同。所以针对这种情况 我们必须拿到用户的自定义classloader,如果常规方式启动的,比如普通spring xml项目借助plus发布,这种没有自定义classloader,是默认AppClassLoader,所以我们在用户项目启动过程中借助agent字节码增强的方式来获取到真正的用户classloader。

我们做的事情:找到用户使用的子classloader之后通过反射的方式来获取classloader中的元素Classpath,其中classPath中的URL就是当前项目加载class时需要的所有运行时class环境,并且包括三方的jar包依赖等。

我们获取到URL数组,把我们自定义的拓展classpath目录加入到URL数组的首位,这样当有新增class时,我们只需要将class文件放到拓展classpath对应的包目录下面即可,当有其他bean依赖新增的class时,会从当前目录下面查找类文件。

为什么不直接对Appclassloader进行加强?而是对框架的自定义classloader进行加强

 

考虑这样一个场景,框架自定义类加载器中有ClassA,然后这个时候用户新增了一个Class B需要热加载,B class里面有A的引用关系,如果我们增强AppClassLoader时,初始化B实例时ClassLoader.loadclass首先从UserClassLoader开始找classB,依靠双亲委派原则,B是被Appclassloader加载的,因为B依赖了类A,所以当前AppClassLoader加载B一定是找不到的,这个时候汇报ClassNotFoundException。也就是说我们对类加载器拓展一定要拓展最上层的类加载器,这样才会达到我们想要的效果。

3.3、spring bean重载

spring bean reload过程中,bean的销毁和重启流程,其中细节点涉及的比较多。主要内容如下图展示:

 

 

 

首先当修改java class D时,通过spring classpathScan扫描校验当前修改的bean是否是spring bean(注解校验)然后触发销毁流程(BeanDefinitionRegistry.removeBeanDefinition)此方法会将当前spring 上下文中的 bean D 和依赖 spring bean D的 Bean C 一并销毁,但是作用范围仅仅在当前spring 上下文,若C被子上下文中的Bean B 依赖,是无法更新子上下文中的依赖关系的,此时,当有流量打进来,Bean B中关联的Bean C还是热部署之前的对象,所以热部署失败,所以我们在spring初始化过程中,需要维护一个父子上下文的对应关系,当子上下文变时若变更范围涉及到Bean B时,需要重新更新子上下文中的依赖关系,所以当有多上下文关联时需要维护多上下文环境,并且当前上下文环境入口需要reload。入口指:spring mvc controller,Mthrift和pigeon,对不同的流量入口,我们采用不同的reload策略。RPC框架入口主要操作为解绑注册中心,重新注册,重新加载启动流程等,对Spring mvc controller主要是解绑和注册url Mappping来实现流量入口类的变化切换

 

3.4、spring xml重载

当用户修改/新增spring xml时,需要对xml中所有bean进行重载

 

 

重新reload之后,将spring 销毁后重启。

注意:xml修改方式改动较大,可能涉及到全局的Aop的配置以及前置和后置处理器相关的内容,影响范围为全局,所以目前只放开普通的xml bean标签的新增/修改,其他能力酌情逐步放开。

3.5、mybatis xml 重载

 

4、远程反编译

在代码中通过插件右键-远程反编译即可查看当前classpath下面最新编译的最新class文件,这是如何办到的的呢,核心代码如下:

agentString+= "try {\n" +
                "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" +
                "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );\n" +
                "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"enhanceUserClassLoader\",new java.lang.Class[0]);\n" +
                "\t\t\tmethod.setAccessible ( true );\n" +
                "\t\t\tmethod.invoke ( null, new Object[0]);\n" +
                "\t\t} catch (java.lang.Exception e){\n" +
                "\t\t\te.printStackTrace (  );\n" +
                "\t\t}";

上面代码是在用户侧启动DefaultListableBeanFactory时,初始化所有bean之后完成的,在方法preInstantiateSingletons之后会对当前用户侧classloader进行反向持有+ 路径增强。

public static void enhanceUserClassLoader(){
        if(springbootClassLoader != null){
            LOGGER.info ( "对用户classloader进行增强,springbootClassLoader:" + springbootClassLoader );
            URLClassLoaderHelper.prependClassPath ( springbootClassLoader );
            LOGGER.info ( "对用户classloader进行增强成功,springbootClassLoader:" + springbootClassLoader );
        }
    }

通过使用代码启动时反射增强classloader,下面来看看核心方法prependClassPath

public static void prependClassPath(ClassLoader classLoader){
        LOGGER.info ( "用户classloader增强,classLoader:" + classLoader );
        if(!(classLoader instanceof URLClassLoader)){
            return;
        }
        URL[] extraClasspath = PropertiesUtil.getExtraClasspath ();
        prependClassPath( (URLClassLoader) classLoader,extraClasspath);
    }

其中URL[] extraClasspath = PropertiesUtil.getExtraClasspath ();这里获取的是用户自定义的classpath,每次新增修改class之后都会放进去最新的资源文件。

 
public static void prependClassPath(URLClassLoader classLoader, URL[] extraClassPath) {
        synchronized (classLoader) {
            try {
                Field ucpField = URLClassLoader.class.getDeclaredField("ucp");
                ucpField.setAccessible(true);
                URL[] origClassPath = getOrigClassPath(classLoader, ucpField);
                URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length];
                System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length);
                System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length);
                Object urlClassPath = createClassPathInstance(modifiedClassPath);
                ExtraURLClassPathMethodHandler methodHandler = new ExtraURLClassPathMethodHandler(modifiedClassPath);
                ((Proxy)urlClassPath).setHandler(methodHandler);
                ucpField.set(classLoader, urlClassPath);
                LOGGER.debug("Added extraClassPath URLs {} to classLoader {}", Arrays.toString(extraClassPath), classLoader);
            } catch (Exception e) {
                LOGGER.error("Unable to add extraClassPath URLs {} to classLoader {}", e, Arrays.toString(extraClassPath), classLoader);
            }
        }
    }

只需关注

URL[] origClassPath = getOrigClassPath(classLoader, ucpField);

URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length];

System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length);

System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length);这几行代码

首先获取到用户侧classloader中URLClassPath的URLS,然后在通过反射的方式将用户配置的extclasspath的路径设置到URLS数组中的首位,这样每次调用URLClassLoader的findResource方法都会获取到最新的资源文件了。

5、我们支持的功能

功能点

是否支持

修改方法体内容

新增方法体

新增非静态字段

新增静态字段

spring bean中新增@autowired注解

在spring 扫描包base package下,新增带@Service的bean,并且注入

新增xml

增加修改静态块

新增修改匿名内部类

新增修改继承类

新增修改接口方法

新增泛型方法

修改 annotation sql(Mybatis)

修改 xml sql(Mybatis)

增加修改静态块

匿名内部类新增,修改

内部类新增,修改

新增,删除extend父类,implement 接口

父类或接口新增方法,删除方法

泛型方法,泛型类

多文件热部署

spring boot项目

war包项目

修改spring xml (只修改bean标签)

新增@Configuration @Bean

pigeon服务框架

@Transactional 注解新增/修改,注解参数修改

序列化 框架支持
dubbo alibaba
dubbo apache
dubbox
motan  ✅

删除继承的class

枚举 字段修改

修改static字段值

其他功能迭代挖掘ing

 

6、强大到令人窒息的多文件热部署以及源码交流 

由于篇幅原因和文采捉急,没有办法完整的写出热部署过程中遇到的各种各样稀奇古怪和无法解释的问题,和其中的坎坷经历。更多的功能需求迭代建议和agent源码技术交流可以加入QQ群来详细交流,QQ群号:825199617

 

 

  

posted on 2020-09-14 15:26  张玉龙  阅读(9086)  评论(12编辑  收藏  举报