Java虚拟机类加载机制(七)

96
Bollen_Chak
2017.10.27 14:18* 字数 6103

读书笔记 深入理解Java虚拟机:JVM高级特性与最佳实现(第二版)

概述

深入了解了Class文件存储格式的具体细节后,虚拟机如何加载这些Class文件?Class文件中的信息进入虚拟机后会发生什么变化?这是作者第七章讲解的内容。

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

类加载都是在程序运行期间完成的,虽然会增加程序一点性能开销,但能为 Java 应用提供高度的灵活性。通过依赖运行期动态加载和动态连接特点使 Java 具备动态扩展的语言特性。例如:

  • 编写面向接口的应用程序,可以等到运行时再指定其实际的实现类
  • 通过 Java 预定义的和自定义类加载器在运行时从其他地方加载二进制流作为程序代买的一部分(Applet、JSP、OSGi 技术)

类加载的时机

类的生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)共 7 个阶段。

其中验证、准备、解析 3 个部分统称为连接(Linking),这 7 个阶段的发生顺序如下图所示:

图7-1 类加载阶段发生顺序

什么时候开始类加载过程的第一个阶段:加载?

Java 虚拟机规范并没有强制规定加载(Loading)的时机。但严格规定有且只有在以下 5 种情况时如果类没有初始化,则需要先触发其初始化(Initialization)

初始化之前,自然会进行加载连接

  1. 遇到 new(实例化对象)、getstatic(读取除常量外静态字段)、putstatic(设置读取除常量外静态字段) 或 invokestatic(调用类的静态方法) 这 4 条字节码指令时,所在的类需要初始化。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时。
  3. 初始化一个类时,如果其父类没有初始化,则需先初始化其父类(接口除外,只有使用到父接口的时候才会初始化)。
  4. 虚拟机启动时会先初始化用户指定的执行主类(包含 main 方法的类)。
  5. 使用 JDK1.7 动态语言支持时,java.lang.invoke.MethodHandle 实例最后解析的结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄时,则这个方法句柄对应的类需要初始化。

上述 5 种场景中的行为成为对一个类主动引用。除了主动引用之外,所有引用类的方式都不会触发类的初始化,称为被动引用

类加载的过程

接下来讲解加载、验证、准备、解析和初始化这 5 个阶段所执行的具体动作。

加载

加载类加载过程第一个阶段。在加载阶段虚拟机要做 3 件事:

  1. 通过一个类的全限定名来获取定义此类的二进制流。
    • 可以从压缩包中读取,如:JAR、EAR、WAR 格式。
    • 从网络读取,如:Applet 。
    • 运行时计算生成,如:动态代理技术在 java.lang.reflect.Proxy 中,通过 ProxyGenerator.generateProxyClass为特定接口生成形式为「*$Proxy」的代理类的二进制字节流。
    • 由其他文件生成,如:JSP 应用通过 JSP 文件生成对应的 Class 类。
    • 从数据库中读取,如:中间件服务器 SAP Netweaver 可以选择把程序安装到数据库中来完成程序代码在集群间分发。
      ……
  2. 将这个字节流所代表的静态存储结构转化为方法区运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

获取类的二进制流是开发人员可控性最强的,既可以通过系统提供的启动类加载器完成,也可以由用户自定义类加载器来控制字节流的获取方式(重写类加载器的 loadClass 方法)。

数组类比较特殊,它不通过类加载器创建,而是由 Java 虚拟机直接创建。但数组的元素类型(Element Type)最终是要靠类加载器创建。

加载阶段完成后,虚拟机外部的二进制字节流就会按照所需的格式存储在方法区中,存储格式由虚拟机自行定义。然后在内存中实例化一个 java.lang.Class 对象(并没有在堆中,Class 对象虽然是对象,但在 HotSpot虚拟机中是存放在方法区里),这个对象将作为程序访问方法区中这些类型数据的外部接口。

加载阶段与连接阶段的验证中一部分字节码文件格式验证动作是交叉进行的,加载阶段尚未完成,连接阶段就可能已经开始。这两个阶段总体的开始时间仍然保持固定的先后顺序。

验证

验证阶段的目的是保证 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机的安全。

