暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Java 类加载与双亲委派模型

阿东编程之路 2022-07-10
965


在计算机中,所有数据都是以二进制的方式存储,计算机只认识 0 和 1。Java有个响当当的口号是“Write Once, run AnyWhere(一次编译,处处运行)”,而这个“一次编译处处运行”的特性就是通过 Java 字节码实现的,我们编写的 Java 文件通过编译等操作编译成JVM能识别的 class 字节码文件,接着在需要的时候被加载进 JVM 内存中,并对数据进行校验,转换解析和初始化,最终变成 JVM 可以执行的 Java 类型,这个过程被称作 Java 的类加载。


一. Java 类加载

一个Java类的生命周期包括:加载(Loading)、验证(Verification)、准备(Perparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个阶段,其中验证、准备、解析三个阶段可以统称为连接。


加载,验证,准备,初始化的顺序是确定的,而这个顺序也只是“开始”的顺序确定(互相交叉混合进行,在一个阶段过程中触发下一个阶段),解析阶段为了支持Java的动态绑定特性有可能会在初始化后执行(动态绑定就是子类重写父类的方法,JVM在运行时决定调用哪个方法)。


类加载的时机

对于加载阶段的时机,《Java 虚拟机规范》没有进行规范而是交给虚拟机自己去“把握”实现;但对于而《Java 虚拟机规范》中规定有且只有六种情况必须对类进行初始化:

  • 使用new关键字实例化对象时、设置或读取一个类的静态字段(final 修饰的静态字段除外,final修饰的常量会在编译期放入常量池中)时和调用一个类的静态变量方法时,如果类没有进行过初始化会进行初始化;

  • 使用 java.lang.reflect 包的方法对类型进行反射调用时,如果类没有进行过初始化会进行初始化;

  • 如果类进行初始化时,发现父类没有初始化过,会先去初始化父类;

  • 启动类会被主动加载(main方法类);

  • 当使用动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后解析结果是 PEF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且方法句柄对应的类型没有进行初始化,则需要首先触发初始化。

  • 当一个接口中定义了JDK 8 的默认方法时候(default 修饰的接口方法),如果这个类的实现类进行初始化,那该接口会在其之前进行初始化。

那我们再回过头看下,加载阶段的触发条件和初始化触发条件一样吗?

可以根据上述条件进行反证下
    public class Student {


    String name;

    String gender;

    public Student() {}
    }


    public class TestLoad {


    public static void main(String[] args) {


    Class<Student> studentClass = Student.class;
    }
    }

    上述代码中的“Class<Student> studentClass = Student.class;”是不符合初始化的六个条件的,我们在类加载器 ClassLoader 的源码的 loadClass() 方法里打个断点执行:


    所以还是会有其他情况会触发加载阶段的(加载不一定初始化)。


    类加载的过程

    加载

    加载的主要目的是通过类加载器将class字节码文件加载进JVM内存,而加载阶段主要做的以下三件事情:

    • 通过类的全限定名获取此类的二进制字节流;

    • 将字节流所代表的静态存储结构转化为方法区的运行时数据结构(类元信息,比如字段,接口,方法等)

    • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区中该类数据的入口。

    验证

    验证阶段主要是为了检查class文件的二进制字节流是否符合虚拟机的规范,保证不会危害虚拟机自身的安全。

    验证阶段相比于其他阶段是非常耗时的,包括以下四个过程:

    • 文件格式验证,比如开头是否是 0XCAFEBABE 开头,检查常量池中的常量类型是否支持等;
    • 元数据验证,主要对元数据的语义进行校验,比如该类父类是否被final修饰,是否实现了抽象父类或接口的所有方法等;

    • 字节码验证,主要校验字节码内语法是否合法、符合逻辑;

    • 符号引用验证:这个阶段发生在解析阶段(符号引用转换为直接引用),校验符号引用,比如判断通过全限定名是否能够找到对应类和关键字访问权限等。

    某些验证好像在编译阶段已经进行过了?或者为什么不把这么耗时的工作放到编译阶段呢?

    因为 Java 的特性“一次编译处处运行”,我们其实可以自己编写 class 字节码文件不用走编译,如果语法出错运行中就会出问题,所以在运行阶段也是要验证的。

    准备

    准备阶段是为类的静态变量分配内存(JDK1.7 后就在堆内分配内存)并设置初始值的阶段,基本数据类型的初始值大部分都是 0,引用类型是 null;但是有个特殊情况:被 final 修饰静态变量直接设置指定的值
      // 在准备阶段a的值为0
      static int a = 1;
      // 在准备阶段b的值为1
      final static b = 1;

      解析

      解析阶段是将常量池中的符号引用转换为直接引用的过程。

      • 符号引用:其实就是一个字符串,但是通过这个字符串可以唯一标记一个类的方法、字段。

      • 直接引用:直接引用就是指向目标具体的指针或内存中的相对偏移量(方法区)。

      初始化

      初始化过程就是为类的静态变量真正赋值或执行static代码块中的语句。


      二. Java 类加载器和双亲委派模型

      在加载阶段有个步骤是“通过类的全限定名去将 class 文件加载成二进制字节流”,而实现这个步骤的就是类加载器(Class Loader)

      Java 类加载器的作用也不仅仅是加载,两个类是否相等,是需要 类加载器 + 类 两个条件共同判断的,如果两个类来自同一个 class 文件,但是加载的类加载器不同,两个类也不相同。

      Java 类加载器

      从实现角度来看,Java有两类类加载器,一类是 C++ 实现的启动类加载器(Bootstrap ClassLoader);另一类是 Java 实现的类加载器,这些类加载器都继承自抽象类 java.lang.Classloader

      启动类加载器(Bootstrap Class Loader)

      启动类加载器负责加载存放在 <JAVA_HOME>\lib 目录,或者被-Xbootclasspath 参数指定的路径存放的,而且是Java虚拟机能够按照名称识别(比如 rt.jar,tools.jar)的类库。

      扩展类加载器(Extension Class Loader)

      扩展类加载器由类sun.misc.Launcher$ExtClassLoader实现,主要负责加载<JAVA_HOME>\lib\ext 目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。

      应用程序类加载器(Application Class Loader)

      应用程序类加载器由 sun.misc.Launcher$AppClassLoader 实现,主要负责加载用户类路径(ClassPath)上所有的类库(开发者自己编写的类)。

      自定义类加载器(User Class Loader)

      用户可以根据自己的需求来自己实现一个类加载器来加载指定类,比如想要实现类的隔离。


      双亲委派模型


      Java中的类加载通常采用的是双亲委派模型。

      双亲委派模型的思想是:如果一个类加载器收到类加载的请求,他不会直接加载指定类,而是将把请求委托给自己的父加载器(这里父子关系不是类继承关系而是一个逻辑关系)去加载,只有父类加载器无法加载才会由当前的类加载器进行加载。所以所有的类都会被送到顶层的启动类加载器尝试加载。

      为什么要有双亲委派模型?直接加载不香吗?

      最主要的原因是为了防止核心API被篡改,如果每个类都自行加载,用户自己编写一个全类名和核心类一致(比如String)的类,系统中就会出现多个同样的类,那么 Java 最基础的行为就无法保证,应用程序将会变得一片混乱。

      双亲委派模型如何实现?

      主要有三个核心方法:

      • loadClass()进行类加载的方法,双亲委派模型的逻辑就在这个方法里

      • findClass()真正进行类加载的方法,将class文件加载成二进制字节流到内存

      • defineClass()通过内存二进制字节码字节流生成一个java.lang.Class对象

      来看下 ClassLoader 类的源码,还是比较清晰明了的:
        // 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异常
        // 子类加载器catch异常后,调用自己的findClass()方法进行加载
        }


        if (c == null) {
        // If still not found, then invoke findClass in order
        // to find the class.
        long t1 = System.nanoTime();
        // 根据名称或位置加载.class字节码
        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;
        }
        }

        1. 检查类是否被加载;

        2. 检查类是否被加载,如果没有被加载进行后续操作;

        3. 如果父加载器不为空就委托给父类加载器,为空代表顶层是启动类加载器或自定义类加载器没设置父类加载器并交给启动类加载器加载;

        4. 父类加载器加载失败后会抛出ClassNotFoundException异常,子类加载器catch异常后,调用自己的findClass()方法进行加载。

        类加载器通过 synchronized 同步代码块来保证并发加载类时不会重复加载,并且将锁粒度优化到最小,不同的类可以并行加载:
          // 锁对象集合:key->value, 全类名->ObjectLock
          private final ConcurrentHashMap<String, Object> parallelLockMap;


          // ClassLoader$getClassLoadingLock源码
          protected Object getClassLoadingLock(String className) {
          Object lock = this;
          if (parallelLockMap != null) {
          Object newLock = new Object();
          // 以全类名为key,如果不存在就set,并将该key的value返回
          lock = parallelLockMap.putIfAbsent(className, newLock);
          if (lock == null) {
          lock = newLock;
          }
          }
          return lock;
          }

          大概逻辑就是维护一组以类的全类名为key,锁对象为value的本地缓存,锁粒度细化到了加载的类。


          三. 破坏双亲委派模型

          如何破坏类加载机制?

          自定义类加载器破坏双亲委派模型

          自定义的类加载器其实很容易破坏双亲委派模型,只要重写 loadClass() 方法并且里面不写向父类加载器委派的逻辑即可。

          Tomcat 破坏双亲委派模型

          Tomcat 是常用的 Web 容器,一个容器内可能会部署多个应用,如果多个应用内依赖了相同的第三方类库比如 guava 且版本相同,如果按照双亲委派模型,就无法加载多个相同的类。所以 Tomcat 为了实现类的隔离,为每个应用提供了一个 WebAppClassLoader 类加载器。并且加载时直接通过 WebAppClassLoader 进行加载自己应用路径下的类,而不是委托给父类加载器,从而破坏双亲委派模型。

          Java SPI(service provider interface)破坏双亲委派模型

          正常我们调用方依赖提供方的接口(SPI),接口都是由提供方实现;而 SPI 正好相反,SPI就是提供方提供了接口和一套服务发现的逻辑,实现交给调用方去做。


          比较经典的例子就是 JDK 的 Driver,因为一个数据库可能会存在着不同的数据库驱动实现,数据库厂商不可能去修改 JDK 源码,所以就有了一套 SPI 服务发现的机制。

          获取JDBC连接的代码:
            Connection conn = DriverManager
            .getConnection("jdbc:mysql://localhost:3306/mysql", "账号", "密码");

            我们跟一下源码看看 SPI 的实现原理以及 SPI 如何破坏双亲委派模型:
              // Driver注册集合
              private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();


              // Worker method called by the public getConnection() methods.
              private static Connection getConnection(

              for(DriverInfo aDriver : registeredDrivers) {
              // 检查该类加载器是否有权限加载Driver
              if(isDriverAllowed(aDriver.driver, callerCL)) {
              try {
                              println("trying " + aDriver.driver.getClass().getName());
              // 通过url、账号、密码等信息获取链接,如果为null就遍历下一个
              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;
              }
              }
              } else {
                          println("skipping: " + aDriver.getClass().getName());
              }
              }
              }

              我们看到大概逻辑就是遍历一个 registeredDrivers 的驱动注册集合去寻找驱动,所以重点就在 Driver 何时被注册进 registeredDrivers。
                // DriverManager类内的静态方法去加载驱动
                static {
                loadInitialDrivers();
                println("JDBC DriverManager initialized");
                }

                点进 loadInitialDrivers() 方法,发现是使用 ServiceLoader 类去加载驱动:
                  // DriverManager类内的loadInitialDrivers()方法
                  private static void loadInitialDrivers() {
                  ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                  Iterator<Driver> driversIterator = loadedDrivers.iterator();
                  try{
                  // 内部实现了迭代器,在调用next方法时候去真正加载类
                  while(driversIterator.hasNext()) {
                  driversIterator.next();
                  }
                  } catch(Throwable t) {
                  // Do nothing
                  }
                  return null;
                  }

                  点击 ServiceLoader.load() 方法:
                    public static <S> ServiceLoader<S> load(Class<S> service) {
                    // 使用线程上下文加载器,可以显示指定,默认是AppClassLoader,
                    ClassLoader cl = Thread.currentThread().getContextClassLoader();
                    // 使用线程上下文加载器去发现服务并加载
                    return ServiceLoader.load(service, cl);
                    }

                    这里就是 SPI 打破双亲委派模型的关键点,在JDK的源码内使用 AppClassLoader 去加载类,因为 DriverManager 位于 rt.jar 包中,会被启动类加载器加载,而根据类加载器的传递性,内部引用也需要通过启动类加载器加载,而刚才说了 Driver 是各个厂商自己实现,根据双亲委派原则启动类加载器无法加载第三方包类的,所以 Java 团队采用了指定 AppClassLoader 类加载器加载类的方式去破坏双亲委派原则来加载各个厂商自己的实现的 Driver。

                    我们再来看看 ServiceLoader 是如何加载类的:
                      // 内部重写迭代器的hasNext方法
                      public boolean hasNext() {
                      if (acc == null) {
                      // 该方法根据路径寻找SPI配置的全类名
                      return hasNextService();
                      } else {
                      PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
                      public Boolean run() { return hasNextService(); }
                      };
                      return AccessController.doPrivileged(action, acc);
                      }
                      }


                      // SPI配置路径
                      private static final String PREFIX = "META-INF/services/";


                      private boolean hasNextService() {
                      if (nextName != null) {
                      return true;
                      }
                      if (configs == null) {
                      try {
                      // 拼成全类名
                      String fullName = PREFIX + service.getName();
                      if (loader == null)
                      configs = ClassLoader.getSystemResources(fullName);
                      else
                      configs = loader.getResources(fullName);
                      } catch (IOException x) {
                      fail(service, "Error locating configuration files", x);
                      }
                      }
                      }

                      ServiceLoader 内部 LazyIterator 类实现迭代器 Iterator,并重写 hasNext 方法,内部大概逻辑就是根据"META-INF/services/"寻找配置文件(配置文件的名需要是抽象接口的全类名),拿到配置文件中的全类名。

                      我们再看下 MySQL 的实现的 Driver ,也是按照这种规定配置:


                      接着再看下重写的 next() 方法:
                        // 内部重写迭代器的next方法
                        public S next() {
                        if (acc == null) {
                        return nextService();
                        } else {
                        PrivilegedAction<S> action = new PrivilegedAction<S>() {
                        public S run() { return nextService(); }
                        };
                        return AccessController.doPrivileged(action, acc);
                        }
                        }


                        private S nextService() {
                        if (!hasNextService())
                        throw new NoSuchElementException();
                        String cn = nextName;
                        nextName = null;
                        Class<?> c = null;
                        try {
                        // 根据全类名和设置的AppClassLoader去加载SPI实现类
                        c = Class.forName(cn, false, loader);
                        } catch (ClassNotFoundException x) {
                        fail(service,
                        "Provider " + cn + " not found");
                        }
                        if (!service.isAssignableFrom(c)) {
                        fail(service,
                        "Provider " + cn + " not a subtype");
                        }
                        try {
                        // 实例化
                        S p = service.cast(c.newInstance());
                        // 放进providers集合,后续根据全类名找SPI实现
                        providers.put(cn, p);
                        return p;
                        } catch (Throwable x) {
                        fail(service,
                        "Provider " + cn + " could not be instantiated",
                        x);
                        }
                        throw new Error(); // This cannot happen
                        }

                        大概逻辑就是通过刚才 hasNext() 拿到的SPI实现类的全类名和最开始设置的AppClassLoader 去加载实现类(Driver 内会有逻辑去注册到 DriverManager 的 registeredDrivers 集合),并进行实例化放到 providers 集合内,后续根据全类名获取 SPI 的实现。

                        简单来说 JDK 提供的 ServiceLoader 来发现和维护 SPI 实现类,内部是通过上下文线程类加载器的方式去破坏双亲委派模型的

                        SPI在很多源码有应用,比如 Spring,Dubbo 等,思想还是很值得我们学习的。

                        看了ServiceLoader的源码后,SPI实现起来也比较简单了

                        SPI抽象接口和实现类:
                          public interface SpiService {


                          public void spiMethod();
                          }


                          public class SpiServiceImpl implements SpiService {


                          @Override
                              public void spiMethod() {
                          System.out.println("快关注「阿东编程之路」!");
                          }
                          }

                          在META-INF/services/路径下以抽象接口全限定名命名配置文件,配置文件内写上实现类全限定名:
                            com.beiting.spi.impl.SpiServiceImpl

                            SPI服务发现和寻找逻辑:
                              public abstract class SpiManager {
                              // 服务注册集合
                              private static final Map<String, SpiService> spiServices = new ConcurrentHashMap();


                              private static final SpiService defaultService = new DefaultSpiService();


                              static {
                              loadSpiServices();
                              }
                              // SPI服务发现和注册逻辑
                              private static void loadSpiServices() {


                              ServiceLoader<SpiService> load = ServiceLoader.load(SpiService.class);
                              Iterator<SpiService> iterator = load.iterator();
                              while (iterator.hasNext()) {
                              SpiService next = iterator.next();
                              spiServices.put(next.getClass().getName(), next);
                              }
                              }
                              // 获取SPI实现
                              public static SpiService getSpiService(String name) {


                              return spiServices.getOrDefault(name, defaultService);
                              }
                              // 默认SPI方法实现
                              static class DefaultSpiService implements SpiService {


                              @Override
                              public void spiMethod() {
                              System.out.println("和阿东一起进步!");
                              }
                              }
                              }

                              测试:
                                public class SpiDemo {


                                public static void main(String[] args) {


                                // 自定义SPI方法实现
                                SpiManager.getSpiService("com.beiting.spi.impl.SpiServiceImpl").spiMethod();
                                // 默认实现
                                SpiManager.getSpiService("xxx").spiMethod();
                                }
                                }

                                结果:



                                四.  总结

                                本篇文章讲了Java类加载的时机和过程、重要的类加载器、类加载的双亲委派模型、破坏双亲委派模型的一些例子,还详细分析了Java的SPI如何破坏双亲委派模型以及如何自己实现一个SPI。







                                1.Java 
                                2. https://docs.oracle.com/javase/specs/index.html

                                文章转载自阿东编程之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                评论