性能调优——02 Java类加载机制升职加薪之旅

1 快速梳理Java类加载机制

  • 三句话总结JDK8的类加载机制:
    • 类缓存:每个类加载器对他加载过的类都有⼀个缓存;
    • 双亲委派:向上委托查找,向下委托加载;
    • 沙箱保护机制:不允许应⽤程序加载JDK内部的系统类;

1.1 JDK8 的类加载体系

  • 先看一个简单的 Demo:

    package com.roy.cl;
    
    public class LoaderDemo {
        public static String a ="aaa";
    
        public static void main(String[] args) throws ClassNotFoundException {
            // 父子关系 AppClassLoader <- ExtClassLoader <- BootStrap Classloader
            ClassLoader cl1 = LoaderDemo.class.getClassLoader();
            System.out.println("cl1 > " + cl1);
            System.out.println("parent of cl1 > " + cl1.getParent());
            // BootStrap Classloader由C++开发,是JVM虚拟机的一部分,本身不是JAVA类。
            System.out.println("grant parent of cl1 > " + cl1.getParent().getParent());
            // String,Int等基础类由BootStrap Classloader加载。
            ClassLoader cl2 = String.class.getClassLoader();
            System.out.println("cl2 > " + cl2);
            System.out.println(cl1.loadClass("java.util.List").getClass().getClassLoader());
    
            // java指令可以通过增加-verbose:class -verbose:gc 参数在启动时打印出类加载情况
            // BootStrap Classloader,加载java基础类。这个属性不能在java指令中指定,推断不是由java语言处理。。
            System.out.println("BootStrap ClassLoader加载目录:" + System.getProperty("sun.boot.class.path"));
            // Extention Classloader 加载JAVA_HOME/ext下的jar包。 可通过-D java.ext.dirs另行指定目录
            System.out.println("Extention ClassLoader加载目录:" + System.getProperty("java.ext.dirs"));
            // AppClassLoader 加载CLASSPATH,应用下的Jar包。可通过-D java.class.path另行指定目录
            System.out.println("AppClassLoader加载目录:" + System.getProperty("java.class.path"));
        }
    }
    
    • 运行结果:这段代码演示了Java的类加载器层次结构及其加载路径,输出结果验证了Java的三层类加载器架构及其职责划分;

      在这里插入图片描述

      1. 类加载器父子关系:
        • AppClassLoader(应用类加载器)是当前类的加载器
        • 其父加载器是ExtClassLoader(扩展类加载器)
        • ExtClassLoader的父加载器是Bootstrap ClassLoader(显示为null,因为它是C++实现的)
      2. 核心类加载:
        • String等基础类由Bootstrap ClassLoader加载(显示为null)
        • java.util.List也由Bootstrap ClassLoader加载
      3. 各类加载器的加载路径:
        • Bootstrap:加载JDK核心库(rt.jar等)
        • ExtClassLoader:加载JRE扩展目录(lib/ext)
        • AppClassLoader:加载应用classpath指定的路径
  • 可以看到 JDK8 中的两个类加载体系:

    在这里插入图片描述

    • 左侧是JDK中实现的类加载器,通过parent属性形成⽗⼦关系。应⽤中⾃定义的类加载器的parent都是AppClassLoader
    • 右侧是JDK中的类加载器实现类,通过类继承的机制形成体系,可以通过继承相关的类实现⾃定义类加载器;
  • JDK8中的类加载器都继承于⼀个统⼀的抽象类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 {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
    
                if (c == null) {
                    // 发现⽗类加载起没有加载过,就⾃⾏解析class⽂件加载
                    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();
                }
            }
            // 这⼀段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。运⾏时加载类,默认是⽆法进⾏链接步骤的。
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
    
    • 这个⽅法就是最为核⼼的双亲委派机制,并且这个⽅法是protected声明的,这意味着这个⽅法是可以被⼦类覆盖的,所以双亲委派机制也是可以被打破的;
  • 当⼀个类加载器要加载⼀个类时,整体的过程就是通过双亲委派机制向上委托查找,如果没有查找到,就向下委托加载。整个过程整理如下图:

    在这里插入图片描述

1.2 沙箱保护机制

  • 01 全面理解JVM虚拟机

