欢迎光临散文网 会员登陆 & 注册

JAVA类加载的故事二:双亲委派机制

2020-11-05 15:03 作者:楼兰java宝藏圈  | 我要投稿

JAVA类加载的故事二:双亲委派机制

@[toc] == 我是楼兰, 你的神秘技术宝藏 ==

书接上回

 在我们上一集的故事中,我们万能的程序员在邪恶经理的压迫下,实现了java的热加载,并且通过对类文件的二进制流进行处理,也保证了从网络加载jar包的数据安全性。我们万能的程序员又花了点功夫,对这个机制的各种异常情况以及打包机制进行了一下修复完善,很快就实现出了一整套计算规则实时发布的机制,经理用着也很顺手。于是,双方的故事进入了短暂的和平期。直到有一天......

第一章、到底加载的是哪个计算类?

 在我们之前的简单Demo中,薪资计算类SalaryCaler是必须要放在ClassLoadDemo工程外的。但是我们万能的程序员在一次调试时,不小心在主工程ClassLoadDemo中也新增了一个薪资计算类,在提交代码时忘了删除,这种情况在所难免对吧,因为在开发过程中,总是需要在本地进行调试的。在这个计算类中,是按照应得工资进行正确计算的: public class SalaryCaler {
  public Double cal(Double salary) {
   return salary*0.8;
  }
 }

这样,整个代码结构成了这个样子:

在这里插入图片描述

 而这个不小心的操作,在某一次系统重启后经理发现,原来挺厉害的热加载功能失效了,每次算出来的工资都是实际的工资,也就是说**加载到的工资计算类都是ClassLoaderDemo工程中的SalaryCaler类**(大家可以自己测试验证下)。这下经理不高兴了,这不是拆我的台吗?赶紧要我们万能的程序员想办法,彻底解决这个问题。
 
 于是程序员赶紧查百度,想办法,最终找打了一个简单的方法解决了这个问题。我们的类加载器不会关ClassLoadDemo工程中的SalaryCalerl了,而是每次都会去重新加载目标目录中的类。就是在我们自定义的类加载器中重载一个父类的方法:  @Override
  public Class<?> loadClass(String name) throws ClassNotFoundException {
   if(name.startsWith("com.roy")) {
    return this.findClass(name);
   }else {
    return super.loadClass(name);
   }
  } 把这个方法写到SalaryJARLoader中,这样,我们在计算工资时就会加载到jar包中的SalaryCaler类,经理的问题就这样愉快的解决了。
 
 这是为什么呢?程序员又下了一番苦功夫,才了解到,这是通过打破JAVA的双亲委派机制,让类加载器自行加载外部的class类来实现的。但是这到底是怎么回事?别急,我们会慢慢把这故事给讲清楚。
 
 另外,现在我们自定义的这个类加载器虽然是正确的完成了工作,但是,**把com.roy这样的报名直接给写到代码里,这样确实是太low了。**接下来,我们肯定还是要想办法把com.roy这样的字眼从代码中踢出去,同时**也让我们的类加载器加载到我们想要的SalaryCaler类,而不用通过反射来进行计算**。这样太别扭了。那带着这两个问题,我们稍微深入下JDK源码,来找找有什么好的办法。

JDK类加载核心-双亲委派机制

 既然我们重写了父类的loadClass才解决的这个问题,那我们当然要先看看这个loadClass方法到底是怎么样的。我们简单跟踪下源码,跟到java.lang.ClassLoader中,我们就可以找到这个方法了。 public Class<?> loadClass(String name) throws ClassNotFoundException {
         return loadClass(name, false);
     }
 protected Class<?> loadClass(String name, boolean resolve)
         throws ClassNotFoundException
     {
         synchronized (getClassLoadingLock(name)) {
             <1>
             Class<?> c = findLoadedClass(name);
             <2>
             if (c == null) {
                 long t0 = System.nanoTime();
                 try {
                     if (parent != null) {
                         c = parent.loadClass(name, false);
                     } else {
                         c = findBootstrapClassOrNull(name);
                     }
                 } catch (ClassNotFoundException e) {
                 }
                 if (c == null) {
                     long t1 = System.nanoTime();
                     c = findClass(name);
                     sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                     sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                     sun.misc.PerfCounter.getFindClasses().increment();
                 }
             }
             <3>
             if (resolve) {
                 resolveClass(c);
             }
             return c;
         }
     }

