JVM之自定义ClassLoader(二)

570 阅读12分钟

上一篇文章:JVM之我们的代码是如何被加载到JVM执行的?(一)

我们讲到了常见的类加载器,以及类加载器的工作原理,我们也可以自定义类加载器来加深理解。

让我们来一个自定义类加载器

主要步骤:

  1. 继承ClassLoader
  2. 重写findClass()方法
public class MyClassLoader extends ClassLoader {
    private String path;

    public MyClassLoader(String path) {
        ClassLoader parent = getParent();
        System.out.println("parent == " + parent);
        this.path = path;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] result = getClass(name);
            if (result == null) {
                throw new ClassNotFoundException();
            } else {
               // 生成Class对象
                return defineClass(name, result, 0, result.length);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        return super.findClass(name);
    }

    private byte[] getClass(String name) {
        try {
            if (Objects.isNull(name) || name.length() == 0) return null;
            int index = name.lastIndexOf(".");
            String path = this.path;
            if(index == -1){
                path = path + name + ".class";
            }else{
                path = path + name.substring(index+1) + ".class";
            }
            return Files.readAllBytes(Paths.get(path));
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

// 将生成的Study.class文件放在E盘目录下
// 然后将项目中的Study.java和Study.class删除-->将Study类认为是非本项目的代码文件
// 通过本地的路径,加载项目之外的Study.class文件
public class JVMTest {
     public static void main(String[] args) throws Exception {
        MyClassLoader loader1 = new MyClassLoader("E:/");
        Class<?> clazz1 = loader1.findClass("com.tomato.Study");
        Study o = (Study)clazz1.newInstance();
     }
}

即使同一个Class文件加载出来的Class对象,JVM也可能认为是两个不同的class

如果我们没有将项目中Study.java删除,将会报异常:ClassCastException,明明是同一个Class文件加载,为什么转换的时候会是类型转换异常!!!

JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM才认为这两个class是相同的。就算两个class是同一份class字节码,如果被两个不同的ClassLoader实例所加载,JVM也会认为它们是两个不同class。比如网络上的一个Java类org.classloader.simple.NetClassLoaderSimple,javac编译之后生成字节码文件NetClassLoaderSimple.class,ClassLoaderA和ClassLoaderB这两个类加载器并读取了NetClassLoaderSimple.class文件,并分别定义出了java.lang.Class实例来表示这个类,对于JVM来说,它们是两个不同的实例对象,但它们确实是同一份字节码文件,如果试图将这个Class实例生成具体的对象进行转换时,就会抛运行时异常java.lang.ClassCaseException,提示这是两个不同的类型。

我们既然已经自定义了类加载器,但是除了上面我们手动加载一个类的场景之外,JVM是什么时候加载Class文件的?是不是所有的Class文件都加载出来?如果一下全加载进来,启动速率肯定受影响,那怎么优化呢?

类加载什么时候加载Class文件?

项目启动时,并不是在把所有Class全部加载到内存中的,采用的是LazyLoading方式,按需加载。 其中有五个必须加载类的情况。

  1. new getstatic putstatic invokestatic 指令,访问final除外,因为final在编译为class文件时就确定好了
  2. java.lang.reflect 对类反射调用时
  3. 初始化子类是,父类必定被先加载
  4. 虚拟机启动时,需要执行的主类需要加载
  5. 动态语言支持java.lang.invoke.MethodHandle解析的结果为REF_getstatic、REF_putstatic、REF_invokestatic的方法句柄时,该类被初始化。

上面我们知晓了类加载器加载Class文件的时机,下面则是解答加载的三种执行模式。

JVM的三种执行模式

之前有说过Java文件从开始编译到最后执行的流程:1.xxx.java -> 2.javac编译 -> 3.xxx.class -> 4.ClassLoader -> 5.字节码解释器、JIT -> 6.执行引擎执行

Snipaste_2021-07-05_16-23-37.png

其中第五步就涉及到执行模式,调整JVM可以更改执行模式(默认混合模式)

  • 解释模式

通过解释器(Bytecode Interpreter)解释执行,因为不需要编译,启动很快,但是执行慢,可通过-Xin参数指定为纯解释模式。

  • 编译模式

由JIT编译为本地代码(C语言实现),运行很快但是启动慢,可以用-Xcomp参数指定为纯编译模式。

  • 混合模式

由于上面两种方式各有各的好,所以JVM就把两种模式都用上,并加上“热点代码编译”合称之为混合模式,起始阶段采用解释执行,然后通过热点代码检测(HotSpot),检测这段代码是否为热点代码,如果是则将它编译。可以用-Xmixed参数指定为混合模式。

在混合模式下,JVM是通过热点代码的检测来判断这段代码是否需要被编译(被优化):

  1. 多次被调用的方法(方法计数器:监测方法执行频率);
  2. 多次被调用的循环(循环计数器:监测循环执行频率);

十分特殊的“上下文类加载器”

之前的介绍的常见的类加载器:Bootstrap ClassLoader,Extention ClassLoader,App ClassLoader,Customer ClassLoader都是实际存在的,但是这个“上下文类加载器”是一个定义,它可以是上面实际中的任何一个。

设置上下文类加载器是属于线程的方法Thread.setContextClassLoader(ClassLoader cl)

官方解释:

    /**
     * Sets the context ClassLoader for this Thread. The context
     * ClassLoader can be set when a thread is created, and allows
     * the creator of the thread to provide the appropriate class loader,
     * through {@code getContextClassLoader}, to code running in the thread
     * when loading classes and resources.
     * 为这个线程设置上下文类加载器。这个上下文类加载器可以在线程创建的时候设置(指定),也允许
     * 这个线程的创建者提供适当的类加载器,通过getContextClassLoader方法在运行的线程中加载类
     * 或者加载资源。
     * 
     */
    public void setContextClassLoader(ClassLoader cl) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkPermission(new RuntimePermission("setContextClassLoader"));
        }
        contextClassLoader = cl;
    }

我自己理解就是即可以setContextClassLoader()方法指定类加载器,如果不指定则使用当前创建这个线程的地方的类加载器(有的文章也指出是父线程的加载器,套我自己的理解,我觉得是一样的,因为当前new Thread()的线程也是当前线程的创建者,如果有不同的说法,感谢大家可以在评论下方告知我,我去学习学习)。

使用“上下文类加载器”干什么用? -- 打破双亲委派机制

由于“即使同一个Class文件加载出来的Class对象,JVM也可能认为是两个不同的class”的原因,双亲委派机制的确有各种好,但是身为(渣男)有进取心的研发人员肯定不满足“双亲”安排的机制,研发人员需要的是(自由)自定义的权利。同时我们会发现很多核心类是被Bootstrap ClassLoader加载的,但是它们的实现可能是交给其他厂商实现。例如JNDI,JDBC等Java的服务提供者接口(Service Provider Interface,SPI),核心代码被Bootstrap ClassLoader加载,但是JNDI和JDBC加载的是第三方厂商提供的实现。这种情况下,就可以使用这种机制“打破”双亲委派机制,从而有效的打破双亲委派机制带来的限制。

使用自定义类加载器,完成热插拔式功能

学了这么多理论知识,那到底我们可以使用这些理论完成哪些功能?(十年磨枪,不为出枪,难道是为了麒麟臂)

下面带大家完成一个简单的服务器热插拔式功能:修改了新代码不需要重新编译整个项目、部署等操作,只要将上传新代码至服务器,就能运行最新代码。

实现步骤

