[JVM]-[深入理解Java虚拟机学习笔记]-第七章 虚拟机类加载机制

概述

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

类加载的时机

一个类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期将会经历加载 ( L o a d i n g Loading Loading),验证 ( V e r i f i c a t i o n Verification Verification),准备 ( P r e p a r a t i o n Preparation Preparation),解析 ( R e s o l u t i o n Resolution Resolution),初始化 ( I n i t i a l i z a t i o n Initialization Initialization),使用 ( U s i n g Using Using),和卸载 ( U n l o a d i n g Unloading Unloading) 七个阶段
其中验证,准备,解析三个部分统称为连接 ( L i n k i n g Linking Linking)
类的生命周期
加载,验证,准备,初始化,卸载 这五个阶段的顺序是确定的,必须按照这种顺序按部就班地开始

必须初始化的情况

虚拟机规范中严格规定了有且只有下面这六种情况必须立即对类进行初始化,那么自然,加载,验证,准备会在其前面开始进行:

  1. 遇到 new,getstatic,putstatic 或 invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化阶段,能够生成这四条指令的典型场景有:new 关键字实例化对象读取或设置一个类的静态字段 (除了被 final 修饰,已在编译期把结果放入常量池的静态字段,这些已经算是全局的不变量,与类没有多大关系了);调用一个类的静态方法的时候
  2. 对类进行反射调用的时候,如果类没有进行过初始化则需先初始化
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. JVM 启动的时候用户需要指定一个要执行的主类 (包含 main() 方法的那个类),虚拟机会先初始化这个主类
  5. 当一个接口中定义了 JDK 8 引入的默认方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化

类需要被初始化自然是因为需要使用到这个类了,所以以上这些行为都是需要去使用到类的行为
以上的行为称为对一个类进行主动引用。除此之外,所有引用类型的方式,尽管对类引用了,但都不会触发类的初始化,称为被动引用,例如:通过子类引用父类的静态字段,引用了子类,但不会导致子类初始化;通过数组定义来引用类,不会触发此类的初始化,如 User[] users = new User[10] 并不会触发 User 类的初始化;常量 (static final 修饰) 在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

类加载的过程

类加载的全过程,包括加载,验证,准备,解析,初始化

加载

在加载阶段,虚拟机需要完成三件事情:

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

验证

验证阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
由于 Class文件并不一定只能由 Java源码编译而来,它可以使用包括直接用键盘 0 和 1 在二进制编辑器上敲出 Class文件在内的途径产生。JVM 如果不检查输入的字节流而对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击,甚至崩溃,所以验证字节码是 JVM 保护自身的一项必要措施,这个阶段是否严谨直接决定了 JVM 是否能承受恶意代码的攻击
从整体上看,验证阶段大致上会完成四个阶段的检验动作:文件格式验证,元数据验证,字节码验证和符号引用验证

准备

准备阶段是正式为 类中定义的变量 (即静态变量,被 static 修饰的变量) 分配内存并设置类变量初始值的阶段。在JDK 7及之前,HotSpot 使用永久代来实现方法区,所以类变量存放在方法区中;JDK 8及之后,类变量则会随着 Class 对象一起存放在 Java 堆中,所以 “类变量在方法区” 就完全是一种逻辑上的表述了
这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次,这里所说的 “初始值” “通常情况下” 是数据类型的零值,例如一个类变量定义为 private static int v = 123; 那么变量 v 在准备阶段过后的初始值为 0 而不是 123,因为这时尚未开始地执行任何 Java 方法,而把 v 赋值为 123 的 putstatic 指令是程序被编译以后,存放于类构造器 <clinit>() 方法之中,所以把 v 赋值为 123 的动作要到初始化阶段才会被执行
上面提到在 “通常情况下” 初始值是零值,如果类字段的字段属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为 ConstantValue 属性所指定的初始值,即如果类变量是 static final 修饰,那就会被赋为指定的值,如 private static final int v = 123; v 在准备阶段就会被设为 123

解析

解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程

初始化