如加载阶段所述,Class 文件并不一定是 Java 源码编译而来,甚至可以用 16 进制编辑器直接编写。虚拟机如果不进行字节流验证,可能因载入有害字节流而导致系统崩溃。

验证阶段大致上会完成 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证

文件格式验证

验证字节流是否符合 Class 文件格式规范,能被当前版本的虚拟机处理。可能包括以下验证点:

  • 是否魔数以 0xCAFEBABE 开头。
  • 主、次版本号是否在当前虚拟机的处理范围内。
  • 常量池中是否有不支持的常量类型(检查常量 tag 标志)。
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据。
  • Class 文件中各个部分及文件本身是否有被删除或附加的其他信息
    ……

此阶段的验证是基于二进制字节流,只有通过了这个阶段后,字节流才会进入方法区内进行存储。后面的 3 个阶段验证全都是基于方法区的存储结构进行的,不会再操作字节流。

元数据验证

对字节码描述信息进行语义分析,确保其描述信息符合 Java 语言规范的要求。可能包括以下验证点:

  • 是否有父类(除了 java.lang.Object 之外所有类都有父类)。
  • 父类是否继承了不允许被继承的类(被 final 修饰的类)。
  • 如果不是抽象类,是否实现了父类或接口中要求实现的方法。
  • 类中的字段、方法是否与父类产生矛盾(如覆盖了父类的 final 字段,或出现不符合规则的方法重载,例如方法参数一样,但返回值不同等)
    ……

字节码验证

验证阶段最复杂的阶段,主要目的是通过数据流和控制流分析,确定程序的语义是合法的、符合逻辑的。在对元数据信息中的数据类型做完校验后对方法体进行校验分析,保证在运行时方法不会做出危害虚拟机安全的事件。

由于数据流验证的高复杂性,为避免过多的时间消耗,JDK 1.6 以后 Javac 编译器和 Java虚拟机进行了一项优化,给方法体的 Code 属性的属性表增加了一项名为 StackMapTable 属性。这个属性描述了方法体中所有的基本块(按控制流拆分的代码块)开始时本地变量表和操作数栈应有的状态。在字节码验证阶段就不需要根据程序推导状态合法性,只要检查 StackMapTable 属性中的记录是否合法即可。

理论上 StackMapTable 属性也存在被篡改的可能。有可能在恶意篡改 Code 属性的同事生成相应的 StackMapTable属性来骗过虚拟机类型校验。

符号引用验证

验证目的是确保符号引用转直接引用在解析阶段能正常执行。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。通常需要校验一下内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
  • 符号引用中农的类、字段、方法的访问性是否可被当前类访问
    ……

如果无法通过符号引用验证,将会抛出 java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。

虽然验证阶段十分重要,但如果能保证自己编写的以及第三方包中代码都已经反复使用和验证过,可以使用-Xverify:none参数来关闭大部分的验证措施,以缩短加载时间。

准备

准备阶段是正式为类变量(除常量外,被 static 修饰的变量)在方法区分配内存并设置类变量的初始值(数字类型为 0,布尔类型为 false,引用类型为 null……)阶段。实例变量在准备阶段是不会设值的,而是在对象实例化时随着对象一起分配在 Java
堆中。

例如在下面的代码中,类变量 value 在准备阶段后值为 0 而不是 123。因为此时尚未执行任何 Java 方法,把 123 赋值给 value 的 putstatic 指令是被程序编译后存放在类构造器 <client>() 方法中的,这个方法只有在初始化阶段才会执行。

public static int value = 123;

如果这个变量是常量,类字段的字段属性表中存在 ConstantValue 属性,那么准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值。例如:

public final static int value = 123;

编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 值把 value 赋值为 123。

解析

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

  • 符号引用(Symbolic Reference):以一组符号来描述引用的目标,符号的字面量形式明确的定义在 Java 虚拟机规范中的 Class 文件格式中。

  • 直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或者是一个间接能定位到目标的句柄。如果有了直接引用,那目标一定存在于内存中。

虚拟机规范没有规定解析阶段发生的具体时间,可以自行决定到底在类被加载器加载时就对常量池中的符号要引用进行解析,还是等到一个符号引用将要被使用前才去解析他。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。后 3 种符号引用与 JDK 1.7 新增的动态语言支持息息相关。

