JAVA实现代码热更新
- 引言
- 类加载器
- 实现热更新思路
- 多种多样的加载来源
- SPI服务发现机制
- 完整代码
- 类加载器共享空间机制
- Tomcat如何实现JSP的热更新
- Spring反向访问用户程序类问题
- 补充细节
- 推荐资源
引言
本文将带领大家利用Java的类加载器加SPI服务发现机制实现一个简易的代码热更新工具。
类加载相关知识可以参考: 深入理解JVM虚拟机第三版, 深入理解JVM虚拟机(第二版)—国外的,自己动手写JVM
类加载器
JVM通过ClassLoader将.class二进制流读取到内存中,然后为其建立对应的数据结构:
/*
ClassFile {u4 magic;u2 minor_version;u2 major_version;u2 constant_pool_count;cp_info constant_pool[constant_pool_count-1];u2 access_flags;u2 this_class;u2 super_class;u2 interfaces_count;u2 interfaces[interfaces_count];u2 fields_count;field_info fields[fields_count];u2 methods_count;method_info methods[methods_count];u2 attributes_count;attribute_info attributes[attributes_count];
}
*/
//伪代码,不全
type Class struct {accessFlags uint16name string // thisClassNamesuperClassName stringinterfaceNames []stringconstantPool *ConstantPoolfields []*Fieldmethods []*MethodsourceFile stringloader *ClassLoadersuperClass *Classinterfaces []*ClassinstanceSlotCount uintstaticSlotCount uintstaticVars SlotsinitStarted booljClass *Object...
}
接着对Class执行验证,准备和解析,当然将符号引用解析为直接引用的过程一般用到的时候才会去解析,这也说明了为什么类只会在用到的时候才会进行初始化。
如果想要在内存中唯一确定一个类,需要通过加载该类的类加载实例和当前类本身来唯一确定,因为每个类加载器都有自己的命名空间:
//伪代码
type ClassLoader struct {//负责从哪些路径下加载class文件cp *classpath.Classpath//简易版本命令空间隔离实现classMap map[string]*Class // loaded classes
}
对于由不同类加载实例对象加载的类而言,他们是不相等的,这里的不相等包括Class对象的equals方法,isAssignableFrom方法,isInstance方法,Instanceof关键字,包括checkcast类型转换指令。
同一个类加载实例不能重复加载同一个类两次,否则会抛出连接异常。
实现热更新思路
- 自定义类加载器,重写loadClass,findClass方法
/*** @author 大忽悠* @create 2023/1/10 10:31*/
public class DynamicClassLoader extends ClassLoader{...@Overrideprotected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {Class<?> c=null;//0.确保当前类加载不会重复加载已经加载过的类if((c=findLoadedClass(name))!=null){return c;}//1.父类加载if (getParent() != null) {try{c = getParent().loadClass(name);}catch (ClassNotFoundException e){}}//2.自己加载if(c==null){c = findClass(name);}//3.是否对当前class进行连接if (resolve) {resolveClass(c);}return c;}}@Overridepublic Class<?> findClass(String name) throws ClassNotFoundException {byte[] classBytes=getClassBytes(name);return defineClass(name,classBytes, 0, classBytes.length);}/*** @param name 全类名* @param resolve 是否需要对加载得到类进行link过程--验证,准备,解析(一般都是懒解析)*/public static Class<?> dynamicLoadClass(String name,Boolean resolve) throws ClassNotFoundException {DynamicClassLoader dynamicClassLoader = new DynamicClassLoader();return dynamicClassLoader.loadClass(name,resolve);}/*** @param name 全类名*/public static Class<?> dynamicLoadClass(String name) throws ClassNotFoundException {return dynamicLoadClass(name,false);}...
}
dynamicLoadClass作为新增的静态方法,每次都会重新创建一个DynamicClassLoader自定义类加载器实例,并利用该实例去加载我们指定的类:
public static void main(String[] args) throws InterruptedException, ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {invokeSay();Thread.sleep(15000);invokeSay();}private static void invokeSay() throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {Class<?> aClass = DynamicClassLoader.dynamicLoadClass("com.exm.A");Object newInstance = aClass.newInstance();Method method = aClass.getMethod("say");method.invoke(newInstance);}
我们只需要在休眠的这15秒内,替换掉对应的class文件实现,即可完成代码的热更新,并且同时确保父类加载器不能够找到同类路径的类,否则就不能让自定义加载器得到机会重新读取二进制流到内存并建立相应的数据结构了。
默认的父类加载器是类路径加载器,也被称作系统类路径加载器
该系统类加载器就是默认创建用来加载启动类的加载器,因为我们在启动类中通过方法调用引用了DynamicClassLoader,因此我们自定义的类加载器也是通过加载启动类的加载器进行加载的。
在本类中引用到的类都会使用加载本类的加载器进行加载
多种多样的加载来源
class二进制流数据可以来自于文件,网络,数据库或者其他地方,因此为了支持多种多样的加载来源,我们可以定义一个ClassDataLoader接口:
/*** @author 大忽悠* @create 2023/1/10 11:37*/
public interface ClassDataLoader {/*** @param name 全类名* @return 加载得到的二进制文件流*/byte[] loadClassData(String name);
}
- 这里给出一个从文件中加载classData的实现案例:
package com;import java.io.*;/*** @author 大忽悠* @create 2023/1/10 11:48*/
public class FileClassDataLoader implements ClassDataLoader{/*** 默认从当前项目路径找起*/private String basePath="";/*** @param name 全类名* @return 加载得到的二进制文件流*/@Overridepublic byte[] loadClassData(String name) {return getClassData(new File(basePath+name.replace(".","/")+".class"));}private static byte[] getClassData(File file) {try (InputStream ins = new FileInputStream(file); ByteArrayOutputStream baos = newByteArrayOutputStream()) {byte[] buffer = new byte[4096];int bytesNumRead = 0;while ((bytesNumRead = ins.read(buffer)) != -1) {baos.write(buffer, 0, bytesNumRead);}return baos.toByteArray();} catch (IOException e) {e.printStackTrace();}return new byte[] {};}
}
DynamicClassLoader自定义加载器内部新增两个属性:
/*** 负责根据全类名加载class二进制流*/private final static List<ClassDataLoader> classDataLoaderList=new ArrayList<>();/*** 所有DynamicClassLoader加载器共享一个缓存*/private final static Map<String,byte[]> classBytesCache =new HashMap<>();public static void registerClasDataLoader(ClassDataLoader classDataLoader){classDataLoaderList.add(classDataLoader);}public static void cacheUpdateHook(String name,byte[] classData){classBytesCache.put(name,classData);}
对应的loadClass方法被修改为如下:
@Overrideprotected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {Class<?> c=null;//0.确保当前类加载不会重复加载已经加载过的类if((c=findLoadedClass(name))!=null){return c;}//1.父类加载--如果缓存中存在,那么父类也就无需再次寻找了if (classBytesCache.get(name)==null && getParent() != null) {try{c = getParent().loadClass(name);}catch (ClassNotFoundException e){}}//2.自己加载if(c==null){c = findClass(name);}//3.是否对当前class进行连接if (resolve) {resolveClass(c);}return c;}}@Overridepublic Class<?> findClass(String name) throws ClassNotFoundException {byte[] classBytes = classBytesCache.get(name);if(classBytes==null){for (ClassDataLoader classDataLoader : classDataLoaderList) {if((classBytes=classDataLoader.loadClassData(name))!=null){break;}}}if (classBytes==null || classBytes.length == 0) {throw new ClassNotFoundException();}classBytesCache.put(name,classBytes);return defineClass(name,classBytes, 0, classBytes.length);}
DynamicClassLoader内部内置了多个ClassData数据源,我们通过遍历数据源列表,只要其中一个返回结果不为空,我们就立刻返回。
为了避免每次都需要重新从数据源中读取数据,我们可以将从数据源中获取到的二进制字节码缓存起来,然后让ClassDataLoader通过cacheUpdateHook钩子函数更新缓存达到动态更新的效果。
我们自定义的FileClassDataLoader通过回调registerClassDataLoader接口,将自身注册到DynamicClassLoader的数据源列表中去:
static {DynamicClassLoader.registerClasDataLoader(new FileClassDataLoader());}
但是如何让FileClassDataLoader静态代码块能够执行,也就是FileClassDataLoader类需要被初始化,如何做到?
SPI服务发现机制
在不通过new指令,不调用类里面的方法和访问类中字段的情况下,想要类能够被初始化,我们可以通过Class.forName完成:
forName的重载方法有一个Initialize参数,表明加载了当前类后,是否需要初始化该类,如果我们调用单参数的forName,那么默认为true。
所以,现在,我们只需要通过一种方式获取到ClassDataLoader的所有实现类类名,然后挨个使用Class.forName方法,完成实现类的初始化,就可以让实现类都注册到DynamicClassLoader中去。
SPI可以使用Java提供的serviceLoader,或者参考Spring的spring.factories实现,这里我给出一个简单的实现方案:
/*** @author 大忽悠* @create 2023/1/10 12:03*/
public class SPIService {/*** 服务文件地址*/private static final String SERVICE_PATH = "META-INF" + File.separator + "SPI.properties";/*** 服务信息存储*/private static Properties SERVICE_MAP;static {try {SERVICE_MAP = new Properties();SERVICE_MAP.load(SPIService.class.getClassLoader().getResourceAsStream(SERVICE_PATH));} catch (IOException e) {throw new RuntimeException(e);}}/*** @param name 需要寻找的服务实现的接口的全类名* @return 找寻到的所有服务实现类*/public List<Class<?>> loadService(String name) {if (SERVICE_MAP == null) {return null;}String[] classNameList = SERVICE_MAP.getProperty(name).split(",");ArrayList<Class<?>> classList = new ArrayList<>(classNameList.length);for (String classDataClassName : classNameList) {try {classList.add(Class.forName(classDataClassName));} catch (ClassNotFoundException e) {//忽略不可被解析的服务实现类e.printStackTrace();}}return classList;}}
DynamicClassLoader新增代码:
/*** 负责提供SPI服务发现机制*/private final static SPIService spiService=new SPIService();static {//通过SPI机制寻找classDataLoaderspiService.loadService(ClassDataLoader.class.getName());}
完整代码
package com;import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;/*** @author 大忽悠* @create 2023/1/10 10:31*/
public class DynamicClassLoader extends ClassLoader{/*** 负责根据全类名加载class二进制流*/private final static List<ClassDataLoader> classDataLoaderList=new ArrayList<>();/*** 所有DynamicClassLoader加载器共享一个缓存*/private final static Map<String,byte[]> classBytesCache =new HashMap<>();/*** 负责提供SPI服务发现机制*/private final static SPIService spiService=new SPIService();static {//通过SPI机制寻找classDataLoaderspiService.loadService(ClassDataLoader.class.getName());}@Overrideprotected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {Class<?> c=null;//0.确保当前类加载不会重复加载已经加载过的类if((c=findLoadedClass(name))!=null){return c;}//1.父类加载--如果缓存中存在,那么父类也就无需再次寻找了if (classBytesCache.get(name)==null && getParent() != null) {try{c = getParent().loadClass(name);}catch (ClassNotFoundException e){}}//2.自己加载if(c==null){c = findClass(name);}//3.是否对当前class进行连接if (resolve) {resolveClass(c);}return c;}}@Overridepublic Class<?> findClass(String name) throws ClassNotFoundException {byte[] classBytes = classBytesCache.get(name);if(classBytes==null){for (ClassDataLoader classDataLoader : classDataLoaderList) {if((classBytes=classDataLoader.loadClassData(name))!=null){break;}}}if (classBytes==null || classBytes.length == 0) {throw new ClassNotFoundException();}classBytesCache.put(name,classBytes);return defineClass(name,classBytes, 0, classBytes.length);}/*** @param name 全类名* @param resolve 是否需要对加载得到类进行link过程--验证,准备,解析(一般都是懒解析)*/public static Class<?> dynamicLoadClass(String name,Boolean resolve) throws ClassNotFoundException {DynamicClassLoader dynamicClassLoader = new DynamicClassLoader();return dynamicClassLoader.loadClass(name,resolve);}/*** @param name 全类名*/public static Class<?> dynamicLoadClass(String name) throws ClassNotFoundException {return dynamicLoadClass(name,false);}public static void registerClasDataLoader(ClassDataLoader classDataLoader){classDataLoaderList.add(classDataLoader);}public static void cacheUpdateHook(String name,byte[] classData){classBytesCache.put(name,classData);}
}
/*** @author 大忽悠* @create 2023/1/10 11:37*/
public interface ClassDataLoader {/*** @param name 全类名* @return 加载得到的二进制文件流*/byte[] loadClassData(String name);
}
package com;import java.io.*;/*** @author 大忽悠* @create 2023/1/10 11:48*/
public class FileClassDataLoader implements ClassDataLoader{/*** 默认从当前项目路径找起*/private String basePath="";static {DynamicClassLoader.registerClasDataLoader(new FileClassDataLoader());}/*** @param name 全类名* @return 加载得到的二进制文件流*/@Overridepublic byte[] loadClassData(String name) {return getClassData(new File(basePath+name.replace(".","/")+".class"));}private static byte[] getClassData(File file) {try (InputStream ins = new FileInputStream(file); ByteArrayOutputStream baos = newByteArrayOutputStream()) {byte[] buffer = new byte[4096];int bytesNumRead = 0;while ((bytesNumRead = ins.read(buffer)) != -1) {baos.write(buffer, 0, bytesNumRead);}return baos.toByteArray();} catch (IOException e) {e.printStackTrace();}return new byte[] {};}
}
package com;import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;/*** @author 大忽悠* @create 2023/1/10 12:03*/
public class SPIService {/*** 服务文件地址*/private static final String SERVICE_PATH = "META-INF" + File.separator + "SPI.properties";/*** 服务信息存储*/private static Properties SERVICE_MAP;static {try {SERVICE_MAP = new Properties();SERVICE_MAP.load(SPIService.class.getClassLoader().getResourceAsStream(SERVICE_PATH));} catch (IOException e) {throw new RuntimeException(e);}}/*** @param name 需要寻找的服务实现的接口的全类名* @return 找寻到的所有服务实现类*/public List<Class<?>> loadService(String name) {if (SERVICE_MAP == null) {return null;}String[] classNameList = SERVICE_MAP.getProperty(name).split(",");ArrayList<Class<?>> classList = new ArrayList<>(classNameList.length);for (String classDataClassName : classNameList) {try {classList.add(Class.forName(classDataClassName));} catch (ClassNotFoundException e) {//忽略不可被解析的服务实现类e.printStackTrace();}}return classList;}}
完整项目架构:
类加载器共享空间机制
请注意,在类加载器之间具有了委派关系,首先发起装载要求的类加载器不必是定义该类型的加载器。
实际定义了那个类型的类装载器被称为该类型的定义类装载器,要求某个类装载器去装载一个类型,但是却返回了其他类装载器装载的类型,这种装载器被称为是那个类型的初始类装载器。
被委派的类装载器装载的这个类型,会在所有被标记为该类型的初始类装载器的命名空间中共享。
最后通过一个案例理解一下类加载器共享空间机制:
测试:
当我们通过自定义的加载器加载A类时,首先会确保A类的父类和实现的接口都会被先被加载,类加载的简易版本代码如下所示:
func (self *ClassLoader) LoadClass(name string) *Class {if class, ok := self.classMap[name]; ok {// already loadedreturn class}var class *Class//加载数组对象需要区别对待if name[0] == '[' { // array classclass = self.loadArrayClass(name)} else {//class = self.loadNonArrayClass(name)}if jlClassClass, ok := self.classMap["java/lang/Class"]; ok {class.jClass = jlClassClass.NewObject()class.jClass.extra = class}return class
}func (self *ClassLoader) loadArrayClass(name string) *Class {class := &Class{accessFlags: ACC_PUBLIC, // todoname: name,loader: self,initStarted: true,superClass: self.LoadClass("java/lang/Object"),interfaces: []*Class{self.LoadClass("java/lang/Cloneable"),self.LoadClass("java/io/Serializable"),},}self.classMap[name] = classreturn class
}func (self *ClassLoader) loadNonArrayClass(name string) *Class {data, entry := self.readClass(name)class := self.defineClass(data)link(class)if self.verboseFlag {fmt.Printf("[Loaded %s from %s]\n", name, entry)}return class
}func (self *ClassLoader) readClass(name string) ([]byte, classpath.Entry) {data, entry, err := self.cp.ReadClass(name)if err != nil {panic("java.lang.ClassNotFoundException: " + name)}return data, entry
}// jvms 5.3.5
func (self *ClassLoader) defineClass(data []byte) *Class {class := parseClass(data)hackClass(class)class.loader = selfresolveSuperClass(class)resolveInterfaces(class)self.classMap[class.name] = classreturn class
}func parseClass(data []byte) *Class {cf, err := classfile.Parse(data)if err != nil {//panic("java.lang.ClassFormatError")panic(err)}return newClass(cf)
}// jvms 5.4.3.1
func resolveSuperClass(class *Class) {if class.name != "java/lang/Object" {class.superClass = class.loader.LoadClass(class.superClassName)}
}
func resolveInterfaces(class *Class) {interfaceCount := len(class.interfaceNames)if interfaceCount > 0 {class.interfaces = make([]*Class, interfaceCount)for i, interfaceName := range class.interfaceNames {class.interfaces[i] = class.loader.LoadClass(interfaceName)}}
}
本例中很明显,Interface接口是被DynamicClassLoader委托给了父类系统类加载器进行加载的,因此DynamicClassLoader和系统类加载器是Interface的初始类加载器.
此处在启动类中使用Interface类型时,还是会交给当前系统类加载器进行加载,此时因为系统类加载器已经加载过了,因此直接返回先前加载得到的Class对象。
这里因为DynamicClassLoader负责加载的A类,其在内存中生成的class数据结构,其中
//假设这是类A在内存中对应的class数据结构
type Class struct {...//值为DynamicClassLoaderloader *ClassLoader//Object--委派给了启动类加载器加载superClass *Class//继承的Inteface委派给了系统类加载器加载interfaces []*Class...
}
因为被委派的类装载器装载的这个类型,会在所有被标记为该类型的初始类装载器的命名空间中共享。
所以superClass指向的实际是启动类加载器命名空间下的Object Class内存数据结构:
//假设这是启动类加载器
type ClassLoader struct {cp *classpath.ClasspathverboseFlag bool//A Class中的superClass实际是Object Class,指向的是启动类加载器命令空间下的同一个Object Class classMap map[string]*Class // loaded classes
}
//假设这是系统类加载器
type ClassLoader struct {cp *classpath.ClasspathverboseFlag bool//A Class中的interfaceClass实际是Interface Class,指向的是系统类加载器命令空间下的同一个Interface Class classMap map[string]*Class // loaded classes
}
这也就是为什么执行checkCast强制类型转换指令不会报错的原因。
这部分内容没看明白的,可以去参考一下下面这本书的第8章内容:
Tomcat如何实现JSP的热更新
这里给出一张深入理解JVM虚拟机第三版中第9章关于tomcat 6之前的类加载器体系结构:
JasperLoader的加载范围仅仅是这个JSP文件编译出来的那一个class文件,当服务器检测到JSP文件修改时,会创建一个新的JasperLoader来加载被修改的JSP文件实现HotSwap功能。
注意: 每一个JSP文件都会被转换为一个Servlet类,JSP在JSP文件被解析后,得到对应的html字节流文件,servelt类负责将html字节流文件响应给浏览器.
所以过程就是 JSP–>JspServlet.java–>JspServlet.class–>JasperLoader加载—>因为JspServlet.class实现了Servlet相关规范接口,所以直接转换为对应的接口(为什么可以直接转换,参考上面案例),然后调用接口方法,完成响应内容输出。
大家联系上面我给出的案例,会发现其实本质是一样的,JSP热更新只需要每次重新创建一个JasperLoader实例,然后加载解析生成的JspServlet.class文件即可
Spring反向访问用户程序类问题
该问题是在深入理解JVM虚拟机第三版第9章被提出的:
要解决这个问题,我们还是先回顾一下Tomcat类加载器体系结构:
Tomcat的类加载体系结构并没有破坏原本的双亲委派机制,而是进一步扩展了原本的双亲链,WebApp类加载器负责加载/WebApp/WEB-INF下.class文件,而加载Spring jar包的工作是通过CommonClassLoader完成,CommonClassLoader作为WebAppClassLoader的父类加载器(是双亲链上的父类,不是继承上的),因此应用程序类可以委托父类加载器,访问到Spring jar下的类,但是目前Spring jar下的类似乎无法反向访问应用程序的类。
父类加载器将加载类请求委派给子类加载器做一做法显然打破了双亲委派机制,因此我们的思路就是如何打破双亲委派机制,让CommonClassLoader将加载应用程序类的请求委派给子类WebAppClassLoader呢?
思路有两种:
- 自定义类加载器,重写loadClass方法,这里很难实现,因为Spring jar需要提前确定需要从哪里加载用户应用程序的类。
- 通过线程上下文加载器,Spring设置自己的线程上下文加载器为WebAppClassLoader,如果后面需要加载用户程序的类,那么直接通过线程上下文加载器加载即可。
显然,如果Spring底层的servlet服务器实现是Tomcat,然后整合Tomcat时,只需要将自身当前线程上下文加载器设置为WebAppClassLoader即可。
提出一个问题: 什么情况下会出现spring需要访问用户程序类的场景呢?
- spring从配置文件或者包扫描途径加载be时,肯定需要通过
Class.forName(用户程序类的全类名)
来加载用户程序类的
像JDK核心库中很多SPI顶层接口,如JDBC,JNDI等,这些顶层接口的具体实现由第三方厂商提供,这样做的好处是提供了高度抽象,应用程序则只需要面向接口编程即可,不用关心具体实现,但问题在于所有顶层接口都由JDK提供,加载这些接口的类加载器是根加载器,第三方厂商提供的类库驱动则是由系统类加载器加载的。因此此时去需要通过线程上下文类加载器,将加载第三方厂商实现类的加载请求委托为子类加载器实现。
线程上下文类加载器
补充细节
只有当前类加载器通过自己的defineClass加载某个class字节流到JVM完成加载后,才会将当前被自己加载的类放入自己的已加载类缓存中去,这样我们通过findLoadedClass方法才会从缓存中寻找到这个已经被当前类加载器加载的类。
如果当前类加载器无法加载A类,而是委托给父类加载器加载,那么父类加载成功后,会将A类加入自己的类缓存,当前类加载器不会将类A加入自己的已加载类缓存中。
推荐资源
这里推荐三本书: 深入理解JVM第三版 , 自己动手写JVM , 深入理解JVM虚拟机第二版 , 虚拟机设计与实现
文章:
【转】Java类加载器:类加载原理解析
Java类加载器:线程上下文类加载器
如果想彻底弄懂,还是推荐阅读源码,可以看别人的源码分析文章。