深入理解Java虚拟机——第七章——虚拟机类加载机制

概述

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

类型的加载、连接和初始化过程都是在程序运行期间完成的。

类加载的时机

类从被加载到虚拟机内存中开始到卸载出虚拟机内存为止,生命周期包括:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析3个部分统称连接。

加载、验证、准备、初始化、卸载这5个阶段的开始顺序是确定的,但开始后就不一定按顺序进行,通常都是交叉混合进行。而解析阶段顺序不一定:在某些情况下可以在初始化之后开始,这是为了支持Java的运行时绑定(也称动态绑定)。

虚拟机规范对类加载的时间并没有强制约束,但是5种情况下必须对类进行初始化(加载、验证、准备自然要在此之前开始):

  1. 遇到new、getstatic、putstatic、invokestatic这4条字节码指令,如果类没有进行过初始化则需要先触发其初始化。指令对应的场景:实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)、调用一个类的静态方法。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候
  3. 当初始化一个类的时候,如果发现父类没有进行过初始化,则需要先触发父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的类),虚拟机会先初始化这个主类
  5. JDK1.7的动态语言支持时,如果一个.java.lang.invoke.MethodHandle实例最后解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发使其初始化。

这5种场景的行为称为对一个类的主动引用,除此以外,所有引用类的方式都不会触发初始化,称为被动引用

被动引用例如:常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类。

public class ConstClass {
    static {
        System.out.println("ConstClass init");
    }
    public static int String Hello = "hello";
}

public class Main {
    // 输出结果没有ConstClass init。常量被存储到了Main类的常量池中,对Hello的引用是对自身常量池的引用
    System.out.println(ConstClass.Hello);
}

接口与类的初始化过程是一致的,只有主动引用的第三点不同,接口在初始化不会要求父类接口初始化,而是真正使用到才会初始化。

类加载的过程

类加载过程也就是加载、验证、准备、解析、初始化这个5个阶段所执行的具体动作

加载

加载阶段,虚拟机需要完成3件事:

  1. 通过类的全限定名获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。(没有特别规定Class对象存放在哪,HotSpot将其存在了方法区而不是Java堆)

虚拟机规范对这3点要求不具体,比如获取类的二进制字节流可以不从Class文件获取:

  • 从ZIP包读取,最后发展出JAR、EAR、WAR格式
  • 从网络中获取,例如Applet
  • 运行时计算生成,如动态代理
  • 其它文件生成,如JSP文件生成对应的Class类
  • 从数据库中读取

非数组类的加载可控性最强,可以由开发人员自定义的类加载器来控制字节流的获取方式,不用系统提供的类加载器。而数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。

加载阶段和连接阶段是交叉进行,加载阶段还未完成连接阶段就可以开始。

验证

确保Class文件的字节流包含的信息符合当前虚拟机的要求。

验证主要有4个阶段:

  1. 文件格式验证:验证字节流是否符合Class文件的规范。验证的是基于二进制的字节流,只有此验证阶段通过,字节流才会进入方法区进行存储。所以后面3个验证阶段都是基于方法区的存储结构进行的。
  2. 元数据验证:对类的元数据进行语义校验,保证不存在不符合Java语言规范的元数据信息。例如类是否实现了接口中要求实现的所有方法。
  3. 字节码验证:对数据流和控制流分析,确保程序语义是合法的、符合逻辑的。例如确保类型转换是有效的,不会把一个子类强转成不是它的父类。JDK1.7后只用类型检查来完成数据流分析。类型检查比类型推导节省时间(具体含义未了解)。
  4. 符号引用验证:发生在虚拟机将符号引用转换成直接引用的时候,也就是在解析阶段发生。可以看做是对类自身以外的信息进行匹配校验。例如通过全限定名是否能找到相应的类,类、字段、方法的访问性(public等)是否能被当前类访问。

准备

为类变量分配内存并设置初始值的阶段,这些变量所需的内存都在方法区分配。有两个注意的地方

  • 进行内存分配的是类变量(static修饰)而不是实例变量,实例变量是在对象实例化时跟对象一起被分配到Java堆中
  • 通常情况下初始值设置是0而不是源程序所设置的值。例如static int a = 123,给a设置的初始值是0而不是123。因为这时候还未执行任何Java方法赋值动作在初始化的时候进行。初始值直接设置成所需值则是被final修饰的常量情况。

解析

将常量池内的符号引用替换为直接引用的过程

  • 符号引用:以一组符号来描述所引用的目标。目标可以不被加载到内存中,不同的虚拟机内存布局不同,但能接受的符号引用是一致的,因为符号引用的字面量意思明确定义在虚拟机规范的Class文件中。
  • 直接引用:可以是直接指向目标的指针、相对偏移量或者间接定位到目标的句柄。目标一定在内存中存在。

对同一个符号引用解析多次是很常见的。(即通过符号引用可以找到相关的信息,比如方法的符号引用可以找到方法的相关信息,找到后就被替换成直接引用,下次虚拟机通过直接引用就可以直接找到方法在内存中的位置,不用再从符号引用搜索)

初始化

