前言
在前说明:很久没有更新博客了,这一年在公司作了好多事情,包括代码分析和热部署替换等黑科技,一直没有时间来进行落地写出一些一文章来,甚是惋惜,趁着中午睡觉的时间补一篇介绍性的文章吧。html
首先热部署的场景是这样的,公司的项目很是多,真个BU事业部的项目加起来大约上几百个项目了,有一些项目本地没法正常启动,因此一些同窗在修改完代码,或者是在普通的常规任务开发过程当中都是盲改,而后去公司的代码平台进行发布,恶心的事情就在这里,有的一些项目从构建到发布运行大约30分钟,因此每次修改代码到代码见效须要30分钟的周期,这个极大的下降了公司的开发效率,一旦惰性成习惯,改变起来将十分的困难,因此咱们极须要一个在本地修改完代码以后,能够秒级在服务端生效的神器,这样,咱们的热部署插件就诞生了。java
热部署在业界自己就是一个难啃的骨头,属于逆向编程的范畴,JVM有类加载,那么热部署就要去作卸载后从新加载,Spring有上下文注册,spring Bean执行初始化生命周期,热部署就要去作类的销毁,从新初始化,里面设计到的细节点很是之多,业界的几款热部署的处理方式也不尽相同,因为须要巨大的底层细节须要处理,因此目前上想找到一个彻底覆盖全部功能的热部署插件是几乎不可能的,通常你们听到的热部署插件主要是国外的一些项目好比商业版本的jrebel,开源版的springloaded,以及比较粗暴的spring dev tools。当前这些项目都是现成的复杂开源项目或者是闭包的商业项目,想去自行修改匹配本身公司的项目,难度是很是之大。闲话少说,进入正文程序员
前言一:什么是热部署
所谓热部署,就是在应用正在运行的时候升级软件,却不须要从新启动应用。对于Java应用程序来讲,热部署就是在运行时更新Java类文件,同时触发spring的一些列从新加载过程。在这个过程当中不须要从新启动,而且修改的代码实时生效web
前言二:为何咱们须要热部署
程序员天天本地重启服务5-12次,单次大概3-8分钟,天天向Cargo部署3-5次,单次时长20-45分钟,部署频繁频次高、耗时长。插件提供的本地和远程热部署功能可以让将代码变动秒级生效,RD平常工做主要分为开发自测和联调两个场景,下面分别介绍热部署在每一个场景中发挥的做用:spring
前言三:热部署难在哪,为何业界没有好用的开源工具
热部署不等同于热重启,像tomcat或者spring boot tool dev这种热重启至关于直接加载项目,性能较差,增量文件热部署难度很大,须要兼容各类中间件和用户写法,技术门槛高,须要对JPDA(Java Platform Debugger Architecture)、java agent、字节码加强、classloader、spring框架、Mybatis框架等集成解决方案等各类技术原理深刻了解才能全面支持各类框架,另外须要IDEA插件开发能力,造成总体的产品解决方案。如今有了热部署,代码就是任人打扮的小姑娘!sql
前言四:为何咱们不用spring boot devtools
有一些朋友问我,为何不直接使用spring boot devtools,有两方面缘由吧,第一它仅仅只使用在spring boot项目中,对于普通的java项目以及spring xml项目是不支持的,最主要的第二点它的热加载方案实际上和tomcat热加载是同样的,只不过它的热加载经过嵌套classloader的方式来完成,这个classloader每次只加载class file变动的class二进制文件,这样就会来带一个问题,在很是庞大的项目面前(启动大约10min+)这种状况,它就显得很苍白。这归根结底的缘由是在于他的reload范围实在是太大了,对于一些小项目还能够,可是一些比较庞大的项目实际使用效果仍是很是感人的。apache
一、总体设计方案
二、走进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-api
Interface ClassFileTransformer:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html数组
2.一、JVM启动前静态Instrument
Javaagent是java命令的一个参数。参数 javaagent 能够用于指定一个 jar 包,而且对该 java 包有2个要求:
-
这个 jar 包的 MANIFEST.MF 文件必须指定 Premain-Class 项。
-
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.二、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.三、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.一、启动时加载instrument agent过程:
-
建立并初始化 JPLISAgent;
-
监听 VMInit 事件,在 JVM 初始化完成以后作下面的事情:
-
建立 InstrumentationImpl 对象 ;
-
监听 ClassFileLoadHook 事件 ;
-
调用 InstrumentationImpl 的loadClassAndCallPremain方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法 ;
-
-
解析 javaagent 中 MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容。
2.3.二、运行时加载instrument agent过程:
经过 JVM 的attach机制来请求目标 JVM 加载对应的agent,过程大体以下:
-
建立并初始化JPLISAgent;
-
解析 javaagent 里 MANIFEST.MF 里的参数;
-
建立 InstrumentationImpl 对象;
-
监听 ClassFileLoadHook 事件;
-
调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class类的agentmain方法。
2.3.三、Instrumentation的局限性
大多数状况下,咱们使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,可是有如下的局限性:
-
premain和agentmain两种方式修改字节码的时机都是类文件加载以后,也就是说必需要带有Class类型的参数,不能经过字节码文件和自定义的类名从新定义一个原本不存在的类。
-
类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有如下限制:
-
新类和老类的父类必须相同;
-
新类和老类实现的接口数也要相同,而且是相同的接口;
-
新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;
-
新类和老类新增或删除的方法必须是private static/final修饰的;
-
能够修改方法体。
-
除了上面的方式,若是想要从新定义一个类,能够考虑基于类加载器隔离的方式:建立一个新的自定义类加载器去经过新的字节码去定义一个全新的类,不过也存在只能经过反射调用该全新类的局限性。
2.四、那些年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.五、如何解决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.一、文件监听
热部署启动时首先会在本地和远程预约义两个目录,/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.二、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.三、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.四、spring xml重载
当用户修改/新增spring xml时,须要对xml中全部bean进行重载
从新reload以后,将spring 销毁后重启。
注意:xml修改方式改动较大,可能涉及到全局的Aop的配置以及前置和后置处理器相关的内容,影响范围为全局,因此目前只放开普通的xml bean标签的新增/修改,其余能力酌情逐步放开。
3.五、mybatis xml 重载
四、远程反编译
在代码中经过插件右键-远程反编译便可查看当前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方法都会获取到最新的资源文件了。
五、咱们支持的功能
功能点 |
是否支持 |
---|---|
修改方法体内容 |
✅ |
新增方法体 |
✅ |
新增非静态字段 |
✅ |
新增静态字段 |
✅ |
spring bean中新增@autowired注解 |
✅ |
在spring 扫描包base package下,新增带@Service的bean,而且注入 |
✅ |
新增xml |
✅ |
增长修改静态块 |
✅ |
新增修改匿名内部类 |
✅ |
新增修改继承类 |
✅ |
新增修改接口方法 |
✅ |
新增泛型方法 |
✅ |
修改 annotation sql(Mybatis) |
✅ |
修改 xml sql(Mybatis) |
✅ |
增长修改静态块 |
✅ |
匿名内部类新增,修改 |
✅ |
内部类新增,修改 |
✅ |
新增,删除extend父类,implement 接口 |
✅ |
父类或接口新增方法,删除方法 |
✅ |
泛型方法,泛型类 |
✅ |
多文件热部署 |
✅ |
spring boot项目 |
✅ |
war包项目 |
✅ |
修改spring xml (只修改bean标签) |
✅ |
✅ |
|
pigeon服务框架 |
✅ |
@Transactional 注解新增/修改,注解参数修改 |
✅ |
序列化 框架支持 | ✅ |
dubbo alibaba | ✅ |
dubbo apache | ✅ |
dubbox | ✅ |
motan | ✅ |
删除继承的class |
❌ |
枚举 字段修改 |
❌ |
修改static字段值 |
❌ |
其余功能迭代挖掘ing |
☺ |
六、强大到使人窒息的多文件热部署以及源码交流
因为篇幅缘由和文采捉急,没有办法完整的写出热部署过程当中遇到的各类各样稀奇古怪和没法解释的问题,和其中的坎坷经历。更多的功能需求迭代建议和agent源码技术交流能够加入QQ群来详细交流,QQ群号:825199617