Java虚拟机:深入了解ClassLoader(类加载器)

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

从最基础的Applet、JSP到相对复杂的OSGi技术,都使用了Java语言运行期类加载的特性。

我们先从Spring Boot的devtools开始说起。熟悉Spring Boot的同学大概都会用到devtools来实现热部署以节省开发时间提高效率,也就是这个依赖:

        <!-- 
             热部署,当应用程序以完整打包好的JAR或WAR文件形式运行时,开发者工具会被禁用,所以
             没有必要在构建生产部署包前移除这个依赖 Spring Boot开发者工具会在重启时排除掉如下
             目录:/META-INF/resources、/resources、/static、/public和/templates
         -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
        </dependency>

当引入此依赖时,当项目中的类文件发生变化,应用会自动重启。那么Spring Boot是如何实现的呢?

原理就是Spring Boot会使用两个ClassLoader,不会发生变化的类(例如第三方类库),由系统类加载器(AppClassLoader)加载,而导入到IDE的项目类文件会被重启类加载器(RestartClassLoader)加载。当应用重启时,这个RestartClassLoader对象会被抛弃并重新创建全新的一个,这就意味着应用重启速度会比“冷启动”快很多,因为不需要重新加载所有的类文件。

但是也因为这个特性,会导致一个比较微妙的问题。如下代码:

@Component
public class DevToolsRunner implements CommandLineRunner {
    @Override
    public void run(String... args) {
        // UserBaseDto是已导入到IDE的项目其它模块类文件
        UserBaseDto userBaseDto = new UserBaseDto();
        byte[] bytes = SerializationUtils.serialize(userBaseDto);
        Object deserializeUserBaseDto = SerializationUtils.deserialize(bytes);
        userBaseDto = (UserBaseDto) deserializeUserBaseDto;
        System.out.println(userBaseDto);
    }
}

当运行Spring Boot项目时,这一行代码:

userBaseDto = (UserBaseDto) deserializeUserBaseDto;

抛出了这个异常:

java.lang.ClassCastException: org.javamaster.b2c.dubbo.dto.UserBaseDto cannot be cast to org.javamaster.b2c.dubbo.dto.UserBaseDto

Why?明明是同一个class,同一个对象序列化后再重新反序列化回来,为啥不行了呢。

对于不熟悉Spring Boot的人来说,遇到这样的问题就比较棘手。在我最初接触Spring Boot的项目时就出现了这个问题,那时被这个问题困扰了挺久的。

后面查阅了Spring Boot的官方文档,才找到问题原因和解决方案。

原因如下,我们可以将ClassLoader打印出来看下:

@Component
public class DevToolsRunner implements CommandLineRunner {
    @Override
    public void run(String... args) {
        // UserBaseDto是已导入到IDE的项目其它模块类文件
        UserBaseDto userBaseDto = new UserBaseDto();
        // userBaseDto object classloader:RestartClassLoader
        System.out.println("userBaseDto object classloader:" 
          + userBaseDto.getClass().getClassLoader().getClass().getSimpleName());
        byte[] bytes = SerializationUtils.serialize(userBaseDto);
        Object deserializeUserBaseDto = SerializationUtils.deserialize(bytes);
        // deserialize userBaseDto object classloader:AppClassLoader
        System.out.println("deserialize userBaseDto object classloader:"
          + deserializeUserBaseDto.getClass().getClassLoader().getClass().getSimpleName());
        userBaseDto = (UserBaseDto) deserializeUserBaseDto;
        System.out.println(userBaseDto);
    }
}

可以看到,序列化前ClassLoader是RestartClassLoader,反序列后的ClassLoader是AppClassLoader,那当然报ClassCastException了。这个问题基本上在使用了Serializable的地方,例如把对象序列化保存到Redis再取回来,都会出现。

解决方案

方案一:SerializationUtils工具类的deserialize方法在做反序列化未考虑类加载器的问题,我们可以自己实现同样功能的工具方法:

public class SerializeUtils {
    public static Object deserialize(@Nullable byte[] bytes) {
        if (bytes == null) {
            return null;
        }
        try {
            ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
            ConfigurableObjectInputStream inputStream =
                    new ConfigurableObjectInputStream(stream, Thread.currentThread().getContextClassLoader());
            Object object = inputStream.readObject();
            return object;
        } catch (Exception e) {
            throw new RuntimeException("deserialize failed", e);
        }
    }
}

这样就确保序列化和反序列化用的是同一个类加载器。

方案二:在META-INF目录建立spring-devtools.properties文件,内容为:

# 注意,/b2c-dubbo-api-[\\w-]+\\.jar这种写法是不起作用的,尽管官方文档或者网上的资料都是这样写的,但经过我的实践
# 没有起作用。调试这个类org.springframework.boot.devtools.settings.DevToolsSettings的isMatch方法就可以看出结果。
# 需写成.*b2c-dubbo-api.*才能生效。
# 其中b2c-dubbo-api是要排除的不使用RestartClassLoader来加载的模块名。
# restart.exclude.b2c-dubbo-api=/b2c-dubbo-api-[\\w-]+\\.jar
restart.exclude.b2c-dubbo-api=.*b2c-dubbo-api.* 

