开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第3天,点击查看活动详情
HotSpot虚拟机类加载机制
类的生命周期
什么叫做类加载?
类加载的定义: JVM把描述类的数据从Class文件加载到内存,并对数据进行校验,解析和初始化,最终变成可以被JVM直接使用的Java类型(因为可以动态产生,这里的Class文件并不是具体存在磁盘中的文件,而是二进制数据流)
一个类型被加载到内存使用 到 结束卸载出内存,它的生命周期分为7个阶段: 加载->验证->准备->解析->初始化->使用->卸载
其中重要阶段一般的开始顺序: 加载->验证->准备->解析->初始化
验证,准备,解析合起来又称为连接所以也可以是加载->连接->初始化
注意这里的顺序是一般的开始顺序,并不一定是执行完某个阶段结束后才开始执行下一个阶段,也可以是执行到某个阶段的中途就开始执行下一个阶段
还有种特殊情况就是解析可能在初始化之后(因为Java运行时的动态绑定)
基本数据类型不需要加载,引用类型才需要被类加载
类加载阶段
接下来将对这五个阶段进行详细介绍
Loading
加载
- 加载的作用
- 通过这个类的全限定名来查找并加载这个类的二进制字节流
- JVM通过文件系统加载某个class后缀文件
- 读取jar包中的类文件
- 数据库中类的二进制数据
- 使用类似HTTP等协议通过网络加载
- 运行时动态生成Class二进制数据流
- 将这个类所代表的静态存储结构(静态常量池)转化为方法区运行时数据结构(运行时常量池)
- 在堆中创建这个类的Class对象,这个Class对象是对方法区访问数据的"入口"
- 堆中实例对象中对象头的类型指针指向它这个类方法区的类元数据
- 对于加载可以由JVM的自带类加载器来完成,也可以通过开发人员自定义的类加载器来完成(实现ClassLoader,重写findClass())
注意
- 数组类是直接由JVM在内存中动态构造的,数组中的元素还是要靠类加载器进行加载
- 反射正是通过加载创建的Class对象才能在运行期使用反射
Verification
验证
- 验证的作用
确保要加载的字节码符合规范,防止危害JVM安全 - 验证的具体划分
- 文件格式验证
目的: 保证字节流能正确解析并存储到方法区之内,格式上符合Java类型信息
验证字节流是否符合Class文件格式规范(比如Class文件主,次版本号是否在当前虚拟机兼容范围内...) - 元数据验证目的: 对类的元数据信息进行语义验证元数据:简单的来说就是描述这个类与其他类之间关系的信息元数据信息验证(举例):
- 这个类的父类有没有继承其他的最终类(被final修饰的类,不可让其他类继承)
- 若这个类不是抽象类,那这个类有没有实现(抽象父类)接口的所有方法
- 字节码验证(验证中最复杂的一步)目的: 对字节码进行验证,保证校验的类在运行时不会做出对JVM危险的行为字节码验证举例:
- 类型转换有效: 子类转换为父类(安全,有效) 父类转换为子类(危险)
- 进行算术运算,使用的是否是相同类型指令等
- 符号引用验证
发生在解析阶段前:符号引用转换为直接引用
目的: 保证符号引用转为直接引用时,该类不缺少它所依赖的资源(外部类),确保解析可以完成
验证阶段是一个非常重要的阶段,但又不一定要执行(因为许多第三方的类,自己封装的类等都被反复"实验"过了)
在生产阶段可以考虑关闭 -Xverify:none以此来缩短类加载时间
Preparation
准备
准备阶段为类变量(静态变量)分配内存并默认初始化
- 分配内存
- 逻辑上应该分配在方法区,但是因为hotSpot在JDK7时将字符串常量,静态变量挪出永久代(放在堆中)
- 实际上它应该在堆中
- 默认初始化
- 类变量一般的默认初始化都是初始化该类型的零值
类型 | 零值 |
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0F |
double | 0.0 |
boolean | false |
char | '\u0000' |
reference | null |
- 特殊的类变量的字段属性中存在ConstantValue属性值,会初始化为ConstantValue所指向在常量池中的值
- 只有被final修饰的基本类型或字面量且要赋的值在常量池中才会被加上
ConstantValue
属性
Resolution
解析
- 解析的作用将常量池中的常量池中符号引用替换为直接引用(把符号引用代表的地址替换为真实地址)
- 符号引用
- 使用一组符号描述引用(为了定位到目标引用)
- 与虚拟机内存布局无关
- 还是符号引用时目标引用不一定被加载到内存
- 直接引用
- 直接执行目标的指针,相对偏移量或间接定位目标引用的句柄
- 与虚拟机内存布局相关
- 解析直接引用时目标引用已经被加载到内存中
- 并未规定解析的时间
可以是类加载时就对常量池的符号引用解析为直接引用
也可以在符号引用要使用的时候再去解析(动态调用时只能是这种情况) - 同一个符号引用可能会被解析多次,所以会有缓存(标记该符号引用已经解析过),多次解析动作都要保证每次都是相同的结果(成功或异常)
类和接口的解析
当我们要访问一个未解析过的类时
- 把要解析的类的符号引用 交给当前所在类的类加载器 去加载 这个要解析的类
- 解析前要进行符号引用验证,如果当前所在类没有权限访问这个要解析的类,抛出异常
IllegalAccessError
字段的解析
解析一个从未解析过的字段
- 先对此字段所属的类(类, 抽象类, 接口)进行解析
- 然后在此字段所属的类中查找该字段简单名称和描述符都匹配的字段,返回它的直接引用
- 如果此字段所属的类有父类或实现了接口,要自下而上的寻找该字段
- 找不到抛出
NoSuchFieldError
异常
- 对此字段进行权限验证(如果不具备权限抛出
IllegalAccessError
异常)
确保JVM获得字段唯一解析结果
如果同名字段出现在父类,接口等中,编译器有时会更加严格,直接拒绝编译Class文件
方法的解析
解析一个从未解析过的方法
- 先对此方法所属的类(类, 抽象类, 接口)进行解析
- 然后在此方法所属的类中查找该方法简单名称和描述符都匹配的方法,返回它的直接引用
- 如果此方法所属类是接口直接抛出
IncompatibleClassChangeError
异常 - 如果此方法所属的类有父类或实现了接口,要自下而上的寻找该方法(先找父类再找接口)
- 如果在接口中找到了,说明所属类是抽象类,抛出
AbstractMethodError
异常(自身找不到,父类中找不到,最后在接口中找到了,说明他是抽象类),找不到抛出NoSuchMethodError
异常
- 对此方法进行权限验证(如果不具备权限抛出
IllegalAccessError
异常)
接口方法的解析
解析一个从未解析过的接口方法
- 先对此接口方法所属的接口进行解析
- 然后在此接口方法所属的接口中查找该接口方法简单名称和描述符都匹配的接口方法,返回它的直接引用
- 如果此接口方法所属接口是类直接抛出
IncompatibleClassChangeError
异常 - 如果此方法所属的接口有父接口,要自下而上的寻找该接口方法
- 如果多个不同的接口中都存在这个接口方法,会随机返回一个直接引用(编译会更严格,这种情况应该会拒绝编译)
- 找不到抛出
NoSuchMethodError
Initializtion
初始化
执行类构造器的过程
- 什么是 ?
- 是javac编译器 在编译期间自动收集类变量赋值的语句和静态代码块合并 自动生成的
- 如果没有对类变量赋值动作或者静态代码块可能不会生成 (带有
ConstantValue
属性的类变量初始化已经在准备阶段做过了,不会在这里初始化)
- 类和接口的类构造器
- 类
又叫类构造器,与实例构造器不同,类构造器不用显示父类类构造器调用
但是父类要在子类之前初始化,也就是完成类构造器 - 接口
执行接口的类构造器时,不会去执行它父类接口的类构造器,直到用到父接口中定义的变量被使用时才执行
- JVM会保证执行在多线程环境下被正确的加锁和同步(也就是只会有一个线程去执行其他线程会阻塞等待,直到完成)
public class TestJVM { static class A{ static { if (true){ System.out.println(Thread.currentThread().getName() + "<clinit> init"); while (true){ } } } } @Test public void test(){ Runnable runnable = new Runnable() { @Override public void run() { System.out.println(Thread.currentThread().getName() + "start"); A a = new A(); System.out.println(Thread.currentThread().getName() + "end"); } }; new Thread(runnable,"1号线程").start(); new Thread(runnable,"2号线程").start(); } } /* 1号线程start 2号线程start 1号线程<clinit> init */
JVM规定6种情况下必须进行初始化(主动引用)
主动引用
- 遇到new,getstatic,putstatic,invokestatic四条字节码指令
- new
- 读/写 某类静态变量(不包括常量)
- 调用 某类静态方法
- 使用
java.lan.reflect
包中方法对类型进行反射 - 父类未初始化要先初始化父类 (不适用于接口)
- 虚拟机启动时,先初始化main方法所在的类
- 某类实现的接口中有默认方法(JDK8新加入的),要先对接口进行初始化
- JDK7新加入的动态语言支持,部分....
被动引用
- 当访问静态字段时,只有真正声明这个字段的类才会被初始化
(子类访问父类静态变量)
public class TestMain { static { System.out.println("main方法所在的类初始化"); } public static void main(String[] args) { System.out.println(Sup.i); } } class Sub{ static { System.out.println("子类初始化"); } } class Sup{ static { System.out.println("父类初始化"); } static int i = 100; } /* main方法所在的类初始化 父类初始化 100 */
子类调用父类静态变量是在父类类加载初始化的时候赋值的,所以子类不会类加载
- 实例数组
public class TestArr { static { System.out.println("main方法所在的类初始化"); } public static void main(String[] args) { Arr[] arrs = new Arr[1]; } } class Arr{ static { System.out.println("arr初始化"); } } /* main方法所在的类初始化 */
例子里包名为:org.fenixsoft.classloading。该例子没有触发类org.fenixsoft.classloading.Arr的初始化阶段,但触发了另外一个名为“[Lorg.fenixsoft.classloading.Arr”的类的初始化阶段,对于用户代码来说,这并不是一个合法的类名称,它是一个由虚拟机自动生成的、直接继承于Object的子类,创建动作由字节码指令anewarray触发. 这个类代表了一个元素类型为org.fenixsoft.classloading.Arr的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。
创建数组时不会对数组中的类型对象(Arr)发生类加载
虚拟机自动生成的一个类,管理Arr的数组,会对这个类进行类加载
- 调用静态常量
public class TestConstant { static { System.out.println("main方法所在的类初始化"); } public static void main(String[] args) { System.out.println(Constant.NUM); } } class Constant{ static { System.out.println("Constant初始化"); } static final int NUM = 555; } /* main方法所在的类初始化 555 */
我们在连接阶段的准备中说明过,如果静态变量字段表中有ConstantValue
(被final修饰)它在准备阶段就已经完成初始默认值了,不用进行初始化
- 调用classLoader类的loadClass()方法加载类不导致类初始化
卸载
方法区的垃圾回收主要有两部分: 不使用的常量和类
回收方法区性价比比较低,因为不使用的常量和类比较少
不使用的常量
没有任何地方引用常量池中的某常量,则该常量会在垃圾回收时,被收集器回收
不使用的类
成为不使用的类需要满足以下要求:
- 没有该类的任何实例对象
- 加载该类的类加载器被回收
- 该类对应的Class对象没在任何地方被引用
注意: 就算被允许回收也不一定会被回收, 一般只会回收自定义的类加载器加载的类
总结
本篇文章围绕类加载阶段流程的加载-验证-准备-解析-初始化-卸载 详细展开每个阶段的细节
加载阶段主要是类加载器加载字节码流,将静态结构(静态常量池)转换为运行时常量池,生成class对象
验证阶段验证安全确保不会危害到JVM,主要验证文件格式,类的元数据信息、字节码、符号引用等
准备阶段为类变量分配内存并默认初始化零值
解析阶段将常量池的符号引用替换为直接引用
初始化阶段执行类构造器(类变量赋值与类代码块的合并)
- 参考资料
- 《深入理解Java虚拟机》
- 部分图片来源网络