1.3 Linking 链接过程

  • ClassLoaderloadClass⽅法中,还有⼀个不起眼的步骤:resolveClass,这是⼀个native⽅法。其实现的过程称为 linking 链接,链接过程的实现功能如下图:

    在这里插入图片描述

    • 其中关于半初始化状态就是JDK在处理⼀个类的static静态属性时,会先给这个属性分配⼀个默认值,作⽤是占住内存。然后等连接过程完成后,在后⾯的初始化阶段,再将静态属性从默认值修改为指定的初始值;
  • 例:

    package com.roy.clinit;
    
    // Apple.apple访问了类的静态变量,会出触发的初始化,即加载-》链接-》初始化
    // 当执行构造函数时,price还没有初始化完成,处于链接阶段的准备阶段,其值为默认值 0,这时构造函数的 price 就是 0,所以最终打印出来的结果是-10而不是10
    class Apple {
        static Apple apple = new Apple(10);
        static double price = 20.00;
        double totalpay;
    
        public Apple(double discount) {
            System.out.println("====" + price);
            totalpay = price - discount;
        }
    }
    
    public class PriceTest01 {
        public static void main(String[] args) {
            System.out.println(Apple.apple.totalpay);
        }
    }
    
    • 结果:

      ====0.0
      -10.0
      
    • 如果想要输出10,可以直接给price属性加一个final关键字,该关键字表示该属性不可变,所以在赋初始值的时候直接赋值20;

  • 思考:为什么在ClassLoader的这个loadClass⽅法中,reslove参数只能传个false,⽽不让传true?

    在这里插入图片描述

    • 这个参数会传递到loadClass方法中的:

      if (resolve) {
          resolveClass(c);
      }
      
    • 原因:在创建对象的过程中,是需要申请内存的。JVM希望所有内存在进程启动的时候把类都创建好,而不能在运行过程中再去申请内存,即不让开发者去参与 Linking 的过程;

    • loadClassforName的区别:

      在这里插入图片描述

      • LoaderDemo类中添加一个静态代码块;

        static {
            System.out.println("Hello World");
        }
        
      • 编写一个 Test 类

        package com.roy.cl;
        
        public class Test {
            public static void main(String[] args) throws ClassNotFoundException {
                ClassLoader cl1 = Test.class.getClassLoader();
                cl1.loadClass("com.roy.cl.LoaderDemo");
                System.out.println("=========");
                Class.forName("com.roy.cl.LoaderDemo");
            }
        }
        
      • 结果:可以发现第一个loadClass没有执行静态代码块中的打印方法,说明loadClass没有执行 Linking 过程;

        =========
        Hello World
        
  • 符号引用与直接引用:

    • 如果A类中有⼀个静态属性,引⽤了另⼀个B类。那么在对类进⾏初始化的过程中,因为A和B这两个类都没有初始化,JVM并不知道A和B这两个类的具体地址。所以这时,在A类中,只能创建⼀个不知道具体地址的引⽤,指向B类。这个引⽤就称为符号引⽤
    • ⽽当A类和B类都完成初始化后,JVM⾃然就需要将这个符号引⽤转⽽指向B类具体的内存地址,这个引⽤就称为直接引⽤

2 一个用类加载机制加薪的故事

  • 故事背景:模拟⼀个OA系统,每个⽉需要定时计算⼤家的⼯资;

    package com.roy.oa;
    
    public class OADemo1 {
        public static void main(String[] args) throws InterruptedException {
            Double salary = 15000.00;
            Double money = 0.00;
            //模拟不停机状态
            while (true) {
                try {
                    money = calSalary(salary);
                    System.out.println("实际到手Money:" + money);
                }catch(Exception e) {
                    System.out.println("加载出现异常 :"+e.getMessage());
                }
                Thread.sleep(5000);
            }
        }
    
        private static Double calSalary(Double salary) {
    		SalaryCaler caler = new SalaryCaler();
    		return caler.cal(salary);
    //        return -1.00;
        }
    }
    
  • 而⽽具体计算⼯资的⽅法,根据⾯向对象的设计思想,会交由⼀个单独的SalaryCaler类来处理;

    package com.roy.oa;
    
    public class SalaryCaler {
    
        public Double cal(Double salary) {
            return salary;
        }
    }
    
  • 这时,⼀个程序员⽼王,想要给⼤家都偷偷加⼀点⼯资,于是他想到的⽅法是直接修改OA系统中计算⼯资的⽅法,给⼤家都加点⼯资:

    package com.roy.oa;
    
    public class SalaryCaler {
    
        public Double cal(Double salary) {
            return salary * 1.4;
        }
    }
    
  • ⽼王偷偷给⼤家加了⼯资,但是经理肯定是不会同意的。于是,一个程序员与资本家⽃智⽃勇涨薪的故事,拉开了序幕。

