avatar

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

本章常见面试题

  • 类加载机制的过程(五个阶段)
  • 什么是双亲委派模型?
  • 讲讲类加载器?

前提知识

在开始本章之前,你需要了解Class文件的存储格式,Java虚拟机在编译的时候将Java程序编译成Class文件,Java虚拟机只与Class文件这种特定的二进制文件格式关联,Class文件中包含了Java虚拟机指令集、符号表等信息

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按顺序紧凑排列在文件中,中间没有添加任何分隔符。

Class文件包含两种数据类型:无符号数

无符号数:表示的是基本数据类型,以u1、u2、u4、u8表示1个字节、2个字节、4个字节和8个字节

表:表示是有多个无符号数或者其他表作为数据项构成的符合数据类型,以”_info”结尾

Class文件头4个字节被称为魔数,是0xCAFEBABE(咖啡宝贝?),后4个字节存储的是Class文件的版本号:5,6个字节是次版本号,7,8个字节是主版本号

类加载的时机

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,整个周期为:加载、验证、准备、解析、初始化、使用、卸载七个阶段,验证、准备、解析这三个阶段统称为连接🔗

对于Java虚拟机而言,以下六种情况必须对类进行初始化

    1. 使用new关键字实例化对象的时候

    2. 读取或设置一个类型的静态字段(除了被final修饰、已在编译期就把结果放入常量池的静态字段)

    3. 调用一个静态类型方法的时候

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

  • 当初始化类的时候,如果发现其父类还没初始化,先触发父类

  • 虚拟机启动时,用户指定的那个执行的主类(包含main()方法的类)先初始化

  • 使用JDK1.7 动态语言的时候,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;

  • JDK1.8新加入的默认方法(被default关键字修饰的接口方法),如果这个接口的实现类发生了初始化,该接口也要在之前被初始化

被动引用:所有引用类的方式都不会触发初始化的方式:

(1)通过子类引用父类的静态字段,不会导致子类的初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SuperClass {
static{
System.out.println("SuperClass init!");
}
//父类的静态字段
public static int value = 123;
}
public class SubClass extends SuperClass {
static{
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
//通过子类引用父类的静态字段,不会导致子类的初始化
System.out.println(SubClass.value);
}
}

运行结果:”SuperClass init!”不会引发子类的初始化

(2)通过数组定义来引用类,不会出触发此类的初始化

1
2
3
4
5
6
7
8
public class NotInitialization {
public static void main(String[] args) {
//通过数组定义引用类,不会触发此类的初始化
SuperClass[] sca = new SuperClass[10];
//通过new创建对象,可以触发初始化
//SuperClass superClass = new SuperClass();
}
}

(3)常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量类的初始化

