前言
对于Java
应用而言,热加载就是在运行过程中实现Class
文件在JVM
中的重新加载,而不用重新启动应用。对有些项目而言,有些公司升级比较频繁为了线上程序的稳定性一般采取增量升级
的形式。如果只是单纯的替换Class
文件,应用程序依然使用的是旧的代码。此时,热加载就显得尤为重要。本文即是对一种热加载实现方式的介绍,希望对大家有所帮助。
要实现这个功能的核心是动态的替换已经存在于JVM
中的Class
对象。所以我们必须先来了解JVM
的类加载机制。
JVM 类加载机制
类加载流程
首先,Java
代码进入JVM
的流程如下,我简单画了个图:
一旦加载进虚拟机中,我们就可以正常的使用关键字new
这个对象了。可以看到这里加载的关键是Classloader
。这里就不得不提Java
中提供的三种类加载器。
- BootstrapClassloader
- ExtClassloader
- AppClassloader
BootstrapClassloader
叫做启动类加载器,用于加载JRE
核心类库,使用C++
实现。加载路径、%JAVA_HOME%/lib
下的所有类库。
ExtClassloader
扩展类加载器,加载%JAVA_HOME%/lib/ext
中的所有类库。
AppClassloader
应用类加载器也叫系统类加载器System Classloader
,加载%CLASSPATH%
路径下的所有类库。
双亲委派
既然说到这三种类加载器,就不得不提著名的双亲委派机制
。在介绍这种机制之前,直接看它解决了什么问题。虚拟机利用该机制保证一个类不会被多个类加载器重复加载,并且保证核心API不会被篡改
。OK,一图胜千言。
一般而言,如果我们没有使用自定义的类加载器,程序默认使用的是应用类加载器即系统类加载器。当类加载器需要加载某个.class
文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
看到这里我想大家就应该能明白,如果一个Class
文件被不同的类加载器加载,虚拟机认为它们不是同一个类。如果互相转换的话会报ClassCastException
。
即便你新建一个和核心类库中全限定类名完全一致的类(比如java.lang.String
),在虚拟机中也会被认为是两个类(它们存在于虚拟机中的地址是不同的),系统的执行也就有了安全上的保障。
我们可以查看源码来学习下是如何实现双亲委派的,出自ClassLoader
类。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先检查是否已被当前类加载器加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//如果存在父类加载器,则递归此方法
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//否则使用启动类加载器加载,进到这里代表当前加载器为Launcher$ExtClassloader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
//当抛出ClassNotFoundException异常
//代表BootstrapClassloader也没有加载到,则调用自己的findClass方法来加载
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//链接,将单一的Class加入到有继承关系的类树中
resolveClass(c);
}
return c;
}
}
其中指的一提的是ExtClassLoader
和ExtClassLoader
都继承自URLClassLoader
,也因此调用findClass
方法时调用的是父类URLClassLoader
的方法。
通过查看源码可以发现:
ExtClassLoader
加载路径为System.getProperty("java.ext.dirs")
AppClassloader
加载路径为System.getProperty("java.class.path")
BootstrapClassloader
加载路径为System.getProperty("sun.boot.class.path")
这也间接佐证了前文介绍的类加载器加载路径的结论。
那么,当Launcher$ExtClassloader
执行完findClass
未找到对应的Class
时是如何委托给AppClassloader
的呢?
可以看到URLClassLoader
中的findClass
没有找到时抛出的是ClassNotFoundException
异常:
protected Class<?> findClass(final String name)
throws ClassNotFoundException
{
final Class<?> result;
try {
result = AccessController.doPrivileged(
new PrivilegedExceptionAction<Class<?>>() {
public Class<?> run() throws ClassNotFoundException {
String path = name.replace('.', '/').concat(".class");
Resource res = ucp.getResource(path, false);
if (res != null) {
try {
return defineClass(name, res);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
} else {
return null;
}
}
}, acc);
} catch (java.security.PrivilegedActionException pae) {
throw (ClassNotFoundException) pae.getException();
}
if (result == null) {
throw new ClassNotFoundException(name); //这里抛出了异常
}
return result;
}
那么我们上层代码则直接退出了,由于之前loadClass
方法方法是递归进来的,所以异常便被抛到了AppClassloader
实例并最终被ClassNotFoundException
捕获,从而AppClassloader
完成findClass
操作。
此外,我们通过查看虚拟机入口sun.misc.Launcher
类(为了方便查看,精简了部分代码):
public class Launcher {
private static Launcher launcher = new Launcher();
private static String bootClassPath =
System.getProperty("sun.boot.class.path");
public static Launcher getLauncher() {
return launcher;
}
private ClassLoader loader;
public Launcher() {
// Create the extension class loader
ClassLoader extcl;
try {
//创建ExtClassLoader其父加载器为null
extcl = ExtClassLoader.getExtClassLoader();
} catch (IOException e) {
throw new InternalError(
"Could not create extension class loader", e);
}
// Now create the class loader to use to launch the application
try {
//设置AppClassLoader的父加载器为ExtClassLoader
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader", e);
}
// 设置 AppClassLoader 为线程上下文类加载器
Thread.currentThread().setContextClassLoader(loader);
}
/*
* Returns the class loader used to launch the main application.
*/
public ClassLoader getClassLoader() {
return loader;
}
/*
* The class loader used for loading installed extensions.
*/
static class ExtClassLoader extends URLClassLoader {}
/**
* The class loader used for loading from java.class.path.
* runs in a restricted security context.
*/
static class AppClassLoader extends URLClassLoader {}
}
看到这里我们可以明白,AppClassLoader
的父加载器为ExtClassLoader
,而ExtClassLoader
的父加载器为null
。
我们可以打印一下:
public class PrintClassloader {
public static void main(String[] args) {
System.out.println(PrintClassloader.class.getClassLoader());
System.out.println(PrintClassloader.class.getClassLoader().getParent());
System.out.println(PrintClassloader.class.getClassLoader().getParent().getParent());
System.out.println(Thread.currentThread().getContextClassLoader());
// DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/mysqlDB", "root", "root");
}
}
其输出的结果为:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@61bbe9ba
null
sun.misc.Launcher$AppClassLoader@18b4aac2
和我们预期的一样。
全盘委派
全盘委派
是指当一个ClassLoader
装载一个类时,除非显示地使用另一个ClassLoader
,则该类所依赖及引用的类也由这个ClassLoader
载入。
是不是不太明白?简单点说,程序的入口使用的是什么类加载器,那么后面的类new
出的对象也使用该类加载器。
看一个实际的例子:
先来看一下获取Mysql
连接的一段代码:
DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/mysqlDB", "root", "root");
首先DriverManager
由rt.jar
包提供,也就是由BootstrapClassloader
加载。数据库厂商使用SPI
技术提供驱动的实现。那么问题来了,三方提供的包引用后是在classpath
下,BootstrapClassloader
加载器是无法完成加载的。我们看看具体是如何打破这种机制完成加载的的,来看看如何获取连接(省略部分代码):
public static Connection getConnection(String url,
String user, String password) throws SQLException {
java.util.Properties info = new java.util.Properties();
if (user != null) {
info.put("user", user);
}
if (password != null) {
info.put("password", password);
}
return (getConnection(url, info, Reflection.getCallerClass()));
}
其中Reflection.getCallerClass()
方法是获取调用方的类加载器,此处我们的调用方是PrintClassloader
,作为一个普通的类,它的类加载器自然是AppClassloader
。继续看下面的代码:
private static Connection getConnection(
String url, java.util.Properties info, Class<?> caller) throws SQLException {
//获取调用方的类加载器(AppClassloader)
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized(DriverManager.class) {
if (callerCL == null) {
//如果没有则获取线程上下文中的类加载器,如果没有特别设置一般是应用类加载器
callerCL = Thread.currentThread().getContextClassLoader();
}
}
SQLException reason = null;
for(DriverInfo aDriver : registeredDrivers) {
//重要函数isDriverAllowed
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
}
}
throw new SQLException("No suitable driver found for "+ url, "08001");
}
是否允许加载:
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
boolean result = false;
if(driver != null) {
Class<?> aClass = null;
try {
//使用指定的类加载器进行加载
aClass = Class.forName(driver.getClass().getName(), true, classLoader);
} catch (Exception ex) {
result = false;
}
result = ( aClass == driver.getClass() ) ? true : false;
}
return result;
}
可以看到最终反射时使用指定类加载器的形式进行加载。
热加载
千呼万唤始出来,终于到了激动人心的热加载环节。由于以上的基础,我们知道只需要提供自己的类加载器并需要重写loadClass
方法,即可完成自己的类加载机制。当然,我们还需要一个文件夹监听器,发现目录下的.class
文件发生变更,便重新加载。
动态类加载器
首先,我们需要自己控制需要监听目录的.class
文件变更,只有这些发生变化时我们才需要重新加载。而其他系统自带的如java.lang.String
类等还是由原来的系统类加载器去加载(遵循双亲委派机制)。
这里直接给出所有代码。
public class DynamicClassLoader extends URLClassLoader {
private final Map<String, Class<?>> classCache = new ConcurrentHashMap<>();
//所有需要我们自己加载的类
private final Map<String /** classname **/, File> fileCache = new ConcurrentHashMap<>();
public DynamicClassLoader() {
super(new URL[0]);
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
//查找本地classloader命名空间中是否已有存在的class对象
// final Class<?> c = findLoadedClass(name);
final Class<?> c = classCache.get(name);
if (c == null) {
if (fileCache.containsKey(name)) {
throw new ClassNotFoundException(name);
} else {
//这个类不需要我们加载(如 java.lang.String 或者我们未指定的路径),交给AppClassloader
return getSystemClassLoader().loadClass(name);
}
}
return c;
}
}
/**
* 递归添加指定目录下的文件到类加载器的加载路径中
*/
public void addURLs(String directory) throws IOException {
Collection<File> files = FileUtils.listFiles(
new File(directory),
new String[]{"class"},
true);
for (File file : files) {
String className = file2ClassName(file);
fileCache.putIfAbsent(className, file);
}
}
public void load() throws IOException {
for (Entry<String, File> fileEntry : fileCache.entrySet()) {
final File file = fileEntry.getValue();
this.load(file);
}
}
private Class<?> load(File file) throws IOException {
final byte[] clsByte = file2Bytes(file);
final String className = file2ClassName(file);
Class<?> defineClass = defineClass(className, clsByte, 0, clsByte.length);
classCache.put(className, defineClass);
return defineClass;
}
private byte[] file2Bytes(File file) {
try {
return IOUtils.toByteArray(file.toURI());
} catch (IOException e) {
e.printStackTrace(System.err);
return new byte[0];
}
}
private String file2ClassName(File file) throws IOException {
final String path = file.getCanonicalPath();
final String className = path.substring(path.lastIndexOf("/classes") + 9);
return className.replaceAll("/", ".").replaceAll(".class", "");
}
}
addURLs
是增加监控目录,该方法是递归返回该目录下的所有.class
文件。等会使用load
方法即可加载到虚拟机中。
其中最关键的则是defineClass
方法,加载.class
的二进制到虚拟机中。当然只能加载一次,如果再次调用则会报错,该类已被加载。
classCache
用来保存已被我们自定义加载器加载的Class
,同时它可以判断当前的类是否需要被我们加载。如果不需要则交给系统类加载器去加载。
具体怎么使用呢?
//初始化类加载器
DynamicClassLoader classLoader = new DynamicClassLoader();
classLoader.addURLs("/Users/pleuvoir/dev/space/git/hot-deploy/target/classes/io/github/pleuvoir");
classLoader.load();
这样即完成了加载。
程序入口
public class Bootstrap {
public static void main(String[] args) throws Exception {
//初始化类加载器
DynamicClassLoader classLoader = new DynamicClassLoader();
classLoader.addURLs(Const.HOT_DEPLOY_DIRECTORY);
classLoader.load();
start0(classLoader);
}
public void start() {
final Mock mock = new Mock();
mock.say();
}
public static void start0(ClassLoader classLoader) throws Exception {
//启动类文件监听器
ClassFileMonitor.start();
//使用全局委派
Class<?> startupClass = classLoader.loadClass("io.github.pleuvoir.Bootstrap");
Object startupInstance = startupClass.getConstructor().newInstance();
String methodName = "start";
Method method = startupClass.getMethod(methodName);
method.invoke(startupInstance);
}
}
再来看程序入口类,这里就是使用了我们的自定义加载器调用load
方法完成指定目录下类文件的加载。同时,在start0
中启动了类文件监听器(下文再说)。
这里为什么使用反射调用该类的方法,便是利用了全局委托
机制。因为Bootrap
是被我们的自定义类加载器加载的,所以它调用start
方法后,new Mock()
对象也是用的是我们的自定义类加载器。试想,如果采用new Bootstrap().start()
方法。那使用的是什么类加载器呢?答案是AppClassLoader
。这样的话,我们创建的新对象就不是我们自定义的类加载器了。
文件监听器
然后来实现文件监听器,我们使用apache common-io
包来完成监听文件变化,其实自己实现也很简单。就是查看文件的lastModify
属性有没有发生变化。
public class ClassFileMonitor {
public static void start() throws Exception {
IOFileFilter filter = FileFilterUtils.and(FileFilterUtils.fileFileFilter(), FileFilterUtils.suffixFileFilter(".class"));
FileAlterationObserver observer = new FileAlterationObserver(new File(Const.HOT_DEPLOY_DIRECTORY), filter);
observer.addListener(new ClassFileAlterationListener());
FileAlterationMonitor monitor = new FileAlterationMonitor(Const.HOT_DEPLOY_CLASS_MONITOR_INTERVAL, observer);
monitor.start();
System.out.println("热加载文件监听已启动。");
}
private static class ClassFileAlterationListener extends FileAlterationListenerAdaptor {
@Override
public void onFileChange(File file) {
System.out.println("文件变化了" + file.getAbsolutePath());
try {
//初始化类加载器
DynamicClassLoader classLoader = new DynamicClassLoader();
classLoader.addURLs(Const.HOT_DEPLOY_DIRECTORY);
classLoader.load();
Bootstrap.start0(classLoader);
} catch (Throwable e) {
e.printStackTrace(System.err);
} finally {
System.gc();
}
}
}
}
这里很好理解,当监听目录下的.class
文件发生变化时,重新创建类加载器。并调用我们提供的函数入口方法。至于为什么要重新创建类加载器,是因为原有的类加载器加载的类无法卸载,所以需要重新创建新的类加载器。
这里其实还有点问题,如果之前的业务方法start
中包含不可退出的代码,如死循环,那么它还会继续执行。此外System.gc()
便不能正常回收之前创建的类加载器,造成类加载器泄露
。
后语
本文是对动态热替换的一种实践,希望对大家有所帮助。由于此文只是演示,并没有考虑到方方面面。有兴趣的读者可以自己尝试,如动态加载Jar
,配合Spring
完成热替换等。代码已上传 github.com/pleuvoir/ho… 供参考。