我们简单的来看下这个源码,下面的每个点都针对笔记中的<>部分,然后这一部分最好对照下我们上一期的双亲委派的那个图来看。这里就是双亲委派机制的核心。

<1> :这个findLoadedClass最终会调到一个findLoadedClass0的本地方法,但是从字面意义是不是就能看到这是在查找已经加载过的类?虽然我们暂时无法深入C++源码了解findLoadedClass0的这个本地方法是如何实现的,但是至少我们知道了每个加载器对他加载的类是有记录的。

<2>:这一部分其实就是双亲委派的核心。当类加载判断这个类自己没有加载过时,就会到父类加载器中去看父类加载器是否加载过。一直找到BootStrapClassLoader(这个类在JAVA中是个null)。最终如果父加载器中都找不到,就会调用自己的findClass方法来定义这个类。

<3>:我们看到这里有个resolve参数,默认是false,这个是干嘛的?这就是我们下面要提到的java类加载过程。

一个类的类加载过程通常分为 **加载、连接、初始化** 三个部分,具体的行为在java虚拟机规范中都有详细的定义,这里只是大致的说明一下。

  • 加载Loading: 这个过程是Java将字节码数据从不同的数据源读取到JVM中,并映射成为JVM认可的数据结构。而如果输入的Class不符合JVM的规范,就会抛出异常。这个阶段是用户可以参与的阶段,我们自定义的类加载器,就是工作在这个过程。

  • 连接Linking:这个是核心的步骤,其实也就是我们上面代码<3>的这一部分。又可以大致分为三个小阶段:1、验证:检查JVM加载的字节信息是否符合Java虚拟机规范,否则就会报错。这一阶段是JVM的安全大门,防止黑客大神的恶意信息或者不合规信息危害JVM的正常运行。2、准备:这一阶段创建类或接口的静态变量,并给这些静态变量赋一个初始值(不是最终指定的值),这一部分的作用更大的是预分配内存。3、解析:这一步主要是将常量池中的符号引用替换为直接引用。例如我们有个类A调用了类B的方法,这些在代码层次还好只是一些对计算机没有意义的符号引用,在这一阶段就会转换成计算机所能理解的堆栈、引用等这些直接引用。

  • 初始化Initialization:这一步才是真正去执行类初始化的代码逻辑。包括执行static静态代码块,给静态变量赋值等。

这就是整个类加载最为核心的双亲委派模型。

一不小心打破了双亲委派?

这样,我们稍微深入了一下JDK的源码,弄明白了双亲委派机制。这个双亲委派机制对于保护JAVA正常运行是至关重要的对吧。要是没有这个机制,JDK的底层会被上层应用搅得天翻地覆,也会让一些不怀好意的黑客有了随意注入非法代码的机会。 而再回头看看我们的SalaryJARClassloader,好像我们通过复写父类的loadClass方法,就让com.roy这个包名跳出了双亲委派机制了。这个包下的类不再去检查父类是否已经加载过,而是直接由我们自定义的类加载器加载了。我们竟然就这样打破了JDK最为基础重要的双亲委派?有没有感觉我们一不小心撼动了整个地球? 但是有没有觉得这样怪怪的?JVM里有两个同样的SalaryCaler类?而且这对我们需要将com.roy包从代码里面移除这个问题有什么帮助呢?貌似目前还真没有。那有什么别的办法?别急,我们继续来看看万能程序员的故事。

第二章、消灭com.roy硬编码,实现同类多版本加载