3 通过类加载器引入外部Jar包

  • 计算⼯资的⽅法都在OA系统⾥,经理直接在代码仓库就能看到。于是⽼王就要开始思考,如何让经理看不到OA系统中计算⼯资的源码;

  • 基础思路:

    • 将计算⼯资的SalaryCaler类从OA系统中抽出来,放到另一个项目中,将该项目打成jar包;
    • 然后让OA系统能够从这个jar包中读取SalaryCaler类,这样就可以绕开经理的视线了;
  • 于是,就可以基于JDK提供的URLClassLoader加载器,从jar包当中加载工资计算类:

    package com.roy.oa;
    
    import java.net.URL;
    import java.net.URLClassLoader;
    
    public class OADemo2 {
        public static void main(String[] args) throws Exception {
            Double salary = 15000.00;
            Double money = 0.00;
    
            URL jarPath = new URL("jar包地址");
            URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {jarPath});
    
            //模拟不停机状态
            while (true) {
                try {
                    money = calSalary(salary, urlClassLoader);
                    System.out.println("实际到手Money:" + money);
                }catch(Exception e) {
                    e.printStackTrace();
                    System.out.println("加载出现异常 :"+e.getMessage());
                }
                Thread.sleep(5000);
            }
        }
    
        private static Double calSalary(Double salary, ClassLoader classloader) throws Exception {
            Class<?> clazz = classloader.loadClass("com.roy.oa.SalaryCaler");
            if(null != clazz) {
                Object object = clazz.newInstance();
                // 利用反射调用该类中的cal方法
                return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary);
            }
            return -1.00;
        }
    }
    
  • 拓展思考: 在真实项⽬中,这个思路有什么⽤呢?

    • 哪些jar包适合放到外部加载?
      • 那些流程⽐较统⼀,但是具体实现规则容易经常产⽣变化的场景。例如:规则引擎、统⼀审批规则、订单状态规则…
    • 外部jar包可以放到哪些地⽅?
      • 远程Web服务器:URLClassLoader可以定义URL从远程Web服务器加载Jar包;
      • maven仓库:drools规则引擎可以从maven仓库远程加载核⼼规则⽂件;
        • 实现效果:当规则改变的时候,修改maven中的核心规则文件即可,对应的拉取了这个文件的工程就会修改规则。