类或接口的解析

完成类或接口中的符号引用解析,在对符号引用的类加载过程中可能触发相关类的加载动作,所有类加载完成后如果没有异常,还需要进行符号引用的访问权限验证。

字段解析

解析字段符号引用首先会解析字段所属的类或接口的符号引用,如果没有异常,虚拟机规范要求按如下步骤进行搜索:

  1. 如果所属类本身就包含简单名称和字段描述符与该字段匹配的字段,结束并返回这个字段。
  2. 否则,按照继承关系从下往上递归搜索各个父接口,如果包含了匹配字段,结束返回。
  3. 否则,按继承关系从下往上递归搜索父类,如果包含了匹配字段,结束并返回。
  4. 否则,查找失败抛出 java.lang.NoSuchFieldError 异常。

查找完成后会对字段进行访问权限验证,没有权限时抛出 java.lang.IllegalAccessError 异常。

类方法解析

类方法解析与字段解析第一步一样,先解析类方法所在的类或接口的符号引用,如果没有异常,虚拟机规范要求按如下步骤进行搜索:

  1. 类方法与接口方法的常量类型定义不同,如果发现类方法表中 class_index 中索引的是接口方法,直接抛出 java.lang.IncompatibleClassChangeError 异常。
  2. 如果在所在类中找到与目标简单名称和描述符相同的方法(以下简称「匹配」),直接返回这个方法的直接引用,查找结束。
  3. 否则,在类的父类中递归查找是否有匹配的方法,如果有返回方法的直接引用,查找结束。
  4. 否则,在类实现的接口列表中及他们的父接口中递归查找是否有匹配的方法,如果有,证明此类是个抽象类,查找结束,抛出 java.lang.AbastractMethodError 异常。
  5. 否则,宣布查找方法失败,抛出 java.lang.NoSuchMethodError 异常。
    最后对类方法进行访问权限验证,没有权限时抛出 java.lang.IllegalAccessError 异常。

接口方法解析

与类方法解析第一步一样,先解析类方法所属类或接口的符号引用。如果解析成功,虚拟机规范要求按如下步骤进行搜索:

  1. 与类方法解析不同,如果在接口方法表中发现 class_index 中索引的是类方法,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
  2. 否则,在接口中查找是否存在匹配的方法,如果存在直接返回接口方法的直接引用,查找结束。
  3. 否则,在接口的父接口中递归查找,直到 java.lang.Object 类(查找范围可能会包括 Object 类)为止,搜索是否有匹配的方法,如果有返回这个接口发方法的直接引用,查找结束。
  4. 否则,宣告查找失败,抛出 java.lang.NoSuchMethodError 异常。
    由于接口方法都是 public 修饰的,因此不需要进行访问权限判断。

初始化

在整个类加载的过程中,除了加载阶段用户应用程序可以自定义类加载器进行控制,其余的阶段都是有虚拟机主导完成的。到了初始化阶段才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。

在准备阶段类变量已经赋过一次初始值。在初始化阶段虚拟机会根据程序员的代码去初始化类变量和其他资源。或者说初始化的过程是执行 <clinit>() 方法的过程

<clinit>() 方法是由编译器按照源文件中出现的顺序,自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并而成的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在他之后的变量只能赋值不能读取。

// 非法向前引用变量代码示例
public Class Test{
   static {
       i = 0;  //静态块中给后面的类变量赋值可以通过编译
       System.out.println(i);//静态块中读取定义在后面的类变量编译器会提示「非法向前引用」
   }
   static int i = 1;
}

<clinit>() 方法和类的构造函数(<init>() 方法)不同,不需要显示地调用父类构造器,虚拟机会保证子类的 <clinit>() 方法执行前父类的该方法已经执行完毕。因此,虚拟机中第一个执行的 <clinit>() 方法一定是 java.lang.Object 类。

由于父类的 <clinit>() 方法先执行,则父类定义的静态代码块先于子类的变量赋值操作。

如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。

虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确的加锁、同步,如果有多个线程去初始化同一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待。如果一个类的 <clinit>() 方法中存在耗时很长的操作,很可能造成多线程阻塞。同一个类加载器只会加载一次类,因此多线程下只会执行一次 <clinit>() 方法。