故事到这里,我们程序员感觉已经可以自己控制SalaryCaler的加载过程了。于是我们的程序员就开始膨胀了。这经理暗地里克扣工资,我要曝光他。于是,我们的万能程序员想要把实际工资和到手的工资都打印出来。于是,我们就会有这样的代码: package com.roy; public class SalaryCalDemo2 { public static void main(String[] args) throws Exception { Double salary = 1999.99; SalaryCaler cal1,cal2; while(true) { cal1 = getLocalCaler(); System.out.println("实际到手Money:"+cal1.cal(salary)); cal2 = getAppCaler(); System.out.println("应得的Money:"+cal2.cal(salary)); Thread.sleep(1000); } } //加载自定义的计算类 private static SalaryCaler getAppCaler() throws Exception { String jarPath = "D:/lib/SalaryCaler.jar"; SalaryJARLoader classloader = new SalaryJARLoader(jarPath); Class<?> objClass = classloader.loadClass("com.roy.SalaryCaler"); Object obj = objClass.newInstance(); return (SalaryCaler)obj; } //加载应用中的计算类 private static SalaryCaler getLocalCaler() throws Exception { return new SalaryCaler();         //JVM默认都是使用的appClassLoader,并且会在启动时将这个类加载器放入线程向下文当中。所以return new SalaryCaler等价于下面这段代码。 // ClassLoader appClassLoader= Thread.currentThread().getContextClassLoader(); // Class<?> objClass = appClassLoader.loadClass("com.roy.SalaryCaler"); // Object obj = objClass.newInstance(); // return (SalaryCaler)obj; } } 这个代码容易理解把。万能的程序员想要通过getAppCaler()方法,返回使用自定义类加载器从jar包加载出来的克扣工资的计算类。然后通过getLocalCaler()获得从JVM默认的AppClassLoader从主工程ClassLoadDemo中加载到的展示原价的工资计算类。这两个类都是同一个类,通过我们之前打破双亲委派的机制,都加载到了JVM内存中,通过这种方式,想要把实际工资和到手的工资都打出来。

实际上这才是我们在工程项目中应该要用到的设计方式对吧。通过声明出不同的SalaryCaler实现类来控制计算逻辑,而把整个业务流程固定下来。

关于这个场景,可以联系上JAVA中经典的JDBC连接数据库的两行代码。Class.forName()后,DriverManger.getConnection就可以加载出针对不同数据库实现的Conneection。有兴趣的可以深入了解下源码,这是一个经典的面试题。

程序员就这样打着自己的如意小算盘,运行了这段代码。但是结果却遇到下面一个神奇的问题: 实际到手Money:1999.99 重新加载类:ࣺjar:file:/D:/lib/SalaryCaler.jar!/com/roy/SalaryCaler.class Exception in thread "main" java.lang.ClassCastException: com.roy.SalaryCaler cannot be cast to com.roy.SalaryCaler at com.roy.SalaryCalDemo2.getAppCaler(SalaryCalDemo2.java:22) at com.roy.SalaryCalDemo2.main(SalaryCalDemo2.java:11) 这是啥?SalaryCaler无法转成SalaryCaler?我是谁?谁是我? 程序员心中开始有点万马奔腾了。但是,半途而废又不是他的风格,没办法,只好静下心来好好分析下这段代码。

  • 首先分析下报错的原因。这里就涉及到了JVM类加载的另一个重要机制:命名空间隔离机制。所有的类加载器都会以当前类加载器以及所有的父加载器为要素,形成一个命名空间。不同命名空间的类是相互不可见的,也就当然是不能相互转换的。所以,在我们打破了双亲委派机制后,尽管我们的JVM内存中确实有两个com.roy.SalaryCaler薪资计算类,但是他们的命名空间是不一样的,所以也就不能相互转换。

  • 然后我们来整理下现在遇到的问题:目前我们遇到的问题根源在于目前我们写在代码里的SalaryCaler类实际上都会被映射成AppClassLoader类加载器加载到的计算类。而AppClassLoader类中的SalaryCaler只可能有一个实现类,所以我们没办法将其他类加载器加载到的SalaryCaler转换成AppClassLoader类加载器中的SalaryCaler。并且,在同一个AppClassLoader类加载的命名空间中,也是不会允许同时有两个SalaryCaler类存在的。那我们要实现多版本,怎么办呢?那就只有一种迂回的方式,用接口实现多态

  • 具体方案是这样: 在主工程的ClassLoadDemo中定义一个SalaryService的计算类服务接口。然后,将两种不同的计算薪水的服务打成两个jar包,放到不同的目录。然后给每个目录定义一个SalaryJARLoader。这样两个不同版本的实现类就可以保存在两个不同的SalaryJARLoader类加载器的命名空间里。我们再想办法在ClassLoadDemo主工程的main方法中(都是由AppClassLoader类加载器加载),去分别获取两个SalaryJARLoader里的实现类,这样就迂回的实现了多个版本同时存在了。

这个场景是不是有点类似于Tomcat?Tomcat可以在webapps目录下部署多个应用。而多个应用之间肯定存在很多类名相同,但是版本不同的类,例如不同版本的Spring类库。那在Tomcat的JVM内存中,是不是也要保存同一个类的多个版本?只有这样才能保证每个应用的访问是隔离的。

整体方案大致是这样的:

在这里插入图片描述

 那具体怎么实现呢?我们要按照以下的步骤逐一开始改造。

1- 在ClassLoadDemo中增加一个SalaryCalService接口:

package com.roy.spi; public interface SalaryCalService { public Double cal(Double money); }

2- 在SalaryCaler下先增加一个按原价进行计算的实现类:

package com.roy.spi; public class SalaryCalServiceImpl implements SalaryCalService{ @Override public Double cal(Double money) { System.out.println("Original Service"); return money; } }

注:为了让代码能够编译通过,我们需要把接口类也拷贝到SalaryCaler工程下,但是我们后面打包不需要用到。也就是maven的provided模式。

3- 然后我们导出SalaryCaler.jar包。 注意,我们导出时,只要导出SalaryCalServiceImpl实现类,不要导出接口类。我们将这个jar包导出到D:\lib1目录。

在这里插入图片描述

4- 同样的方式,我们在SalaryCaler中再导出一个包含了按八折方式实现的SalaryCalServiceImpl实现类的SalaryClaer.jar,我们导出到D:\lib2目录。

这样我们的两个jar包都有了。就开始玩类加载了。

5- 然后,我们需要对SalaryJARLoader的loadClass方法进行修改,让他优先自己加载类,加载不到的(jar包中没有的),再交由父加载器去用双亲委派机制进行加载。

package com.roy; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.net.URL; import java.security.SecureClassLoader; /**  * 从jar包中加载薪水计算类。  * @author 楼兰  *  */ public class SalaryJARLoader extends SecureClassLoader { private String jarPath; public SalaryJARLoader(String jarPath) { this.jarPath = jarPath; } //打破双亲委派机制 @Override public Class<?> loadClass(String name) throws ClassNotFoundException { // if(name.startsWith("com.roy")) { // return this.findClass(name); // }else { // return super.loadClass(name); // } // 把双亲委派机制反过来,先到子类加载器中加载,加载不到再去父类加载器中加载。 synchronized (getClassLoadingLock(name)) { Class<?> c; try { // 先由父加载器去加载 c = this.findClass(name); } catch (ClassNotFoundException e) { // 父加载器加载不到的jar包中的类,由加载器自己加载。 c = this.getParent().loadClass(name); } return c; } }     //findClass方法不动。 }

6- 然后再在ClassLoadDemo中看看我们按自己的反双亲委派的机制进行的类加载器效果怎么样。在ClassLoadDemo中添加一个测试方法:

public class SalaryCalDemo3 { public static void main(String[] args) throws Exception { Double salary = 2000.00; SalaryCalService originalService,discountedService; String originalJarPath = "D:/lib1/SalaryCaler.jar"; String discountedJarPath = "D:/lib2/SalaryCaler.jar"; while(true) { originalService =  getOriginalService(originalJarPath); System.out.println("应得的Money:"+originalService.cal(salary)); discountedService = getOriginalService(discountedJarPath);; System.out.println("实际到手Money:"+discountedService.cal(salary)); System.out.println("===================="); Thread.sleep(1000); }     }     private static SalaryCalService getOriginalService(String jarPath) throws Exception {     //这里一定要new一个classLoader来,不可以重复使用。     //因为同一个classloader不可以多次去重新加载service实现类,会报错的。 SalaryJARLoader myclassloader = new SalaryJARLoader(jarPath); return (SalaryCalService)myclassloader.loadClass("com.roy.spi.SalaryCalServiceImpl").newInstance(); } }

7- 运行的结果,可以同时打印出应得的money和实际到手的money。

在这里插入图片描述

阶段总结:通过这一阶段的折腾,我们的程序员可以在OA系统中(由主工程ClassLoadDemo来模拟)获取到两个不同版本的薪水计算实现类,并且保存了之前热加载的功能。也把我们之前在SalaryJARLoader中的com.roy硬编码问题给解决掉了。这样,我们以让热加载机制加载多一份垃圾对象的方式,实现了同类的多个版本加载。

问题看似挺完美。但是还是会有新的问题。我们之前在SalaryJARLoader中写死了com.roy硬编码方式,但是这么一折腾,虽然SalaryJARLoader中的com.roy没了,但是在测试方法中,加载服务时,又多出来了com.roy.spi.SalaryCalServiceImpl这样的硬编码。这样的硬编码方式会使得我们在每次使用这个服务时都需要记住这么长一串服务全路径。是不是依然非常不爽?那接下来,我们再继续消灭这个长串的服务名

第三章、消灭服务名硬编码,实现SPI服务发现

要消灭这种长串的硬编码,一般有两种方式: 一是通过某些特征来寻找服务名,而不用硬编码指定。常用的特征有使用注解、父类或者泛型等特征。 但是在我们这个场景上却用不上。为什呢?我们要注意,我们需要的是在类加载的过程中去寻找到服务类。而在类加载的过程中,其他的类加载是不完全的,所以,注解、泛型这些特征都是不靠谱的。而父类似乎靠谱点,但是我们这个场景中的父类和实现类是跨类加载器的命名空间的,这就让寻找父类变得非常困难。所以这种方式我们用不上。

当然,这个结论也是我们的程序员百般测试下得出的结论。大家也可以自己按这个思路去尝试一下。

那第二种方式就是把这种硬编码写到一个配置文件里去。 因为配置文件是可以随时修改不用编译的,放到配置文件里,就可以在JAVA运行时,更新配置文件,而不用动java代码,重启java进程。这样也算是迂回的保住了我们的热加载机制。 那我们选定了第二种方法,接下来就是找实现的方式了。 就在程序员一筹莫展之时,突然想到,我们在使用JDBC获取数据库连接的时候是不是也有类似的场景?我们在获取JDBC连接时,会使用DriverManager.getConnection方法来获取针对不同数据库的连接实现类,然后通过这个实现类来完成与数据库的交互。这跟我们要获得SalaryService的不同实现,不是很相似吗? 于是,万能的程序员就开始深入DriverManager的源码,寻找答案。 终于,程序员最终在java.sql.DriverManager源代码的第586行找到了一行神奇的代码,最终带他走出了困局。 ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

SPI机制介绍:

既然这行代码这么神奇,首先要解释下这行代码是干什么的。这行代码就是JDK提供的**SPI机制**。 这个**SPI机制**可以对一个接口(Driver.class),加载到他的所有实现类。加载的方式需要在项目资源目录下加载一个META-INF/services/{接口全类名}的文件(这个文件就在各个数据库厂商提供的驱动包里),然后在文件中,一行指定一个实现类,可以指定多个。这样就可以通过ServiceLoader的load方法加载出这个接口的所有实现类。 SPI机制可以实现多类型的加载,另外,他还可以很方便的实现跨命名空间的类加载。在SerivceLoader中,还有一个load的重载方法,可以传入一个classLoader,这样我们就可以轻松的读取其他命名空间下的类信息了。然后,在SerivceLoader的load实现方法中,还可以看到,他使用了一个类加载器上下文来获取所需要的类加载器。 public static <S> ServiceLoader<S> load(Class<S> service) {         ClassLoader cl = Thread.currentThread().getContextClassLoader();         return ServiceLoader.load(service, cl);     } 而这个类加载器上下文,会在JVM启动时把AppClassLoader给设置进去。具体可以去查查AppClassLoader的源码。 SPI这种跨类加载器的用法就非常适合我们这个场景了,在我们这个场景下,需要切换不同的SalaryJARClassloader实例来获取不同的实现类版本,就可以用SPI机制来帮忙。

动手改造

了解这个机制后,就开始对我们的工程进行进一步改造了。 首先,服务接口类,我们已经有了,就用SalaryCalService。 其次,服务实现类的类名,这个我们也已经确定下来了,就是我们需要消灭掉的com.roy.spi.SalaryCalServiceImpl这一串。 然后,就可以按SPI机制来写配置文件了。 在ClassLoader工程下新增一个名为resources的源文件夹source folder,然后在下面新增META-INF/services/com.roy.spi.SalaryCalService文件。然后在文件里,只要写一行内容: com.roy.spi.SalaryCalServiceImpl 这样,SPI的环境配置就配好了。整体代码像这样:

在这里插入图片描述

 然后我们就可以开始对测试方法进行改造了。

 package com.roy;
 
 import java.util.Iterator;
 import java.util.ServiceLoader;
 
 import com.roy.spi.SalaryCalService;
 
 /**
  * 使用SPI机制实现多版本加载
  *
  * @author 楼兰
  */
 public class SalaryCalDemo4 {
 
  public static void main(String[] args) throws Exception {
   Double salary = 2000.00;
 
   SalaryCalService originalService, discountedService;
   String originalJarPath = "D:/lib1/SalaryCaler.jar";
   String discountedJarPath = "D:/lib2/SalaryCaler.jar";
 
   while (true) {
    originalService = getOriginalService(originalJarPath);
    System.out.println("应得的Money:" + originalService.cal(salary));
    discountedService = getOriginalService(discountedJarPath);
    ;
    System.out.println("实际到手Money:" + discountedService.cal(salary));
 
    System.out.println("====================");
    Thread.sleep(1000);
 
   }
  }
 
  private static SalaryCalService getOriginalService(String jarPath) throws Exception {
   // 这里一定要new一个classLoader来,不可以重复使用。
   // 因为同一个classloader不可以多次去重新加载service实现类,会报错的。
   SalaryJARLoader myclassloader = new SalaryJARLoader(jarPath);
   Iterator<SalaryCalService> iter = ServiceLoader.load(SalaryCalService.class,myclassloader).iterator();
   if (iter.hasNext()) {
    // 只要一个子类
    return iter.next();
   } else {
    throw new ClassNotFoundException("缺少SPI的实现类");
   }
   //上面是比较简单的用法,常用的是下面这种方法,减少上下文的切换。
 //  ClassLoader classloader = Thread.currentThread().getContextClassLoader();
 //  try {
 //   Thread.currentThread().setContextClassLoader(classloader);
 //   Iterator<SalaryCalService> iter = ServiceLoader.load(SalaryCalService.class).iterator();
 //   if(iter.hasNext()) {
 //    //只要一个子类
 //    return iter.next();
 //   }else {
 //    throw new ClassNotFoundException("缺少SPI的实现类");
 //   }
 //  }finally {
 //   Thread.currentThread().setContextClassLoader(classloader);
 //  }
  }
 } 代码测试的结果还是能正常打印出实际工资和克扣后的工资,就不再演示了。

故事完结

 我们这一期的故事暂告一段落,我们的程序员简单的进入了一下JDK源码,就给自己引出了一大堆的麻烦。例如我们这两期的这个热加载机制,其实可以想象,在后台创建了大量的垃圾对象。可以想象,当系统稍微变复杂一点,需要占用更多的资源,那这些垃圾对象就会造成非常大的影响。那影响到底会怎么样?要怎么去评估?怎么去优化?有没有感兴趣的朋友?可以在下面留言,看能不能有下一期的故事。


JAVA类加载的故事二:双亲委派机制的评论 (共 条)

分享到微博请遵守国家法律