JVM - 从类加载器到热加载

2,916 阅读10分钟

前言

对于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;
    }
}

其中指的一提的是ExtClassLoaderExtClassLoader都继承自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");

首先DriverManagerrt.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… 供参考。