1
2
3
4
5
6
7
8
9
10
11
public class ConstClass {
static {
System.out.printlen("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}

接口的初始化与类初始化的区别:

一个类初始化要求其父类全部都已经初始化过了,但一个接口在初始化时,并不要求父接口全部完成了初始化,只有帧栈使用到父接口(如引用接口中定义的常量)时,才会初始化

类加载的过程

类加载包括 加载、验证、准备、解析和初始化

加载

Java加载过程是整个类加载的一个阶段,包含:

  • 通过一个类的全限定名(将类全名路径的.全部替换成/,比如: me/cglib/SampleClass)来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

Java虚拟机获取的是二进制字节流,可有很多来源:

  • 从ZIP压缩包读取,JAR包等格式
  • 从网络中获取,Web Applet
  • 动态代理,在运行时计算生成,代理$Proxy
  • 由其他文件生成,JSP文件生成对应的Class文件
  • 从数据库中读取,比如某些中间件服务器(SAP Netweaver)
  • 从加密文件中获取

对于非数组类加载可以由虚拟机内置的引导类加载器完成,也可以由用户自定义的类加载器(重写一个类加载器的findClass()loadClass()方法)完成。

验证

验证是连接阶段的第一步,目的是确保Class文件的字节流中包含的信息符合规范,不会危害虚拟机自身的安全

由于之前讲的Class文件并不一定只是由Java源码编译而来,所以如果不检查输入的字节流,很可能因为载入了错误或有恶意企图的字节码流而导致整个系统受攻击和崩溃

验证阶段大致完成四个阶段的校验:文件格式验证、元数据验证、字节码验证、符号引用验证

1.文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范

  • 是否以魔数0xCAFEBABE开头
  • 主、次版本号是否在当前Java虚拟机接收范围内
  • 常量池的常量中是否有不被支持的常量类型
  • 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量

满足第一阶段的验证后,这段字节流才允许进入Java虚拟机内存的方法区中存储,所以后面三个阶段 全部是基于方法区的存储结构上进行

2.元数据验证

第二阶段是对字节码描述的信息进行语义分析,对元数据信息的数据类型进行校验,包括:

  • 该类是否有父类(除了java.lang.Object之外)
  • 该类是否继承了不允许被继承的类(被final修饰的类)
  • 如果该类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
  • 类中的字段、方法是否与父类产生矛盾

3.字节码验证

第三阶段主要目的是通过数据流分析和控制流分析,确定程序语义是合法的。

对类的方法体(Class文件中的Code属性)校验分析

  • 保证不会出现比如在操作数栈放置了一个int类型数据,使用时却按long类型加载入本地变量表中
  • 保证任何跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转化总是有效的

由于数据流分析和控制流分析的高度复杂性,为提高性能,在JDK 6 之后在方法体Code属性的属性表中新增了一项StackMapTable属性,这项属性描述了方法体所有的基本块,只要检查StackMapTable属性中的记录是否合法即可

4.符号引用验证

最后一个验证发生在虚拟机将符号引用转化为直接引用时,这个转化动作发生在连接的第三阶段—解析阶段中发生,需要验证:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符
  • 符号引用中的类、字段、方法的可访问性(public,private,protected)是否可被当前类访问

符合引用的目的主要是保证解析行为能正常执行

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,注意:

  • 这时内存分配的类变量,不包括实例变量,实例变量会在对象实例化的时候随着对象一起分配在

  • 这里的初始值,通常情况下是数据类型的零值

    数据类型 零值 数据类型 零值
    int 0 boolean false
    long 0L float 0.0f
    short (short) 0 double 0.0d
    char ‘\u000’ reference null
    byte (byte) 0
    • 特殊情况的初始化:类字段的字段属性存在ConstantValue属性,那么会被初始化指定的值

      public static final int value = 123;

解析

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

  • 符号引用:是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能无歧义的定位到目标即可(比如字符串的字面量)。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到内存中的内容。
  • 直接引用:是直接指向目标的指针、相对偏移量或者能间接定位到目标的句柄。与虚拟机实现的内存布局有关,引用目标必须是已经在内存中存在的。

解析结果缓存

对同一符号引用进行多次解析请求是很常见的事情,除invokedynamic指令外,虚拟机实现可以对第一次解析的结果进行缓存,例如运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。

无论是否真正执行多次解析动作,Java虚拟机都需要保证在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能成功;同样的,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行

初始化

类的初始化阶段是类加载过程的最后一个步骤,这个阶段Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权转移给应用程序。之前的几个类加载动作,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余都完全由Java虚拟机来主导控制

初始化阶段就是执行类构造器<clinit>()方法的过程,这个方法是Javac编译器自动生成的

clinit()方法和类的构造函数的区别

clinit方法是出现在类加载过程的初始化阶段,init方法出现在类实例化时调用的(Java编译后,会在字节码中生成init方法,就是类的构造函数方法)

clinit()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)

init方法(实例构造器init()方法)是在类实例化时调用

<clinit>()方法不需要显式地调用父类构造器,Java虚拟机会保证在子类的clinit()方法执行前,父类的clint()方法已经执行完毕

类加载器

类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确定其在java虚拟机的唯一性。如果两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那么这两个类就必不相等。

类加载器介绍

从Java虚拟机角度来看,只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),使用C++语言实现,是虚拟机自身的一部分;另一个是其他所有类加载器,由Java语言实现,独立存在于虚拟机外部,全都继承自抽象类java.lang.ClassLoader

这里我们介绍三种系统提供的类加载器:

启动类加载器(Bootstrap ClassLoader):

这个类记载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,能被虚拟机识别的类库(按照文件名识别,如rt.jar、tools.jar等)加载到虚拟机内存

扩展类加载器(Extension ClassLoader):

这个类加载器负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。这是一种Java系统类库的扩展机制,可以在程序中使用该加载器来加载Class文件

应用程序类加载器(Application ClassLoader):

这个类加载器负责加载用户类路径(ClassPath)上所有的类库,也被称为’系统类加载器’。如果应用程序没有自定义过自己的类加载器,那么这个就是程序中默认的类加载器

我们的应用程序都是由这3种类加载器互相配合来完成加载,如果用户认为有必要,还可以加入自定义的类加载器来进行扩展

双亲委派模型

双亲委派模型反映的是各种类加载器之间的层次关系,他们之前是相互合作的。

除了顶层的启动类加载器外,其余的类加载器应有自己的父类加载器,不过这种父子关系不是通过继承来实现,而是一种组合关系

工作流程是: 如果一个类加载器收到了类加载的请求,先把这个请求委派给父类加载器去完成(所以所有的加载请求最终都应该传送到顶层的启动类加载器中),只有当父加载器反馈自己无法完成加载请求时,子加载器才会尝试自己去加载。

这样的好处就是保证了一种带有优先级的层次关系,比如java.lang.Object类,无论哪一个类加载器都要先加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境种都能保证是同一个类。

来看个代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
//1 首先检查类是否被加载
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//2 没有则调用父类加载器的loadClass()方法;
c = parent.loadClass(name, false);
} else {
//3 若父类加载器为空,则默认使用启动类加载器作为父加载器;
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
//4 若父类加载失败,抛出ClassNotFoundException 异常后
c = findClass(name);
}
}
if (resolve) {
//5 再调用自己的findClass() 方法。
resolveClass(c);
}
return c;
}

总结

本篇介绍了类加载过程中的五个阶段:加载、验证、准备、解析、初始化,还介绍了几种不同的类加载以及之间的合作关系—双亲委派模型

文章作者: SkironYong
文章链接: https://skironyong.github.io/SkironYong.github.io/posts/2e913e33.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 SkironYong
打赏
  • 微信
    微信
  • 支付寶
    支付寶

评论