4 自定义类加载器实现Class代码混淆

  • 虽然经理在OA系统⾥看不到SalaryCaler类的源码了,但是通过OA系统的源码最终还是可以找到这个jar包。那么就可以对jar包进⾏反编译,查看到jar包对应的源码了。所以,⽼王还需要考虑如何对class⽂件进⾏代码混淆,让经理⽆法反编译出源码;

  • 解决的思路有两个:

    1. 简单⼀点的,将class⽂件的后缀改⼀下,从.class转为.myclass
    2. 只是修改后缀,那么经理还可以把后缀改回来再反编译。所以稳妥⼀点的⽅法,是要改⼀改class⽂件当中的⼆进制内容;
  • JDK只能加载标准的class⽂件,所以这⼀类反常规的思路,JDK就没办法提供帮助了,此时就需要⽤⾃定义的类加载器来解决了;

    • 关于如何实现⾃定义类加载器,可以查看ClassLoader类源码中开头的注释,⾥⾯介绍了如何实现⼀个NetWorkClassLoader
  • 于是,⽼王先在OA系统的工程项目中定义了⼀个⾃定义类加载器,实现从.myclass⽂件中加载类:

    package com.roy.oa;
    
    import java.io.ByteArrayOutputStream;
    import java.io.File;
    import java.io.FileInputStream;
    import java.io.IOException;
    import java.security.SecureClassLoader;
    
    //加载文件系统中的class文件
    public class SalaryClassLoader extends SecureClassLoader {
        private String classPath;
        public SalaryClassLoader(String classPath) {
           this.classPath = classPath;
        }
    
        @Override
        protected Class<?> findClass(String fullClassName) throws ClassNotFoundException {
           //查找.myclass⽂件
           String filePath = this.classPath + fullClassName.replace(".", "/").concat(".myclass");
           int code;
           try {
              FileInputStream fis = new FileInputStream(filePath);
              fis.read();
              ByteArrayOutputStream bos = new ByteArrayOutputStream();
              try {
                 while ((code = fis.read()) != -1) {
                    bos.write(code);
                 }
              } catch (IOException e) {
                 e.printStackTrace();
              }
              byte[] data = bos.toByteArray();
              bos.close();
              return defineClass(fullClassName, data, 0, data.length);
           } catch (Exception e) {
              e.printStackTrace();
           }
           return null;
        }
    }
    
  • 然后,在OA系统中通过这个⾃定义类加载器加载计算⼯资的SalaryCaler类;

    package com.roy.oa;
    
    public class OADemo3 {
        public static void main(String[] args) throws Exception {
            Double salary = 15000.00;
            Double money = 0.00;
            SalaryClassLoader salaryClassLoader = new SalaryClassLoader("jar包地址");
    
            //模拟不停机状态
            while (true) {
                try {
                    money = calSalary(salary,salaryClassLoader);
                    System.out.println("实际到手Money:" + money);
                }catch(Exception e) {
                    System.out.println("加载出现异常 :"+e.getMessage());
                    System.exit(-1); // 删除没用的1
                }
                Thread.sleep(5000);
            }
        }
    
        private static Double calSalary(Double salary,ClassLoader classloader) throws Exception {
            Class<?> clazz = classloader.loadClass("com.roy.oa.SalaryCaler");
            if(null != clazz) {
                Object object = clazz.newInstance();
                return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary);
            }
            return -1.00;
        }
    }
    
  • 目前,老王只是修改了文件的后缀,但是还没有修改文件中的二进制。⼆进制⽂件不太好直接编辑,可以使⽤流的⽅式做⼀点修改,老王在SalaryCaler类所在的工程添加了下面这个类:

    package com.roy;
    
    import java.io.File;
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    
    public class FileTransferTest {
        public static void main(String[] args) throws Exception {
            FileInputStream fis = new FileInputStream("SalaryCaler.class文件的地址");
    
            File targetFile = new File("SalaryCaler.myclass文件存放的路径");
            if(targetFile.exists()) {
                targetFile.delete();
            }
            FileOutputStream fos = new FileOutputStream(targetFile);
    
            int code = 0;
            fos.write(1); // 写一个没用的1
            while((code = fis.read())!= -1 ) {
                fos.write(code);
            }
            fis.close();
            fos.close();
            System.out.println("文件转换完成");
        }
    }
    
  • 这样就能⽣成⼀个简单加密后的.myclass⽂件了,在class⽂件的标准内容前⾯加了⼀个没⽤的1,对应的类加载器只需要把这个1忽略掉就可以了。

  • 拓展思考

    • 如何进⼀步提升关键代码的安全性?

      • 目前这个算法太简单了,经理看看类加载器的源码就知道,只要把.myclass⽂件前⾯的1去掉,就能拿到原来的class⽂件内容,从⽽进⾏反编译。有没有什么算法,可以让经理推导不出原始的class⽂件内容呢?常⽤的加密算法就派上⽤场了。MD5、对称加密、⾮对称加密……
      • 或者是不是能够有更多奇怪的思路,⽐如通过⾃定义类加载器A,从⼀个加密class⽂件当中加载出⼀个类加载器B,再⽤后⾯这个类加载器B,加载加密过的核⼼代码;
    • 如何在真实项⽬中⽤上这种机制?

      • 真实项⽬当中不会拿class⽂件直接部署,都是拿jar包进⾏部署。所以要做的是,在⾃定义类加载器中,将从硬盘上读取class⽂件的实现⽅式,改为从jar包当中读取class⽂件。这个通过⽂件流也很容易实现:

        package com.roy.oa;
        
        import java.io.ByteArrayOutputStream;
        import java.io.InputStream;
        import java.net.URL;
        import java.net.URLConnection;
        import java.security.SecureClassLoader;
        
        /**
         * 从jar包中加载薪水计算类
         */
        public class SalaryJARLoader extends SecureClassLoader {
            private String jarFile;
        
            public SalaryJARLoader(String jarFile) {
               this.jarFile = jarFile;
            }
        
            @Override
            protected Class<?> findClass(String fullClassName) throws ClassNotFoundException {
               String classFilepath = fullClassName.replace('.', '/').concat(".class");
               System.out.println("重新加载类:"+classFilepath);
               int code;
               try {
                  // 访问jar包的url
                  URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath);
        //        InputStream is = jarURL.openStream();
                  URLConnection urlConnection = jarURL.openConnection();
                  // 不使用缓存 不然有些操作系统下会出现jar包无法更新的情况
                  urlConnection.setUseCaches(false);
                  InputStream is = urlConnection.getInputStream();
                  ByteArrayOutputStream bos = new ByteArrayOutputStream();
                  while ((code = is.read()) != -1) {
                     bos.write(code);
                  }
                  byte[] data = bos.toByteArray();
                  is.close();
                  bos.close();
                  return defineClass(fullClassName, data, 0, data.length);
               } catch (Exception e) {
                  e.printStackTrace();
                  System.out.println("加载出现异常 :"+e.getMessage());
                  throw new ClassNotFoundException(e.getMessage());
        //        return null;
               }
            }
        }
        