初始化阶段是类加载过程的最后一个步骤,上面讲到的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与之外,其余动作都完全由 Java虚拟机来主导控制。直到初始化阶段,JVM 才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序
准备阶段中变量已经被赋值过零值,而在初始化阶段,则会根据程序员通过程序编码制定的计划去初始化类变量和其它资源。更直接的说法是:初始化阶段就是执行类构造器 <clinit>() 方法的过程

  1. <clinit>() 方法是由编译器 (javac.exe) 自动收集类中的 所有类变量的赋值动作静态代码块中的语句 合并产生的,编译器收集的顺序是由语句在源文件中的出现的顺序决定的,静态代码块中只能访问到定义在静态代码块之前的变量,定义在它之后的变量,在前面的静态代码块可以赋值,但是不能访问 (即只能写不能读,因为在静态代码块之后可能还有其他赋值语句,那么在静态代码块执行的时候读到的可能不是那个变量最终的值,所以不允许读)
  2. <clinit>() 方法与类的构造函数 ( <init>() 方法) 不同,它不需要显式地调用父类构造器,JVM 会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。因此,在 JVM 中第一个被执行的 <clinit>() 方法的类肯定是 java.lang.Object
  3. <clinit>() 方法对于类或接口来说并不是必需的,如果一个类中既没有静态代码块,也没有对变量的赋值语句,那么编译器可以不为这个类生成 <clinit>() 方法
  4. 接口中不能使用静态代码块,但仍然有变量初始化的赋值操作,所以也会生成 <clinit>() 方法。但与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口中的 <clinit>() 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化
    此外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法,子类初始化时才会要求父类的初始化必须发生在子类初始化之前
  5. JVM 必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步,如果多线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>() 方法,其它线程都需要被阻塞等待,直到活动线程执行完毕 <clinit>() 方法。当然活动线程执行完毕 <clinit>() 方法之后,其它等待的线程被唤醒后不会继续执行 <clinit>() 方法,同一个类加载器下,一个类型只会被初始化一次

类加载器

类加载器,指的就是实现 类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流” 这个动作的代码

类与类加载器

对于任意一个类,都必须由加载它的类加载器这个类本身 (即全限定类名) 一起共同确立其在Java虚拟机中的唯一性。即,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于 同一个 Class 文件,被 同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

双亲委派模型

站在Java虚拟机的角度来看,只存在两种不同的类加载器:
一种是启动类加载器 ( B o o t s t r a p   C l a s s   L o a d e r Bootstrap\ Class\ Loader Bootstrap Class Loader),又称 引导类加载器,这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分,这个类加载器的实例无法被用户获取到 (如果一个类加载器的 getParent() 方法返回 null,说明其父类加载器为引导类加载器);

