HotwapAgent
Java unlimited redefinition of classes at runtime
使用方法
- 按照github和官网说明操作即可, 完整的热更新依赖于DCEVM,不需要DCEVM也可以使用(但是java1.7、1.8不允许改类结构,比如新增字段,新增方法和修改类的父类等)
- 如果自定义监控外部文件,需要将hotswap-agent.properties添加到src/main/resources中,并且要配置extraClasspath属性和启用autoHotswap, 可以通过
propertiesFilePath
参数指定自定义配置路径,例如 :-javaagent:/hotswap-agent.jar=propertiesFilePath=/data/hotswap-agent.properties,autoHotswap=true
- 远程服务启用HotwapAgent并且开启JPDA后,通过IDE debug连上服务以后可以编译本地的类实现远程热更新
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 --XXaltjvm=dcevm -javaagent:HotswapAgent.jar MainClass
- docker镜像可以参考hotswap-docklands来生成
源码解析
评价
- 代码非常优雅,没有一个过长的类,每一个类源码都值得学习
- 字节码操作使用的是javassit, 可以非常直观的在某个方法的前面或者后面插入代码,性能比asm直接操作字节码要差一点
- 热部署的原理和美团的sonic非常相似,如果要实现sonic,可以基于HotswapAgent来实现
- 用了很多事件观察者模式,解耦了各个模块
- 利用注解,非常巧妙的拆解了热部署需要的基础功能,可以很简单的实现一个自定义插件
编译调试
- 源码必须要用带有dcevm的jdk11版本才能编译,只能下载trava-jdk-11-dcevm
- 吐槽一下github下载速度真慢,gitee只能代理下载源码,没法下载release资源,谁有好的办法麻烦分享一下
- 在IDE中执行单元测试的时候还需要额外添加一个jdk1.8或1.7中的包
${java.home}/../lib/tools.jar
,jdk11的lib找不到tools.jar
- 有一个坑点是instrument.transform函数在重定义类redefiningClass的时候,无法调试。在SpringPlugin.registerBasePackage中有一个说明,可能是JVMTI-debugger的bug
// v.d.: Force load/Initialize ClassPathBeanRefreshCommand classe in JVM. This is hack, in whatever reason sometimes new ClassPathBeanRefreshCommand() // stays locked inside agent's transform() call. It looks like some bug in JVMTI or JVMTI-debugger() locks handling.
核心代码
- org.hotswap.agent.HotswapAgent agent类
- org.hotswap.agent.config.PluginManager 插件管理类
- org.hotswap.agent.config.PluginRegistry 插件注册类
- org.hotswap.agent.util.HotswapTransformer 字节操作类
- org.hotswap.agent.annotation.handler.AnnotationProcessor 注解解析类
- org.hotswap.agent.util.classloader.URLClassLoaderHelper URLClassLoader操作辅助类
- ClassLoaderDefineClassPatcher.patch 将classLoaderFrom下的所有字节绑定到classLoaderTo
- org.hotswap.agent.util.ReflectionHelper 发射工具类
- org.hotswap.agent.util.classloader.WatchResourcesClassLoader Special URL classloader to get only changed resources from URL.
- org.hotswap.agent.plugin.spring.ResetSpringStaticCaches 清理所有Spring静态缓存
- org.hotswap.agent.distribution.PluginDocs 文档生成
- 骚操作,将所有插件模块的README.md转换为html,和热部署代码无关
初始化主流程
核心插件plugin
WatchResourcesPlugin
org.hotswap.agent.plugin.watchResources.WatchResourcesPlugin
监控自定义目录,如果没有watchResources不会初始化
初始化流程如下
- 初始化
watchResourcesClassLoader.initWatchResources
- 修改
appClassloader
优先使用watchResourcesClassLoader
,这里有个骚操作,使用javaassist进行代理增强实现的 - WatchResourcesClassLoader将自定义的watchResources路径放在最前面,优先从自定义的目录下面加载类,原理是优先使用UrlOnlyClassLoader类来查找资源,只从URL[]查找资源而不会通过父类去加载
public static class UrlOnlyClassLoader extends URLClassLoader { public UrlOnlyClassLoader(URL[] urls) { super(urls); } // do not use parent resource (may introduce infinite loop) @Override public URL getResource(String name) { return findResource(name); } };
HotswapperPlugin
org.hotswap.agent.plugin.hotswapper.HotswapperPlugin
- Watch for any class file change and reload (hotswap) it on the fly. 说明文档
- 只有配置了hotswap-agent.properties并且启用autoHotswap时才会初始化
- 这个插件监听所有类创建和变更
@OnClassFileEvent(classNameRegexp = ".*", events = {FileEvent.MODIFY, FileEvent.CREATE})
- 有两种热部署模式,本地热加载,和通过JDPA API远程热更新
- 本地热加载:没有配置端口的时候使用这种模式,会调用
pluginManager.hotswap
,进而调用instrumentation.redefineClasses
来主动发起类重定义 - JDPA API热加载:如果配置了端口,通过
com.sun.jdi.SocketAttach
来启动一个jvm attach到localhost:jdwP端口
对应的进程上面,另外一个进程启动时需要添加-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=jdwP端口号
启动参数,发现类变更事件以后,调用jvm.redefineClasses
方法热更新另外一个进程的字节(本地进程还有必要通过jdwp调试模式来修改吗?我测试这种模式热更新会失败,没有找到原因,可能要在jdk11中使用)
- 本地热加载:没有配置端口的时候使用这种模式,会调用
JdkPlugin
org.hotswap.agent.plugin.jdk.JdkPlugin
JDK插件- 监听重定义事件
LoadEvent.REDEFINE
flushBeanIntrospectorCaches- Removing from threadGroupContext
- Removing class from declaredMethodCache.
- 监听重定义事件
LoadEvent.REDEFINE
flushIntrospectClassInfoCache- Flushing class from com.sun.beans.introspect.ClassInfo cache
- 监听重定义事件
LoadEvent.REDEFINE
flushObjectStreamCaches- Flushing class from ObjectStreamClass caches
- 监听重定义事件
AnonymousClassPatchPlugin
org.hotswap.agent.plugin.jvm.AnonymousClassPatchPlugin
匿名类插件- 只监听匿名类的变更
.*\$\d+
- 匿名内是按照MyClass$1, MyClass$2这种在代码中的顺序生成的,如果更换位置以后原来的类位置都会发生变更
- 为了热更新的时候保证匿名类不错乱,才疏学浅某天沉下心来再深入研究
- 只监听匿名类的变更
ClassInitPlugin
org.hotswap.agent.plugin.jvm.ClassInitPlugin
静态类和静态变量插件,类重定义LoadEvent.REDEFINE
时处理- 先删除过去增强注入的老的
$$ha$clinit
方法 - 如果类有静态变量,则将静态变量初始化的函数注入成
public static $$ha$clinit()
方法,如果静态变量有变更或者枚举有变更,将通过scheduleCommand在150ms以后重新调用这个类的$$ha$clinit()
方法,因为代理类重定义是在100ms以后,静态变量在代理类重定义以后再执行
- 先删除过去增强注入的老的
自定义插件
SpringPlugin
实例化流程
- 1.
pluginRegistry.scanPlugins
时发现SpringPlugin类将其加入到registeredPlugins - 2.
annotationProcessor.processAnnotations
将SpringPlugin上面两个@OnClassLoadEvent注解的静态方法生成两个PluginClassFileTransformer注册到hotswapTransformer
中- @Plugin注解上的3个supportClass的静态注解也同样会被
annotationProcessor
处理,supportClass的注解处理和SpringPlugin是一样的 - 2.1
ClassPathBeanDefinitionScannerTransformer
监听了org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider
类加载事件,在其findCandidateComponents
方法的最后插入了ClassPathBeanDefinitionScannerAgent.registerBasePackage
代码 - 2.2
ProxyReplacerTransformer
监听了DefaultListableBeanFactory
、org.springframework.cglib.reflect.FastClass.Generator
、net.sf.cglib.reflect.FastClass.Generator
这三个类加载事件,在这些类字节被加载的时候进行代码插桩增强 - 2.3
XmlBeanDefinitionScannerTransformer
监听了org.springframework.beans.factory.xml.XmlBeanDefinitionReader
类加载事件,在loadBeanDefinitions
方法的最后插入了org.hotswap.agent.plugin.spring.scanner.XmlBeanDefinitionScannerAgent.registerXmlBeanDefinitionScannerAgent
代码
- @Plugin注解上的3个supportClass的静态注解也同样会被
- 3.当
org.springframework.beans.factory.support.DefaultListableBeanFactory
类的字节码被定义的时候,会反射调用上面注册的transformer的函数,将SpringPlugin初始化和init函数代码插入到DefaultListableBeanFactory
的构造函数前面- 3.1 当
DefaultListableBeanFactory
被实例化的时候SpringPlugin才开始实例化 - 3.2 当SpringPlugin进行实例化的时候,执行
annotationProcessor.processAnnotations
方法- a.会将实例化对象pluginInstance的非静态注解变量(hotswapTransformer, watcher, scheduler,appClassLoader)通过反射赋值
- b.会将
@OnResourceFileEvent(path="/", filter = ".*.xml", events = {FileEvent.MODIFY})
注解的registerResourceListeners
方法加入到资源文件监听listener中,当xml文件变更以后会执行XmlBeanRefreshCommand
命令重新加载xml中的bean
- 3.3 执行SpringPlugin.init方法
- 如果在配置文件中自定义了的包路径,会调用registerBasePackage,生成一个字节转换
HaClassFileTransformer
, 该转换器在加载类的时候判定字节是否和原来的类定义有变化, 如果有变更生成一个ClassPathBeanRefreshCommand命令重新对该bean进行初始化
- 如果在配置文件中自定义了的包路径,会调用registerBasePackage,生成一个字节转换
- 3.1 当
- 4.当
org.springframework.aop.framework.CglibAopProxy
类的字节码被定义的时候,会反射调用上面注册的transformer的函数,将CglibAopProxy
类的createEnhancer
方法替换,禁用缓存 - 5.当
ClassPathScanningCandidateComponentProvider
类执行findCandidateComponents
的时候,会调用ClassPathBeanDefinitionScannerAgent.registerBasePackage
代码,进而执行SpringPlugin
的registerComponentScanBasePackage
方法,会和3.3一样,将包名进行注册registerBasePackage转换增强,同时将资源包下面的子包所有URL加入到watcher中, 子包中的类加载的时候后续会通过ClassPathBeanRefreshCommand命令重新再加载一次(子包应该是jar包,并且一个类在这个watcher中只会实例化一次)
如何实现监控文件变更的
- agent启动的时候初始化了一个
watcher
- 使用的是nio的FileSystems.getDefault().newWatchService()监听所有文件资源
- windows下支持额外的ExtendedWatchEvent Modifier.FILE_TREE
watcher
启动了一个线程循环的从系统事件取出WatchEvent
,然后丢到dispatcher
(EventDispatcher)的eventQueue
(ArrayBlockingQueue)队列中dispatcher
也启动了一个线程循环的从队列eventQueue
中取出事件Event,然后callListeners
通知关心事件的监听者, watcher和dispatcher用的是同一个listeners对象- 插件类在初始化的时候会注册监听者到wacher上面
- 自定义的监控目录是通过WatchResourcesPlugin插件注册了listener
- 可以直接调用
watcher.addEventListener
来注册listener - 可以在插件类的方法上面加上@OnClassFileEvent或者OnResourceFileEvent注解, 在资源变更以后会反射调用该方法
- 注解是通过WatchHandler来绑定的, initMethod—》registerResources —> registerResourceListener —》pluginManager.getWatcher().addEventListener
- listener执行的时候会创建WatchEventCommand,
WatchEventCommand.createCmdForEvent(pluginAnnotation, event, classLoader)
,然后会通过scheduleCommand
来执行pluginAnnotation
上面的method
当类的加载或者重定义时如何处理事件
- 通过OnClassLoadEvent注解,注册了很多PluginClassFileTransformer
hotswapTransformer.registerTransformer(appClassLoader, annot.classNameRegexp(), new PluginClassFileTransformer(pluginManager, pluginAnnotation));
- 类第一次加载或重新加载时都会触发instrument.transform函数,在这个函数中会找到匹配的Transformer,然后执行Transformer的transform方法
.class文件变更或者新增以后如何判定是否需要重新加载bean
-
WatchHandler.registerResourceListener注册了很多事件更新的listener,文件更新以后会callListener
- WatchResourcesClassLoader.initWatchResources时通过watcher.addEventListener注册了一个监听器listener,文件变更以后通过listener然后将类保存到changedUrls中, 用来判断类是否发生了变更
- WatchResourcesClassLoader在getResource方法会判断类是否发生了变更,如果有变更返回变更后的resource
-
IDE调试的时候是由IDE触发了instrumentation.redefineClasses事件,调试断点不生效
HotswapperPlugin
如果启用并且没有配置端口的时候会主动触发redefineClasses,插件默认是禁用的,如果是用jar包等启动方式的话应该启用autoHotswap来实现热更新
-
创建了一个新的类文件
在SpringPlugin实例化流程的步骤5中registerComponentScanBasePackage
基于basePackageURL创建了一个资源文件监听listenerwatcher.addEventListener
,这个listener在发现新类.class文件的时候会创建一个ClassPathBeanRefreshCommand命令来实例化这个类对应的bean -
类文件更新
在SpringPlugin实例化流程的步骤3.3和步骤5中调用registerBasePackage
方法生成并注册了HaClassFileTransformer
,当类重新加载的时候会通过老的class定义和新的字节码判定类是否变更,变更后会重新加载beanprivate void registerBasePackage(final String basePackage) { final SpringChangesAnalyzer analyzer = new SpringChangesAnalyzer(appClassLoader); // v.d.: Force load/Initialize ClassPathBeanRefreshCommand classe in JVM. This is hack, in whatever reason sometimes new ClassPathBeanRefreshCommand() // stays locked inside agent's transform() call. It looks like some bug in JVMTI or JVMTI-debugger() locks handling. ClassPathBeanRefreshCommand fooCmd = new ClassPathBeanRefreshCommand(); hotswapTransformer.registerTransformer(appClassLoader, getClassNameRegExp(basePackage), new HaClassFileTransformer() { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (classBeingRedefined != null) { // 类重定义的时候分析判断是否有必要重新初始化bean,ClassSignatureComparer.isPoolClassDifferent if (analyzer.isReloadNeeded(classBeingRedefined, classfileBuffer)) { scheduler.scheduleCommand(new ClassPathBeanRefreshCommand(classBeingRedefined.getClassLoader(), basePackage, className, classfileBuffer)); } } return classfileBuffer; } @Override public boolean isForRedefinitionOnly() { // 只有类重新定义的时候才执行这个转换器 return true; } }); }
xml文件变更后如何更新
上面的3.2.b这一步监听了所有xml文件变更事件,变更以后会通过XmlBeanRefreshCommand
命令重新加载xml中的bean
org.hotswap.agent.plugin.spring.scanner.XmlBeanDefinitionScannerAgent.reloadXml
- 实际调用的是
xmlBeanDefinitionScannerAgent.reloadBeanFromXml
public void reloadBeanFromXml(URL url) {
// this will call registerBeanDefinition which in turn call resetBeanDefinition to destroy singleton
this.reader.loadBeanDefinitions(new FileSystemResource(url.getPath()));
// spring won't rebuild dependency map if injectionMetadataCache is not cleared
// which lead to singletons depend on beans in xml won't be destroy and recreate, may be a spring bug?
ResetBeanPostProcessorCaches.reset(maybeRegistryToBeanFactory());
ProxyReplacer.clearAllProxies();
reloadFlag = false;
}
如何重新加载Spring bean
可以看代码细节来了解Spring bean从字节码到注册的整个流程
- ClassPathBeanRefreshCommand.executeCommand,通过反射代用下面的refreshClass方法
- ClassPathBeanDefinitionScannerAgent.refreshClass方法
- ResetSpringStaticCaches.reset() 清理所有静态缓存
- 为什么所有类变更都要清掉缓存,如果不是Spring bean不清理可不可以?
- resolveBeanDefinition 解析bean定义
public BeanDefinition resolveBeanDefinition(byte[] bytes) throws IOException { Resource resource = new ByteArrayResource(bytes); resetCachingMetadataReaderFactoryCache(); //清理metadataReaderFactory缓存 MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource); // 只有Spring bean类才去拿到beanDefinition if (isCandidateComponent(metadataReader)) { ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader); sbd.setResource(resource); sbd.setSource(resource); if (isCandidateComponent(sbd)) { return sbd; } else { return null; } }else.... }
- defineBean 重新定义类,这里加了同步锁,将整个ClassPathBeanDefinitionScannerAgent类锁住了
-. 为什么前面调用Spring方法的时候都通过反射调用,最后调用freezeConfiguration时是直接调用?
- ResetSpringStaticCaches.reset() 清理所有静态缓存
public void defineBean(BeanDefinition candidate) {
synchronized (getClass()) {
ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate);
candidate.setScope(scopeMetadata.getScopeName());
String beanName = this.beanNameGenerator.generateBeanName(candidate, registry);
if (candidate instanceof AbstractBeanDefinition) {
// 反射执行classPathBeanDefinitionScanner.postProcessBeanDefinition方法
postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName);
}
if (candidate instanceof AnnotatedBeanDefinition) {
// 反射执行AnnotationConfigUtils.processCommonDefinitionAnnotations方法
processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate);
}
removeIfExists(beanName); // 先清理存在的bean
if (checkCandidate(beanName, candidate)) {
BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
// 反射执行AnnotationConfigUtils.applyScopedProxyMode方法
definitionHolder = applyScopedProxyMode(scopeMetadata, definitionHolder, registry);
// 发射执行ClassPathBeanDefinitionScanner.registerBeanDefinition方法
registerBeanDefinition(definitionHolder, registry);
DefaultListableBeanFactory bf = maybeRegistryToBeanFactory();
if (bf != null)
// 清理Spring HandlerMapping, 包括handlerMethods、urlMap、nameMap等字段
// 然后重新调用afterPropertiesSet方法重新初始化HandlerMapping
ResetRequestMappingCaches.reset(bf);
// 清理插件代理,在SpringPlugin实例化流程的2.2中给com.sun.proxy.$Proxy代理类创建了代理类
ProxyReplacer.clearAllProxies();
// 调用beanFactory.freezeConfiguration的方法,Allow for caching all bean definition metadata, not expecting further changes
freezeConfiguration();
}
}
}
MyBatisPlugin
只支持任何一个mybatis xml文件修改以后进行全量解析更新,新增xml和注解这个插件不支持
MyBatisTransformers
监听mybaits核心类加载事然后对其进行增强org.apache.ibatis.parsing.XPathParser
- 增加了一个变量
$$ha$srcFileName
createDocument
方法中this.$$ha$srcFileName=org.hotswap.agent.util.IOUtils.extractFileNameFromInputSource($1)
, 得到inputSource的文件名赋给变量$$ha$srcFileName
- 新增
$$ha$refreshDocument
方法,如果$$ha$srcFileName
不为null,基于$$ha$srcFileName
文件中createDocument
- 增加了一个变量
org.apache.ibatis.builder.BaseBuilder
- 去掉变量configuration的final属性
org.apache.ibatis.builder.xml.XMLConfigBuilder
- 构造函数的后面插入代码
- 实例化MyBatisPlugin
- 调用
MyBatisPlugin.registerConfigurationFile
方法,记录configFile,用来过滤mybatis xml文件 - 用
ConfigurationProxy.getWrapper(this).proxy(this.configuration)
对configuration创建一个代理
- 增加一个
$$ha$refresh
方法,该方法会在xml文件变更以后调用- 调用
XPathParserCaller
.refreshDocument()
函数, 反射调用org.apache.ibatis.parsing.XPathParser
.$$ha$refreshDocument()
- 然后调用
XMLConfigBuilder
.parse()
函数重新解析配置,这个方法源码中根据this.parsed来判断只允许解析一次,所以需要在调用前需要将this.parsed设置为false
- 调用
- 构造函数的后面插入代码
org.apache.ibatis.builder.xml.XMLMapperBuilder
- 构造函数的后面插入代码
- 实例化MyBatisPlugin
- 调用
MyBatisPlugin.registerConfigurationFile
方法,记录configFile,用来过滤mybatis xml文件
- 构造函数的后面插入代码
- MybatisPlugin中注册xml文件修改监听事件,当监听到.xml文件修改以后,创建一个ReflectionCommand命令,反射执行MyBatisRefreshCommands.reloadConfiguration方法
@OnResourceFileEvent(path = "/", filter = ".*.xml", events = {FileEvent.MODIFY}) public void registerResourceListeners(URL url) throws URISyntaxException { if (configurationMap.containsKey(Paths.get(url.toURI()).toFile().getAbsolutePath())) { // 500ms以后执行reloadConfigurationCommand命令 refresh(500); } } private void refresh(int timeout) { scheduler.scheduleCommand(reloadConfigurationCommand, timeout); }
MyBatisRefreshCommands.reloadConfiguration
内部调用的是ConfigurationProxy.refreshProxiedConfigurations()
方法, 调用了MyBatisTransformers.$$ha$refresh
方法更新,这个方法是通过类增强插入的
public static void refreshProxiedConfigurations() {
for (ConfigurationProxy wrapper : proxiedConfigurations.values())
try {
wrapper.refreshProxiedConfiguration();
} catch (Exception e) {
e.printStackTrace();
}
}
public void refreshProxiedConfiguration() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
this.configuration = new Configuration();
ReflectionHelper.invoke(configBuilder, MyBatisTransformers.REFRESH_METHOD);
}
JacksonPlugin
- 在
ObjectMapper
、SerializerCache
、DeserializerCache
、ReadOnlyClassToSerializerMap
、TypeFactory
、LRUMap
等类定义时进行了增强- 给这些类的构造函数中都加入了代码,初始化JacksonPlugin,然后执行
registerNeedToClearCacheObjects
方法将实例对象添加到JacksonPlugin的needToClearCacheObjects
中 - 都插入了一个
public void ha$$clearCache()
方法对缓存进行清理
- 给这些类的构造函数中都加入了代码,初始化JacksonPlugin,然后执行
- 当监听到类变更以后执行
needToClearCacheObject.ha$$clearCache()
方法清理所有缓存
LogbackPlugin
监听logback.xml文件,变更以后重新配置
- 在
ch.qos.logback.core.joran.GenericConfigurator
定义的时候在其doConfigure(url)函数的后面插入代码- 实例化LogbackPlugin插件
- 调用
LogbackPlugin.initLogback(final Object configurator, final URL url)
- 注册一个url资源(就是logback.xml文件的url)监听者,监听到变更以后reload
- 先调用
ch.qos.logback.classic.LoggerContext
.reset()
方法 - 再调用
ch.qos.logback.core.joran.GenericConfigurator
.doConfigure(url)
方法
- 先调用
LogbackPlugin.reload方法源码如下
protected void reload(Object configurator, URL url) {
....
try {
synchronized (configurator) {
ClassLoader classLoader = configurator.getClass().getClassLoader();
Class<?> configuratorClass = classLoader.loadClass("ch.qos.logback.core.joran.GenericConfigurator");
Class<?> contextAwareBaseClass = classLoader.loadClass("ch.qos.logback.core.spi.ContextAwareBase");
Class<?> contextClass = classLoader.loadClass("ch.qos.logback.classic.LoggerContext");
// reset current context
Object context = contextAwareBaseClass.getDeclaredMethod("getContext").invoke(configurator);
contextClass.getDeclaredMethod("reset").invoke(context);
// configure the URL
configuratorClass.getDeclaredMethod("doConfigure", URL.class).invoke(configurator, url);
}
} catch (Exception e) {
LOGGER.error("Unable to reload {} with logback configurator {}", e, url, configurator);
}
}