5 自定义类加载器实现热加载

  • ⽼王通过重重考验,终于瞒过了经理。但是这时⼜遇到⼀个头疼的情况。总公司需要时不时地核算⼯资,⽼王⾃然想要在总公司核算⼯资之前将计算⼯资的⽅式改回去,避免露馅,然后等总公司核算完成了再改回来;

  • 既然SalaryCaler类都是从jar包当中修改的,那么是不是直接修改jar包就可以了呢?很可惜,⽼王经过测试后,结果并不是那么令⼈满意。每次修改jar包后,都需要重启OA系统才能⽣效。总公司每次来核查⼯资就要重启⼀次OA系统,这样岂不是此地⽆银三百两了?

  • 其实深⼊分析就很容易找到原因:SalaryCaler类⽆法及时更新的根本原因就在于SalaryJARLoader对它加载过的类都保存了⼀个缓存。只要这个缓存存在,SalaryClassLoader就不会去jar包中加载,⽽是从缓存当中加载。⽽这个缓存是在JVM层⾯实现的,Java代码接触不到这个缓存,所以解决的思路⾃然就只能简单粗暴地连这个SalaryJARLoader也⼀起重新创建⼀个了:

    package com.roy.oa;
    
    public class OADemo5 {
        public static void main(String[] args) throws Exception {
            Double salary = 15000.00;
            Double money = 0.00;
    
            //模拟不停机状态
            while (true) {
                try {
                    money = calSalary(salary);
                    System.out.println("实际到手Money:" + money);
                }catch(Exception e) {
                    System.out.println("加载出现异常 :"+e.getMessage());
                }
                Thread.sleep(5000);
            }
        }
    
        private static Double calSalary(Double salary) throws Exception {
            SalaryJARLoader salaryClassLoader = new SalaryJARLoader("jar包地址");
            System.out.println(salaryClassLoader.getParent());
            Class<?> clazz = salaryClassLoader.loadClass("com.roy.oa.SalaryCaler");
            if(null != clazz) {
                Object object = clazz.newInstance();
                return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary);
            }
            return -1.00;
        }
    }
    
  • 通过这种⽅式,每次都是创建出⼀个新的SalaryJARLoader对象,那么该类加载器的缓存肯定是空的,它⾃然就只能每次都从jar包当中加载类了。于是,⽼王可以愉快地随时切换jar包,实现热更新了;

  • 拓展思考

    • 这个热加载机制看似很好⽤,为什么在开源项⽬中没有⻅过这种⽤法?
      • 很显然,这种热加载机制需要创建出⾮常多的ClassLoader对象,⽽一些用不着的ClassLoader对象加载过的缓存对象也会随之成为垃圾。这会让JVM中本来就不⼤的元数据区带来很⼤的压⼒,极⼤的增加GC线程的压⼒;
      • 但是在项⽬开发时,其实是有⼀些办法可以实现这种类似的热更新机制。例如IDEA中的JRebel插件,还有之前介绍过的Arthas;
    • 加载SalaryCaler的时候真的只加载⼀个类吗?
      • SalaryJARLoader加载过的类打印出来,可以发现,在加载SalaryCaler时,其实不光加载了这个类,同时还加载了DoubleObject两个类。这两个类哪⾥来的?这就是JVM实现的懒加载机制;
      • JVM为了提⾼类加载的速度,并不是在启动时直接把进程当中所有的类⼀次加载完成,⽽是在⽤到的时候才去加载,这就是懒加载。