另外一种就是其它所有的类加载器,这些类加载器都由 Java 语言实现,都存在于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
首先来了解以下 3 个系统提供的类加载器:

  • 启动类加载器:这个类加载器负责加载存放在 <JAVA_HOME>\lib 目录 (即加载 Java 的核心类库,包括包名为 java,javax,sun 开头的类),或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的类库加载到虚拟机的内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用 null 代替即可,即以 null 值来代表引导类加载器
  • 扩展类加载器 ( E x t e n s i o n   C l a s s   L o a d e r Extension\ Class\ Loader Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以 Java 代码的形式实现的。负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。这其实是一种Java系统类库的扩展机制,JDK开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能。由于扩展类加载器是Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件
  • 应用程序类加载器 ( A p p l i c a t i o n   C l a s s   L o a d e r Application\ Class\ Loader Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。它是 ClassLoader 类中的 getSystemClassLoader() 方法的返回值,所以也称它为 “系统类加载器”。它负责加载用户类路径 ( C l a s s P a t h ClassPath ClassPath,可以通过项目中任何一个类调用xxx.class.getResource("/").toString()获取 ClassPath) 上所有的类库。开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

JDK 9之前的Java应用都是由这三种类加载器互相配合来完成加载的,用户还可以加入自定义的类加载器来进行拓展,如增加除了磁盘位置之外的 Class 文件来源,或者通过类加载器实现类的隔离,重载等功能,这些 类加载器之间的协作关系 “通常” 如下所示 (之所以“通常”,是因为虽然双亲委派模型在 JDK 1.2 被引入后广泛应用,但它并不是一个具有强制性约束力的模型,而是 Java 设计者们推荐给开发者的一种类加载器实现的最佳实践):
双亲委派模型

这种类加载器之间的层次关系就是类加载器的 双亲委派模型 ( P a r e n t s   D e l e g a t i o n   M o d e l Parents\ Delegation\ Model Parents Delegation Model)

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。这里说的父子关系一般不是以继承的关系实现的,而是使用 组合 关系来复用父加载器的代码

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

在这种模型下,一个显而易见的好处就是 Java 中的 类随着它的类加载器一起具备了一种带有优先级的层次关系,例如 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都能够保证是同一个类
其它好处还有

  1. 避免了类的重复加载,当父加载器已经加载过某个类时,子加载器就不会再重新加载这个类
  2. 保证了安全性。<JAVA_HOME>\lib 目录下的类只会被启动类加载器加载,试想一下如果有人恶意地定义了与 <JAVA_HOME>\lib 目录中同名的类,如 java.lang.Integer 等,如果没有双亲委派模型,那么这样的类就会被成功加载,甚至被使用,那么核心的基础的 Java API 就被覆盖,篡改了

实现双亲委派模型的代码全部集中在 java.lang.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) {
    
    
                long t1 = System.nanoTime();
                //父类加载器无法加载时,再调用自身的findClass()方法来进行加载
                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;
    }
}

方法的逻辑为:先检查请求加载的类型是否已经被加载过,若没有就调用父类加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载

破坏双亲委派模型

从上面的内容可以看到,双亲委派模型的实现是在 ClassLoader 的 loadClass() 方法中,所以要破坏双亲委派模型,只需自己 定义一个类加载器重写 loadClass() 方法 即可

历史上的三次破坏双亲委派模型

  1. 第一次 其实发生在双亲委派模型出现之前。由于双亲委派模型在 JDK 1.2 才面世,而类加载器的概念以及抽象类 java.lang.ClassLoader 在 Java 的第一个版本中已经存在,也就是说已经有人自定义类加载器,重写 loadClass() 方法了。为了兼容已有代码,不能去避免 loadClass() 方法被子类覆盖的可能性,只能添加了一个新的 findClass() 方法,引导用户编写自己的类加载逻辑时尽可能去重写这个方法,而不是 loadClass() 方法。从前面分析 loadClass() 方法的时候可以看到,如果父类加载类失败,子类自己就会调用自己的 findClass() 方法来完成加载,这样既不影响用户按照自己的逻辑去加载类,又保证了新写出来的类加载器是符合双亲委派模型的
  2. 第二次 是由这个模型本身的缺陷导致的。双亲委派模型做到了,越基础的类由越上层的加载器进行加载,如果有基础类型需要调用回用户的代码 (ClassPath下) ,那启动类加载器就无法加载了。例如 JDBC 是 Java 自己定义的一组规范,肯定是 Java 中很基础的类型,但它需要调用由其它厂商实现并部署在应用程序 ClassPath 下的 SPI (Service Provider Interface) 代码,即MySQL,Oracle 等公司根据 JDBC 自己实现的驱动类,那么加载 JDBC 的类加载器肯定是不认识这些代码的
    为了解决这个问题,Java 引入了线程上下文类加载器,与 JDBC 类似的服务使用这个线程上下文类加载器去加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,所以违背了双亲委派模型
  3. 第三次 是由于追求代码热替换,模块热部署等热部署技术而导致的。对于一些生产系统来说,实现热部署,更新时不用重启,是非常重要的。相关的实现有 IBM 的 OSGi,它实现模块化热部署的关键就是它自定义的类加载器机制的实现,其中很多的类查找操作都是在平级的类加载器中进行的,也就破坏了双亲委派模型的规则了

Tomcat的破坏双亲委派模型

相关链接

猜你喜欢

转载自blog.csdn.net/Pacifica_/article/details/123647893