spring-devtools.properties的作用是指示Spring Boot哪些模块不要使用RestartClassLoader来加载。

方案三:禁用devtools,注释掉devtools依赖或者在启动类加上:

@SpringBootApplication
@ComponentScan(basePackages = "org.javamaster.b2c")
public class ClassLoaderApplication {

    public static void main(String[] args) {
        // 关闭devtools重启功能
        System.setProperty("spring.devtools.restart.enabled", "false");
        SpringApplication.run(ClassLoaderApplication.class, args);
    }

}

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading) 验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。

本文章聚焦于加载阶段,其它略过。在加载阶段,虚拟机需要完成以下3点:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象, 作为方法区这个类的各种数据的访问入口。

其中实现第1点动作的代码模块称为“类加载器”,对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,也就是说,比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件, 被同一个虚拟机加载, 只要加载它们的类加载器不同,那这两个类就必定不相等。

双亲委派模型

类加载器可划分为:

  • 启动类加载器(BootstrapClassLoader) 负责加载Java核心类,例如/JAVA_HOME/lib目录下的类
  • 扩展类加载器(ExtClassLoader) 负责加载/JAVA_HOME/lib/ext目录下的类
  • 应用程序类加载器(AppClassLoader) 负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

这些类加载器之间的关系一般如下图所示:

类加载器之间的这种层次关系, 称为类加载器的双亲委派模型,但它并不是一个强制性的约束模型, 而是Java设计者推荐给开发者的一种类加载器实现方式。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。可以看下JDK的ClassLoader类的loadClass方法:

    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 {
                        // 调用启动类加载器加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                       //如果父类加载器抛出ClassNotFoundException
                       //说明父类加载器无法完成加载请求
                }

                if (c == null) {
                    //在父类加载器无法加载的时候
                    //再调用本身的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) {
                resolveClass(c);
            }
            return c;
        }
    }

注意,自定义的包名不能以java.lang等开头,否则无法被加载或者收到一个由虚拟机自己抛出的异常。

常见的支持jsp的Web服务器,大多数都支持HotSwap功能。我们知道,JSP文件最终要编译成Java Class才能由虚拟机执行,但是在运行时修改jsp文件为何无需重启也能生效呢?这里的原理也是一样的,每个jsp文件都由一个JasperLoader去加载,JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class,它出现的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

要想更深入学习类加载器,可以去看OSGi,OSGi对类加载器的运用非常独到。

最后,我们来写一个实际的运用。一般我们在项目开发中,大多数时候都会遇到这类情形:排查问题的过程中,想查看内存中的一些参数值,却又没有方法把这些值输出到界面或日志中,又或者定位到某个缓存数据有问题,但缺少缓存的统一管理界面,不得不重启服务才能清理这个缓存。

如果我们能够直接在服务器上动态执行程序代码,那么上述的问题就能迎刃而解。为了完成这个目标:

HotSwapClassLoader类:

public class HotSwapClassLoader extends ClassLoader {
    public HotSwapClassLoader() {
        // 实现提交的执行代码可以访问服务端引用类库
        super(HotSwapClassLoader.class.getClassLoader());
    }
    public Class<?> loadClassBytes(byte[] bytes) {
        return defineClass(null, bytes, 0, bytes.length);
    }
}

Controller类:

@RestController
@RequestMapping("/api")
public class ExecuteController {
    @RequestMapping(value = "/execute", method = {RequestMethod.POST})
    public Object execute(@RequestPart("file") MultipartFile[] multipartFile) throws Exception {
        byte[] bytes = multipartFile[0].getBytes();
        HotSwapClassLoader classLoader = new HotSwapClassLoader();
        Class<?> clz = classLoader.loadClassBytes(bytes);
        Method method = clz.getDeclaredMethod("execute");
        method.setAccessible(true);
        return method.invoke(null);
    }
}

Executor类,其中execute方法体内容可以随意修改,重新编译后上传到ExecuteController执行,这样就实现了在服务器上动态执行任意程序代码:

public class Executor {
    /**
     * 可在服务器上执行任意代码
     *
     * @return
     */
    public static Object execute() {
        // 这里代码可以随意修改,然后让服务器执行
        HelloService helloService = SpringUtils.getContext().getBean(HelloService.class);
        return helloService.sayHello();
    }
}

项目jar部署到服务器后(此处我自己本地电脑有一个linux环境,我把jar包部署在这里),可以使用postman等上传Executor的class文件,这里我使用curl来发起请求:

然后我修改了Exexcutor文件再重新编译:

可以看到,执行了新的代码。所以,这个功能还是很实用的。

项目gibhub示例代码:

jufeng98/java-mastergithub.com图标

编辑于 2019-06-26

文章被以下专栏收录