6 打破双亲委派,实现同类多版本共存

  • 就在⽼王跟资本家们⽃得不亦乐乎的时候,另⼀个新⼿程序员⼩王突然给⽼王来了个背刺。不知道什么原因,⼩王突然在OA系统当中也提交了个SalaryCaler类。这时⽼王突然发现,这个看似没⽤的SalaryCaler类却突然导致刚刚还挺得意的热加载机制失效了。不管jar包如何更新,OA系统总是只加载⼩王提交的那个SalaryCaler类;

  • 为什么会出现这种情况呢?这就是因为JDK的双亲委派机制。⾃定的SalaryJARLoader的parent属性指向的是JDK内的AppClassLoader。⽽AppClassLoader会加载OA系统当中的所有代码,当然就包括⼩王提交的SalaryCaler类。这时,SalaryJARLoader去加载SalaryCaler类时,通过双亲委派,⾃然加载出来的就是APPClassloader中的SalayCaler了;

    在这里插入图片描述

  • 所以,要保持热加载机制不失效,那就只能对这个双亲委派机制下⼿了。下⼿的逻辑也很简单,我们只需要让这个SalaryCaler类优先从jar包中加载就可以了:

    package com.roy.oa;
    
    import java.io.ByteArrayOutputStream;
    import java.io.InputStream;
    import java.net.URL;
    import java.net.URLConnection;
    import java.security.SecureClassLoader;
    
    /**
     * 从jar包中加载薪水计算类。
     */
    public class SalaryJARLoader6 extends SecureClassLoader {
        private String jarFile;
    
        public SalaryJARLoader6(String jarFile) {
           this.jarFile = jarFile;
        }
    
        @Override
        public Class<?> loadClass(String name,boolean resolve) throws ClassNotFoundException {
           //MAC 下会不断加载 Object 类,出现栈溢出的问题
    //     if(name.startsWith("com.roy")) {
    //        return this.findClass(name);
    //     }else {
    //        return super.loadClass(name);
    //     }
    
           // 把双亲委派机制反过来,先尝试自己从class中加载,加载不到再去父加载器中加载
           Class<?> c = null;
           synchronized (getClassLoadingLock(name)) {
              c = findLoadedClass(name); // 从缓存中查找
              if(c == null){
                 c = findClass(name); // 先尝试自己从class中加载
                 if(c == null){
                    c = super.loadClass(name,resolve); //找不到再委托给父加载器
                 }
              }
           }
           return c;
        }
    
        @Override
        protected Class<?> findClass(String fullClassName) throws ClassNotFoundException {
           String classFilepath = fullClassName.replace('.', '/').concat(".class");
           System.out.println("重新加载类:"+classFilepath);
           int code;
           try {
              // 访问jar包的url
              URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath);
              URLConnection urlConnection = jarURL.openConnection();
              urlConnection.setUseCaches(false);
              InputStream is = urlConnection.getInputStream();
    //        InputStream is = jarURL.openStream();
              ByteArrayOutputStream bos = new ByteArrayOutputStream();
              while ((code = is.read()) != -1) {
                 bos.write(code);
              }
              byte[] data = bos.toByteArray();
              is.close();
              bos.close();
              return defineClass(fullClassName, data, 0, data.length);
           } catch (Exception e) {
    //        e.printStackTrace();
              //当前类加载器出现异常,就会通过双亲委派,交由父加载器去加载
    //        System.out.println("加载出现异常 :"+e.getMessage());
    //        throw new ClassNotFoundException(e.getMessage());
              return null;
           }
        }
    }
    
  • 拓展思考

    • 我们可以通过打破双亲委派绕过JDK的沙箱保护机制吗?

      • 显然不能。因为JDK内部的三个类加载器示例的实现是改不了的。只要这三个类加载器的加载改不了,那么JDK中那些核⼼的类就还是安全的;
      • 其实,这个问题也可以延伸到JDK8往后的版本当中。从JDK9开始,JDK中引⼊了模块化机制,⽽内部的类加载器实现也随之做了翻天覆地的改变;
        • 每个类加载器不再是单独负责⼀个⼯作⽬录,⽽是改为分⼯负责⼀部分的模块;
        • 但是,对于⾃定义类加载器,JDK还是保留了原有的双亲委派机制。之后在分析JDK17的类加载机制时可以看到,虽然JDK17内部的加载机制发⽣了变化,但是这些案例,⼏乎都可以平滑地转移过去;
    • 在真实项⽬中,有什么样的业务场景需要打破双亲委派呢?

      • 双亲委派机制是⾮常基础的⼀个底层体系,很多重要框架都需要进⾏定制;

      • 例如Tomcat的类加载体系如下:

      在这里插入图片描述

      • Tomcat的⼏个主要类加载器:
        • commonClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
        • catalinaClassLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可⻅;
        • sharedClassLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可⻅,但是对于Tomcat容器不可⻅;
        • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可⻅,⽐如加载war包⾥相关的类,每个war包应⽤都有⾃⼰的WebappClassLoader,实现相互隔离,⽐如不同war包应⽤引⼊了不同的spring版本,这样实现就能加载各⾃的Spring版本或其他框架版本;
        • Jsp类加载器:针对每个JSP⻚⾯创建⼀个加载器。这个加载器⽐较轻量级,所以Tomcat还实现了热加载,也就是JSP只要修改了,就创建⼀个新的加载器,从⽽实现了JSP⻚⾯的热更新。