初始化阶段根据程序员的主观意愿来初始化类变量和其它资源。或者说初始化阶段是执行<clinit>()方法的过程

  • <clinit>()方法由编译器自动收集类中所有的类变量的赋值动作和静态代码块语句合并产生的。收集顺序由源文件的出现顺序决定。静态代码块只能访问到之前定义的类变量,之后定义的类变量不能访问但能赋值。如下

 

    // 正确,但Main.i打印结果是10,所以赋值其实是没作用的。
    static {
        i = 1000;
        // System.out.println(i); // 如果把注释解掉,会出现非法前向引用的错误
    }
    static int i = 10;

    // 正确
    static int i = 10;
    static {
        i = 1000;
    }
    
    // 错误,静态代码块不能访问非静态变量
    int i = 10;
    static {
        i = 1000;
    }
  • <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,不用显示的调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此虚拟机中第一个执行<clinit>()方法的类是java.lang.Object。
  • 由于父类的<clinit>()方法方法先执行,所以父类的静态代码块语句要优先于子类的赋值操作。如下,最后P.B的值是2

 

class P {
    static int A = 1;
    static {
        A = 2;
    }
}
class P extends P {
    static int B = A;
}
  •  <clinit>()方法对于类或接口来说不是必须的。如果一个类中没有静态代码块和对类变量的赋值操作,那么编译器就不会为这个类生成<clinit>()方法。
  • 接口不能使用静态代码块。但可以有类变量的初始化赋值操作,因此可以生成<clinit>()方法。与类不同的是,执行接口的<clinit>()方法不需要先执行父类的<clinit>()方法,只有当父类接口定义的变量被使用时父类接口才会初始化实现接口的类也是在初始化时不会执行接口的<clinit>()方法
  • 虚拟机保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,即一个类只会初始化一次。如果多线程同时初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其它线程都需要阻塞等待,直到活动线程的<clinit>()方法执行完毕。

类加载器

 “通过一个类的全限定名来获取描述此类的二进制流”这个动作在Java虚拟机外部实现,实现这个动作的代码块称为“类加载器”。

类与类加载器

 对于一个类,都需要由加载它的类加载器和这个类本身一同确立起在Java虚拟机中的唯一性。如果一个类在不同的类加载器中加载,那么加载出的类必不相等。相等包括Class对象的equals()、isAssignableFrom()、isInstance()方法的返回结果。

双亲委派模型

从Java虚拟机角度来讲,只存在两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器大部分用C++实现(如HotSopt),是虚拟机自身的一部分。
  • 所有其他类加载器:都由Java实现独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。

从开发人员来看,可以更细分,绝大部分会使用到3种系统提供的类加载器:

  • 启动类加载器:负责加载<JAVA_HOME>/lib目录的类库,无法被Java程序直接引用。如果想在自定义类加载器把加载请求委派给启动类加载器,则直接用null替代即可。
  • 扩展类加载器:负责加载<JAVA_HOME>/lib/ext目录的类库,开发者可以直接使用。
  • 应用程序类加载器:负责加载用户路径上的类库,开发者可以直接使用。这个类加载器是ClassLoad中的getSystemClassLoader()方法的返回值,所以一般也称为系统类加载器。如果用户没有自定义自己的类加载器,这个就是默认的类加载器。

类加载器关系一般为:自定义类加载器->应用程序类加载器->扩展类加载器->启动类加载器。这种层次模型成为类加载器的双亲委派模型(Parents Delegation Model)

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

双亲委派模型工作过程:如果一个类加载器收到了加载类的请求,它首先不会去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一层的类加载器都是如此,因此所有加载请求最终会达到启动类加载器,只有当父类加载器反馈自己无法完成这个请求(搜索范围没有找到这个类),子加载器才会去自己加载

好处:Java类随类加载器一起具备了优先层次的关系,例如Object类,无论在哪个类加载器都是同一个类,但是如果没有双亲委派模型,自定义了一个Object类,那么会有不同的Object类,程序就会变得混乱无法区分。

但在双亲委派模型下,同名的低层的类永远无法被加载运行。

实现双亲委派的代码集中在ClassLoader的loadClass()方法中,其逻辑比较清晰:先判断类是否已经被加载过,如果没被加载则调用父加载器的loadClass()方法,如果父类不存在则直接使用启动类加载器进行加载,如果父类加载失败则抛出ClassNotFound异常后,再调用自己的findClass()方法进行加载

破坏双亲委派模型:

双亲委派模型出现3次大规模被破坏情况:

  1. 双亲委派是JDK1.2后才引入,但自定义ClassLoader在1.0就存在。为了向前兼容,ClassLoader提供了一个protected findClass()方法,这样如果在父类加载失败时就调用findClass()方法,就符合了双亲委派规则。(这应该是给双亲委派提供实现的方法,破坏的是ClassLoader本身)
  2. 模型自身缺陷导致第二次破坏。双亲委派模型保证了类的统一问题,越基础的类由越上层的类加载器加载,但基础的类又要调用回用户的代码就违背了向上的规则。例如JNDI服务,其代码是启动类加载器加载,但是其目的是对资源进行集中管理和查找,需要调用实现并部署在应用程序的JNDI接口,但启动类加载器是不会向下识别这些实现JNDI的代码的,因为其自身就包含了。为了解决这个问题,引入了线程上下文类加载器(Thread Context ClassLoader)。如果没有进行设置,这个类加载器默认是应用程序类加载器。在JVM中会把当前线程的类加载器加载不到的类交给线程上下文类加载器来加载。基本所有涉及到SPI(Service Provider Interface)的加载动作都采用这种方式,如JDBC。
  3. 第三次破坏是程序动态性导致的。例如代码热替换、模块热部署。OSGi实现模块热部署就是提供Bundle,每个Bundle都有自己的类加载器,不再是树状结构的模型而是网状结构。

 

posted @ 2019-07-26 13:23  大尾鲈鳗  阅读(133)  评论(0编辑  收藏  举报