  1. 构建Springboot工程
  2. 新建NetHotSwapClassLoader.java -- 网络热插拔类加载器
  3. 准备两个不同版本的XXXService.java -- 用于验证热插拔功能的服务类
  4. 新建HotSwapController.java -- 用于上传Class文件,并最终调用Class文件中的执行代码

PS:为了让同学更加理解整个过程,以及加深所学知识,这个热插拔功能写了两版

  • NetHotSwapClassLoader.java -- 网络热插拔类加载器
/**
 * 网络热插拔类加载器
 */
public class NetHotSwapClassLoader extends ClassLoader {

    /**
     * 构造方法
     */
    public NetHotSwapClassLoader() {
        // 使得加载NetHotSwapClassLoader与后续NetHotSwapClassLoader加载其他类的父加载器一致
        // 作用:保证类加载器一致
        super(NetHotSwapClassLoader.class.getClassLoader());
    }

    /**
     * 加载类
     * @param bytes Class文件的字节流
     * @return Class对象
     * @throws ClassNotFoundException 异常
     */
    public Class<?> loadClassBytes(byte[] bytes) throws ClassNotFoundException {
        return defineClass(null, bytes, 0, bytes.length);
    }
}
  • XXXService.java -- 第一个版本的代码
// XXXService第一版代码
public class XXXService {