类加载器

在虚拟机外部,把实现通过一个类的全限定名来获取这个类的二进制字节流的动作的代码模块成为类加载器。类加载器在类层次划分、OSGi、热部署、代码加密等领域大放异彩,成为 Java 技术体系中的重要基石。

类与类加载器

在 Java 虚拟机中,任一一个类的唯一性是由该类与其类加载器共同确立的。也就是说,同一个类在同一个虚拟机中,但在不同的类加载器中,那这两个类则不相等。「相等」的判断依据是 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括 instanceof 关键字对对象所属关系的判定等情况。在使用自定义类加载器时需要注意这点。

双亲委派模型

从 Java 开发人员的角度看,系统提供的类加载器可划分为以下 3 种:

  1. 启动类加载器(Bootstrap ClassLoader):这个类负责将存放在 <JAVA_HOME>\lib 目录中的或者被 -Xbootclasspath 参数指定的路径中指定名称(例如 rt.jar)的类库。启动类加载器无法被 Java 程序直接引用,在自定义类加载器中,如果需要启动类加载器来加载类,在需要传入 ClassLoader 做参数的方法中直接把 null 作为程序的类加载器代替即可。例如,在如下方法的第 3 个参数传 null 即可。
public static Class<?> forName(String name, boolean initialize,ClassLoader loader) throws ClassNotFoundException{
  …
}
  1. 扩展类加载器(Extension ClassLoader):这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,负责加载 <JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  2. 应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$AppClassLoader 实现。这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也成为系统类加载器。负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用。如果应用程序中没有指定类加载器一般就作为默认类加载器。

此外,我们也可以自己定义的类加载器。这些类加载器关系一般如下图所示:

类加载器双亲委派模型

双亲委派模型并不是强制性的约束模型,而是一种 Java 设计者推荐的类加载器实现方式。双亲委派模型工作过程是:当某个类加载器收到加载类请求,首先会把这个请求委派给父类加载器去完成,每一层都如此,直到启动类加载器中,只有当父类加载器反馈无法加载时才会交由子类加载器去加载。

破坏双亲委派模型

  • JNDI 服务需要加载 SPI 提供的代码

双亲委派模型很好的解决了各个类加载器的基础类的统一问题(基础类都是上层类加载器加载的),但如果基础类想要调用用户代码就无法实现。典型的场景是 JNDI 服务(Java Naming and Directory Interface,Java 命名和目录接口),JNDI 的目的是对资源进行集中管理和查找,它需要由独立厂商实现并部署在应用程序的 Classpath 下的 JNDI 接口提供者(SPI,Service Provider Interface)的代码。但启动类加载器不能加载这些代码!怎么办?

Java 通过线程上下文类加载(Thread Context ClassLoader)这个类加载器,可以再 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果线程没有设置则从其父线程中继承一个,如果应用程序没有全局都没有设置过,则这个类加载器默认就是应用程序类加载器。

JNDI 服务可以使用线程上下文类加载 SPI 代码,也就是父类加载器请求子类加载器去完成类加载动作,这打破了双亲委派模型的类层次结构。Java 中所有涉及 SPI 的加载动作基本都采用了这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

  • 代码热替换(HotSwap)、模块热部署(Hot Deployment)

开发者对程序动态性的追求一直十分火热,希望应用能像鼠标在电脑上热拔插一样,即插即用,不用重启电脑。对应软件开发上是希望不用重启应用程序即可完成发布,热部署对企业级软件开发者有很大吸引力。OSGi 是目前 Java 业界的模块化标准,它实现模块化热部署的关键原则是自定义类加载器的实现。每一个模块都有一个类加载器,当需要更换一个模块时,连同类加载器一起换掉以实现热替换。OSGi 的类加载器结构不是双亲委派模型那样的树形结构,而发展成更为复杂的网状结构。

OSGi 中类加载器的使用时很值得学习的,弄懂了 OSGi的实现,就可以算掌握了类加载器的精髓。

小结

本章作者介绍了类加载过程的「加载」、「验证」、「准备」、「解析」和「初始化
」5 个阶段中虚拟机的动作,还介绍了类加载器的工作原理以及对虚拟机的意义。

读书笔记
Web note ad 1