前言
本文内容大多来自《深入理解Java虚拟机》,但是经过整理,语言更加简练。
虚拟机的类加载机制是一个与Java程序员息息相关的知识点,因为我们写的程序都是由类构成的(一个一个class文件),这些类都会被放进JVM中。那么介绍虚拟机的类加载机制就是为了让你了解一个class文件被放入JVM的整个过程。
本文主要介绍两个知识点:
- 虚拟机类加载机制:类的生命周期。
- 双亲委派模型:越基础的类交给越上层的类加载器加载。
一个例子
可能有Java程序员觉得:这么底层的知识了解了干啥?!安心写应用不就好了!我却觉得:在一般的应用开发中确实用不到这部分知识点,但是这却是Java程序员需要知道的基础理论,他帮助你更加了解你每天在写的Java程序的内部运作过程。
这里我给出一个例子:
|
输出结果是:1 2 3 4 5
。
这个例子经常在笔试面试场合遇到(一般写程序不可能写成这样,太BT了…),这个例子如果给一个不懂JVM的程序员看,他一定不知道答案(除非死记硬背),但是如果了解了JVM的类加载机制,那么 So Easy!
类加载机制
虚拟机的类加载机制分为7个阶段:
- 加载:程序员不可见。将一个类的二进制字节流加载到虚拟机的方法区中,并创建类对应的Class对象。
- 验证:程序员不可见。对类的各种信息进行验证,确保JVM的安全。
- 准备:程序员不可见。为静态变量在方法区中分配内存,并初始化为数据类型的零值。
- 解析:程序员不可见。将各种符号引用转化为直接引用。
- 初始化:程序员可见。执行JVM自己生成的
<clinit>()
方法。 - 使用:程序员可见。
- 卸载:程序员不可见。类从方法区中移出。
上述 1,2,3,5 阶段是按顺序开始的,但是可能是交叉进行的。比如不同方面的验证(下文中会介绍)可能穿插在加载、解析阶段中进行。
加载
加载阶段主要完成三个工作:
- 通过常量池中类的全限定名获取对应类的二进制字节流(可能来自Class文件,网络,运行时动态生成等)。
- 将二进制字节流读入虚拟机并在方法区中形成类的数据结构。
- 在方法区中创建类对应的Class对象。
如果要观察类的加载,则需要加上
+XX:TraceClassLoading
,我们会发现程序运行时,首先会加载最基础的类比如java.lang.Object
等。
加载阶段的运行离不开类加载器,类加载器在后文中”双亲委派模型”中会讲解,这里先给出基本介绍:类加载器主要完成根据类的全限定名获取类的二进制字节流(加载阶段的第一个工作)。类加载器有很多,包括系统自带的,自定义的类加载器。
一个类的唯一性就是由类加载器和类的全限定名决定的,也就是说,如果一个类就算类的全限定名一样,但是由不同的类加载器加载到虚拟机中,则可以共存,即他们是不同的两个类。我们在开发中没出现这种情况是因为类默认都是由同一个类加载器(应用程序类加载器)加载。
验证
验证的目的是为了确保二进制字节流符合当前JVM的要求,并使得JVM不受恶意代码的破坏。
验证分为:
- 文件格式验证:这个验证是在加载阶段进行的。主要验证二进制字节流是否符合class文件的格式规范,比如class文件是否以魔数0xCAFEBABE开头,主次版本号是否符合当前虚拟机的范围。
- 元数据验证:对类的元数据(类的定义)进行语义分析与验证,比如类是否重写了接口的所有方法,继承的父类是否是非final的。
- 字节码验证:对类中的字节码进行验证,比如方法体内跳转语句的跳转地址是否仍在方法体之内,类型转换语句是否正确。
- 符号引用验证:这个验证在解析阶段发生,主要对引用的合法性进行验证,比如根据类的全限定名是否能找到对应的类,当前类是否有权限访问引用的类、方法、属性。
准备
为静态变量在方法区中分配内存,初始化值分为:
- 当是static且非final变量,初始化为数据类型的零值。
- 当是static final常量,初始化为赋值语句右边的值。
解析
将常量池中的符号引用(一个符号而已,但是通过这个符号能够标明引用的对象是什么,比如符号引用是 com.xiazdong.Test
,则可以看出他引用的是com.xiazdong.Test
这个类)转化为直接引用(指向目标对象的指针或句柄)。
解析分为类的解析,属性的解析,类方法的解析等,我们这里只介绍类的解析,属性的解析,其他类似。
- 类的解析:将类的符号引用(类的全限定名)交给当前类的类加载器去加载该类(中间会触发元数据验证、字节码验证、符号引用验证),加载完后再判断当前类是否有权限访问引用的类,如果没有权限则抛出
java.lang.IllegalAccessError
。 - 属性的解析:
- 对属性的数据类型对应的类C进行解析(也就是类的解析)。
- 如果在类C中存在名称和类型都与目标相匹配的属性,则返回该属性的直接引用。
- 否则,在类C的父类或更上层寻找名称和类型都与目标匹配的属性,如果有则返回属性的直接引用。
- 否则,抛出
java.lang.NoSuchFieldError
。 - 如果查找成功,则进行访问权限验证,如果当前类对该属性没有访问权限,则抛出
java.lang.IllegalAccessError
。
初始化
真正开始执行程序员编写的Java代码,也可以说是执行 <clinit>()
方法,该方法是由编译器自动收集类中所有类变量的赋值动作和静态代码块的语句,并按顺序合并产生。JVM对于初始化的时机有明确地规定:
- 当遇到字节码指令 new, getstatic, putstatic, invokestatic 时,分别为创建实例,获得静态变量,设置静态变量值,调用静态方法。
- 当通过反射访问该类。
- 当初始化一个类时,如果发现父类没初始化,则先触发父类的初始化;如果发现父接口没初始化,则不用管父接口。
- 程序的主类(main()方法所在的类)要最先初始化。
- (另一个场景先不必考虑。)
例子的分析
了解了类加载机制后,我们开始对上面的例子进行分析。
初始化 A 类
因为A类是程序的执行主类,因此首先执行A的<clinit>()
方法。该类的<clinit>()
方法类似如下:
|
执行 A.getInstance()
- 执行
System.out.print("3")
。 - 因为访问了 B 的静态变量
instance
,因此导致该类的初始化,执行该类的<clinit>()
方法:
|
双亲委派模型
类加载器负责完成加载阶段的”根据类的全限定名获取该类的二进制字节流”。
类加载器分为:
- 启动类加载器(Bootstrap ClassLoader): C++实现,负责加载 <JAVA_HOME>\lib(或 -Xbootclasspath 参数指定路径)中的虚拟机识别的类,程序员不能直接使用该类加载器。
- 扩展类加载器(Extension ClassLoader): 负责加载 <JAVA_HOME>\lib\ext 中的类库。
- 应用程序类加载器(Application ClassLoader): 负责加载 classpath 指定的类库,这是程序的默认类加载器。
- 自定义类加载器: 用户自己定义的类加载器。继承 ClassLoader 类并重写
findClass()
。
有这么多类加载器,一个类到底要由哪个类加载器加载呢?由此引入了双亲委派模型(Parents Delegation Model)。如下图:
双亲委派模型的目的是组织类加载器的层次关系(这种层次关系是通过组合方式实现),并以下面的方式去工作:
如果一个类加载器收到了类加载请求,他首先不会自己去加载,而是会交给父类加载器,因此所有的加载请求最终都应传送到顶层的启动类加载器,只有当父类加载器反馈自己无法完成加载请求时,子加载器才会去尝试自己加载。
因此如果将 java.lang.Object
交给应用程序类加载器加载,则首先会交给父类加载器完成,很幸运启动类加载器能够完成这个加载请求,则就轮不到应用程序类加载器了。
易混点
加载这个词在这篇文章中出现多次,我们要来理清这里面的区别:
- 类加载机制:类的生命周期。
- 加载阶段:类加载机制的第一个阶段。
- 类加载器:完成加载阶段的一部分内容。
终于写完了,这篇文章也算是2014年的最后一篇博客。再见,2014!希望明年能够更好!