7 使⽤类加载器能不能不⽤反射?

  • ⽼王分析了热加载器失效的原因,其实就是因为在OA应⽤的多个类加载器中,同时存在了SalaryCaler类的多个版本;

    在这里插入图片描述

  • AppClassLoader中的SalaryCaler对象,可以直接new出来,但是SalaryJARLoader中的那个SalaryCaler对象,在之前的例⼦当中,都只能通过很别扭的反射来使⽤。同样都是SalaryCaler,就不能让它也像⼀个正常的类那样使⽤吗?

  • 于是,⽼王想到了⼀个简单粗暴的⽅式,明明都是SalaryCaler对象,那是不是可以直接做类型转换呢?像这样:

    package com.roy.oa;
    
    public class OADemo7 {
        public static void main(String[] args) throws Exception {
            Double salary = 15000.00;
            Double money = 0.00;
    
            //模拟不停机状态
            while (true) {
                SalaryCaler caler = new SalaryCaler();
                System.out.println("应该到手Money:" + caler.cal(salary));
    
                SalaryJARLoader6 salaryJARLoader = new SalaryJARLoader6("jar包地址");
                Class<?> clazz = salaryJARLoader.loadClass("com.roy.oa.SalaryCaler");
                Object obj = clazz.newInstance();
    
                // 反射太麻烦,能不能进行类型强转?下面就对clazz进行强转
                SalaryCaler caler2 = (SalaryCaler)obj;
                money = caler2.cal(salary);
    
                money=(Double)clazz.getMethod("cal", Double.class).invoke(obj, salary);
                System.out.println("实际到手Money:" + money);
    
                System.out.println("============");
                Thread.sleep(5000);
            }
        }
    
        private static Double calSalary(Double salary) throws Exception {
            SalaryJARLoader6 salaryClassLoader = new SalaryJARLoader6("jar包地址");
            Class<?> clazz = salaryClassLoader.loadClass("com.roy.oa.SalaryCaler");
    //        System.out.println(clazz.getClassLoader());
    //        System.out.println(clazz.getClassLoader().getParent());
            if(null != clazz) {
                Object object = clazz.newInstance();
                return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary);
            }
            return -1.00;
        }
    }
    
  • 理想很美好,现实很⻣感。这样强⾏的类型转换,只会得到⼀个让⼈怀疑⼈⽣的异常:

    Exception in thread "main" java.lang.ClassCastException: com.roy.oa.SalaryCaler cannot becast to com.roy.oa.SalaryCaler
    
    • 即我不能转换成我;
    • 虽然类名一样,但是来自于不同的类加载器,所以他们是不同的类;
  • 有什么办法能够摆脱这个别扭的反射机制呢?

    • 这时,JDK提供的SPI扩展机制就映⼊眼帘了;
    • JDK提供了⼀种SPI扩展机制,其核⼼是通过这个神奇的API:ServiceLoader.load(SalaryCalService.class) 就可以查找到某⼀个接⼝的全部实现类;
    • 应⽤所需要的,是提供⼀个配置⽂件,这个配置⽂件需要放在${classpath}/META-INF/services这个固定的⽬录下,然后配置文件的 ⽂件名是传⼊接⼝的全类名,⽽⽂件的内容则是⼀⾏表示⼀个实现类的全类名;
      • ${classpath}表示JAVA项⽬的依赖路径,可以放在依赖的jar包当中,也可以放到当前项⽬下,所以SPI机制是⼀种⾮常好的扩展机制;
      • 很多开源框架都⼤量运⽤SPI机制来保留功能扩展点,最典型的是ShardingSphere
      • ⽽SpringBoot也是围绕SPI机制提供功能扩展,只不过SpringBoot的SPI机制是⾃⼰实现的,⽽没有⽤JDK提供的;
  • ⽽这个⼤家司空⻅惯的SPI机制,其实在它具体实现时,也是传⼊了ClassLoader的(查看**ServiceLoader.load(SalaryCalService.class)**中的load方法的源码):

    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }
    
  • 所以就可以⽤这样的⽅式,定义⼀个统⼀的接⼝,⽽将这些不同的实现类都作为接⼝的不同实现去加载。这样,虽然多定义了⼀个接⼝,但是⾄少摆脱了那些别扭的反射代码:

    在这里插入图片描述

    package com.roy.oa;
    
    import java.util.Iterator;
    import java.util.ServiceLoader;
    
    public class OADemo8 {
        public static void main(String[] args) throws Exception {
            Double salary = 15000.00;
    
            //使用 SalaryJARLoader6,就需要在 OA系统所在的工程中添加 SPI 的配置文件(见上图)
            while (true) {
                SalaryJARLoader6 salaryJARLoader = new SalaryJARLoader6("jar包地址");
                SalaryCalService salaryService = getSalaryService(salaryJARLoader);
                System.out.println("应该到手Money:" + salaryService.cal(salary));
    
                SalaryJARLoader6 salaryJARLoader2 = new SalaryJARLoader6("jar包地址");
                SalaryCalService salaryService2 = getSalaryService(salaryJARLoader2);
                System.out.println("实际到手Money:" + salaryService2.cal(salary));
    
                Thread.sleep(5000);
            }
        }
        private static SalaryCalService getSalaryService(ClassLoader classloader){
            SalaryCalService service = null;
    //        ServiceLoader.load(SalaryCalService.class,classloader);
            ClassLoader c1 = Thread.currentThread().getContextClassLoader();
            try{
                Thread.currentThread().setContextClassLoader(classloader);
                ServiceLoader<SalaryCalService> services = ServiceLoader.load(SalaryCalService.class);
                //这里只需要拿SPI加载到的第一个实现类
                Iterator<SalaryCalService> iterator = services.iterator();
                if(iterator.hasNext()){
                    service = iterator.next();
                }
            }finally {
                Thread.currentThread().setContextClassLoader(c1);
            }
            return service;
        }
    }
    
  • 拓展思考

    • 在上面的示例当中,按照SPI配置⽂件中的要求,在计算⼯资的方法所在的工程中,SalaryCalService的实现类就必须放在指定的路径下;

    • 那么可不可以把SPI配置⽂件也放在计算工资的方法所在的工程中,到时候一起打包到jar包⾥⾯?这样子编写这个实现类的程序员就可以自己定义SPI配置文件中的路径,自己决定要将这个实现类放在哪里了。那么如果想要在jar包当中⾃⼰定义SalaryCalService的实现类,要怎么办?

    • 在上面讲到,这个配置⽂件需要放在${classpath}/META-INF/services这个固定的⽬录下,那么什么是classpath?我们目前的实例代码中,都只是加载jar包中的一个类的class文件,而不是加载整个jar包;

      • 将OA系统中的SPI配置文件删掉,将计算⼯资的方法所在的工程重新打成jar包,在该jar包的${classpath}/META-INF/services下添加SPI配置文件,一样不可行。原因就是没有加载整个jar包,所以读取不到配置文件;
    • 实现方案1:使用URLClassLoader类加载器,加载整个jar包

      package com.roy.oa;
      
      import java.net.URL;
      import java.net.URLClassLoader;
      import java.util.Iterator;
      import java.util.ServiceLoader;
      
      public class OADemo9 {
          public static void main(String[] args) throws Exception {
              Double salary = 15000.00;
      
              //使用 URLClassLoader,就不需要在 OADemo 中添加 SPI 的配置文件,直接在 SalaryCaler.jar中添加 SPI 配置文件即可
              //将实现类和SPI 配置文件放在一起,更符合工程化的思想
              while (true) {
                  String jarPath1 = "jar包地址";
                  URLClassLoader urlClassLoader1 = new URLClassLoader(new URL[] {new URL(jarPath1)});
                  SalaryCalService salaryService1 = getSalaryService(urlClassLoader1);
                  System.out.println("应该到手Money:" + salaryService1.cal(salary));
      
                  String jarPath2 = "jar包地址";
                  URLClassLoader urlClassLoader2 = new URLClassLoader(new URL[] {new URL(jarPath2)});
                  SalaryCalService salaryService2 = getSalaryService(urlClassLoader2);
                  System.out.println("实际到手Money:" + salaryService2.cal(salary));
      
                  SalaryCalService salaryCalService3 = getSalaryService(null);
                  System.out.println("OA系统中计算的Money:"+salaryCalService3.cal(salary));
                  Thread.sleep(5000);
              }
          }
          private static SalaryCalService getSalaryService(ClassLoader classloader){
              ServiceLoader<SalaryCalService> services;
              if(null == classloader){
                  services = ServiceLoader.load(SalaryCalService.class);
              }else{
                  ClassLoader c1 = Thread.currentThread().getContextClassLoader();
                  Thread.currentThread().setContextClassLoader(classloader);
                  services = ServiceLoader.load(SalaryCalService.class);
                  Thread.currentThread().setContextClassLoader(c1);
      
              }
              SalaryCalService service = null;
              if(null != services){
                  //这里只需要拿SPI加载到的第一个实现类
                  Iterator<SalaryCalService> iterator = services.iterator();
                  if(iterator.hasNext()){
                      service = iterator.next();
                  }
              }
              return service;
          }
      }
      
    • 实现方案2:使用java -cp指令,后面跟上要加载的jar包,这样就可以把对应的jar包加载到工程下;

      • IDEA启动项目时,也是使用的这个指令。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

木木慕慕

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值