    public static Object processStatic() {
        // 第一版代码
        return "processStatic:Hello World!";
    }

    public Object process() {
        // 第一版代码
        return "process:Hello World!";
    }
}
  • HotSwapController.java
@RestController
@RequestMapping("/hotSwap")
public class HotSwapController {

    @PostMapping(value = "process")
    public Object process(@RequestPart("file") MultipartFile[] multipartFiles) {
        try {
            byte[] bytes = multipartFiles[0].getBytes();
            // 使用网络热插拔类加载器
            NetHotSwapClassLoader classLoader = new NetHotSwapClassLoader();
            // 加载类
            Class<?> clazz = classLoader.loadClassBytes(bytes);
            Object instance = clazz.newInstance();
            // 执行上传的class文件中的代码(非现在项目中的代码)
            Method method1 = clazz.getDeclaredMethod("processStatic");
            Method method2 = clazz.getDeclaredMethod("process");
            Object invoke1 = method1.invoke(null);
            Object invoke2 = method2.invoke(instance);
            System.out.println("processStatic执行结果 == " + invoke1);
            System.out.println("process执行结果 == " + invoke2);
            return invoke1;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "异常出现,未按预计执行";
    }

    @GetMapping(value = "test")
    public Object test() {
        // 试运行第一版代码
        XXXService hotSwapService = new XXXService();
        Object processStatic = XXXService.processStatic();
        Object process = hotSwapService.process();

        System.out.println("test processStatic执行结果 == " + processStatic);
        System.out.println("test process执行结果 == " + process);
        return processStatic;
    }
}

准备到这一步,我们现在先通过Postman调用/hotSwap/test接口,测试现在的执行的代码,运行结果如下:

test processStatic执行结果 == processStatic:Hello World!
test process执行结果 == process:Hello World!

我们现在已经看到线上的是第一版的代码,那我们在不重新部署第二版代码的情况下,却执行第二版的代码: 现在准备第二版本的代码

  • XXXService.java -- 第二个版本的代码(第二版代码可自行编写,不一定要参照我这样写,只要两个版本代码有区别就行)
public class XXXService {

    public static Object processStatic() {
        // 第二版 代码  通过SpringBoot生成的SbProperties
        SbProperties bean = SpringContextHolder.getApplicationContext().getBean(SbProperties.class);
        return bean;
    }

    public Object process() {
        // 第二版 代码  通过SpringBoot生成的HelloService
        HelloService helloService = SpringContextHolder.getApplicationContext().getBean(HelloService.class);
        return helloService.test1();
    }
}

将第二版代码编译获得XXXService.class文件,并使用Postman上传至接口/hotSwap/process(注意:此时启动的服务一直运行的都是第一版代码)

获得执行结果如下:

processStatic执行结果 == SbProperties(title=好好学习, desc=天天向上)
process执行结果 == service test1

细心的朋友可以在此时,如果再调用/hotSwap/test接口会发现执行的还是第一版的代码,Why?我们可以回顾之前的内容,非同一个ClassLoader加载的Class,JVM依然认为它俩不是同一个Class,所以如果要达成调用/hotSwap/test,也是最新的第二版代码,需要保证两个Class是同一个ClassLoader加载出来的,并且要将第二个版本的Class对象刷到内存当中,这样就能完成全局的Class对象生效。

让我们来实现一下第二版。

1.新写接口YService,方法定义与XXXService一致(目的是为了使用YService来规避显性使用XXXService带来的类型转换异常,例如上一版直接在test()方法里就显性使用了XXXService),然后让XXXService继承它。(由于之前的第二版代码没有继承YService导出的Class,所以需要重新实现YService,再将第二版代码重新导出!!!这步很重要,不然拿之前的Class文件上传会出问题))

public interface YService {
    Object process();
}

// XXXService第一版代码(新)
public class XXXService implement YService {

    public static Object processStatic() {
        // 第一版代码
        return "processStatic:Hello World!";
    }

