[JVM][7][虚拟机类加载机制]

第 7 章 虚拟机类加载机制

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

7.1 概述

在Class文件中描述的各类信息,最终都需要加载到虚拟机中之后才能被运行和使用

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

与那些编译时需要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的。Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方加载一个二进制流作为其程序代码的一部分

7.2 类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个阶段称为连接(Linking)

关于在什么情况下需要开始类加载过程的第一阶段“加载”,虚拟机规范并没有强制要求,这一点交给了虚拟机具体实现来把握

但是对于初始化阶段,严格规定了有且只有以下6种情况必须立刻进行类的初始化

  1. 遇到newgetstaticputstaticinvokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK1.7的动态语言支持时,方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口种定义了默认方法时,如果有这个接口的实现类发生了初始化,那这个接口要在其之前被初始化

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

7.3 类加载的过程

接下来我们详细讲解一下Java虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化这5个阶段所执行的具体动作

7.3.1 加载

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

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

虚拟机规范没有指明二进制字节流要从一个 Class文件中获取,准确地说是根本没有指明要从哪里获取、怎样获取。虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的“舞台”,由此衍生出了好多技术:例如JAR(从zip中获取)、动态代理(运行时计算生成)、JSP技术(从其他文件中生成)、从加密文件中获取等

相对于类加载过程的其他阶段,加载阶段是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)

在加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为此程序访问方法区中的类型数据的外部接口

7.3.2 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟杌自身的安全。

Java语言本身是相对安全的语言,使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。但前面已经说过, Class文件并不一定要求用Java源码编译而来,可以使用任何途径产生。在字节码语言层面上,上述Java代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。虚拟机如果不检査输人的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

验证阶段大致上会完成下面4个阶段的检验动作

  • 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理
  • 元数据验证:对类的元数据信息进行语义验证,确保符合规范,例如:这个类是否继承了不被允许继承的父类、如果这个类不是抽象类是否实现了接口中要求实现的各种方法
  • 字节码验证:对类的方法体进行校验(Class文件的Code属性)
  • 符号引用验证:验证该类是否缺少或者被禁止访问它所以来的某些外部类、方法、字段等资源

7.3.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被 static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值。

7.3.4 解析

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

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

7.3.5 初始化

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量

7.4 类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自已决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

7.4.1 类与类加载器

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

7.4.2 双亲委派模型

自JDK1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构

三层类加载器是指

  • 启动类加载器( Bootstrap Class Loader):这个类加载器负责将存放在<JAVA_HOME>\lib目录中的虚拟机识别的类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。
  • 扩展类加载器( Extension Classloader):这个加载器由sun.misc.Launcher$ExtClassload实现,它负责加载<JAVA_HOME>\lib\ext目录中的所有类库,开发者可以直接使用扩展类加载器
  • 它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自已的类加载器,一般情况下这个就是程序中默认的类加载器

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

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

双亲委派模型对于保证Java程序的稳定运作很重要

7.5 Java模块化系统