    @Override
    public Object process() {
        // 第一版代码
        return "process:Hello World!";
    }
}

// XXXService第二版代码(新)
public class XXXService implement YService {

    public static Object processStatic() {
        // 第二版代码
        return "Good Good Study!";
    }

    @Override
    public Object process() {
        // 第二版代码
        return "Day Day UP!";
    }
}


2.改造NetHotSwapClassLoader

/**
 * 网络热插拔类加载器
 */
public class NetHotSwapClassLoader extends ClassLoader {


   public static final Map<String, byte[]> classBytes = new HashMap<String, byte[]>();
   public static final Map<String,Class<?>> classMap = new HashMap<String, Class<?>>();


    /**
     * 构造方法
     */
    public NetHotSwapClassLoader() {
        // 使得加载NetHotSwapClassLoader与后续NetHotSwapClassLoader加载其他类的父加载器一致
        // 作用:保证类加载器一致
        super(NetHotSwapClassLoader.class.getClassLoader());
    }

    /**
     * 由于很多时候我们使用了很多核心类库里的东西,例如Object,String对象,
     * 如果重写时不注意,很可能会发生ClassNotFoundException异常,因为根据ClassPath,这些类库都是由Bootstrap ClassLoader加载出来的。
     * 自定义的ClassLoader加载的ClassPath根本没包含这个区域。
     */
//    @Override
//    public Class<?> loadClass(String name) throws ClassNotFoundException {
//        return super.loadClass(name);
//    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        // 打破双亲委派机制,不从父类加载器中寻找
        byte[] buf = classBytes.get(name);
        if (buf == null) {
            return Class.forName(name);
        }
        if(classMap.containsKey(name)){
            return classMap.get(name);
        }
        Class<?> aClass = defineClass(name, buf, 0, buf.length);
        return aClass;
    }

    /**
     * 加载类
     * @param bytes Class文件的字节流
     * @return Class对象
     * @throws ClassNotFoundException 异常
     */
    public Class<?> loadClassBytes(String name,byte[] bytes) throws ClassNotFoundException {
        return defineClass(name, bytes, 0, bytes.length);
    }
}

  1. HotSwapController.java
@RestController
@RequestMapping("/hotSwap")
public class HotSwapController {

    @PostMapping(value = "process")
    public Object process(@RequestPart("file") MultipartFile[] multipartFiles) {
        try {

            byte[] bytes = multipartFiles[0].getBytes();
            NetHotSwapClassLoader.classBytes.put(XXXService.class.getName(),bytes);
            // 使用网络热插拔类加载器
            NetHotSwapClassLoader classLoader = new NetHotSwapClassLoader();
            // 加载类
            Class<?> clazz = classLoader.findClass(XXXService.class.getName());
            Object instance = clazz.newInstance();
            // 执行上传的class文件中的代码(非现在项目中的代码)
            Method method1 = clazz.getDeclaredMethod("processStatic");
            Method method2 = clazz.getDeclaredMethod("process");
            Object invoke1 = method1.invoke(null);
            Object invoke2 = method2.invoke(instance);
            System.out.println("processStatic执行结果 == " + invoke1);
            System.out.println("process执行结果 == " + invoke2);
            return invoke1;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "异常出现,未按预计执行";
    }

    @GetMapping(value = "test")
    public Object test() {
        Object processStatic = null;
        
        // 使用网络热插拔类加载器
        NetHotSwapClassLoader classLoader = new NetHotSwapClassLoader();
        try {
            // 由于loadClass方法的双亲委派机制,我们加载Class的方法直接使用findClass
            // 上面的文章也解析了源码,双亲委派机制找不到也加载不出来时,也是类加载器通过findClass加载Class
            Class<?> clazz = classLoader.findClass(XXXService.class.getName());
            YService hotSwapService = (YService) clazz.newInstance();
            processStatic = hotSwapService.process();
        } catch (Exception e) {
            e.printStackTrace();
        }

        return processStatic;
    }
}

未上传前执行的是第一版代码,上传完Class文件后,再次执行test()方法,发现执行的都是最新的代码。因为没有封装,所以看起来很冗余,可以抽取出来写成工具类,这样就比较顺眼一点。

至此,通过了解ClassLoader,完成了一个简单的热插拔式的功能。

希望通过两个版本的对比,期望能让同学们加深理解Classloader。