【Java虚拟机】《深入理解Java虚拟机》| 虚拟机类加载机制(一)

虚拟机类加载机制


  • 前提概念
    • 什么是虚拟机类加载机制?
    • Java天生可以动态扩展的语言特性
    • java xxx 命令执行时类加载的过程(宏观)
    • 类成员的加载顺序
  • 类加载的时机
    • 什么是类加载的实时机?
    • 主动引用和被动引用的概念
  • 类加载的过程
    • 类的生命周期
    • 类加载的过程(5个阶段)
    • 加载阶段
    • 验证阶段
    • 准备阶段
    • 解析阶段
    • 初始化阶段
    • 类加载过程的小结

前提概念


什么是虚拟机类加载机制?

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

Java天生可以动态扩展的语言特性

与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然令类加载时稍微增加了一些性能开销,但是会为Java应用程序提高高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖动态加载动态连接这个特点实现的

java xxx 命令执行时类加载的过程(宏观)
  • javac hello.java | 编译hello.java文件,获得字节码
  • java hello | 运行解释hello.class文件
    1. 启动JVM
    2. 加载JDK核心类库
    3. 找到hello类的字节码文件
    4. 把字节码加载到JVM中
    5. 验证字节码是否符合安全规则
    6. 找到hello类的main函数运行
类成员的加载顺序

在深入了解类加载机制的前提,你必须要知道代码中类加载的顺序,什么意思呢?就是类的成员、构造函数、代码块等在类加载时的执行顺序,再进一步了解继承的情况下,父子类的成员、构造函数、代码块的加载顺序

【Java笔记】继承方式下静态成员变量、普通成员变量、静态代码块、构造代码块、构造函数在JVM的加载顺序


类加载的时机


什么是类加载的实时机?

就是什么时候会触发类的加载的意思


主动引用和被动引用的概念
要什么时候会导致类的加载,书上是这么说的:
  • 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,如果类没有进行过任何的初始化,则必须先初始化
  • 使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化
  • 当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化操作
  • 当虚拟机启动时,用户需要指定一个要执行主类的(包含main()方法的那个类),虚拟机会先初始化这个类
  • 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后解析的结果REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有初始化,则需要先触发其初始化

简而言之,对于上面的几种中会触发类加载的场景,我们称之为主动引用,相对于不会触发类加载的场景,我们称之为被动引用

主动引用: (一定会发生类的初始化)

1. new一个类的对象
2. 调用类的静态成员(变量或方法)(除了static final的常量)
3. 使用java.lang.reflect包的方法对类进行反射调用
4. 当虚拟机启动时,如java hello,则一定会初始化hello类,也就是先启动main方法所在的类
5. 当初始化一个类,如果其父类没有被初始化,则先初始化父类

被动引用:(不会发生类的初始化)

1. 当访问一个静态域,只有真正声明着了语的类才会被初始化(通过子类调用父类的静态变量,不会导致子类初始化,只会初始化父类)
2. 通过数组定义类引用,不会触发此类的初始化
3. 引用常量(static final)不会初始化类的初始化(常量在编译阶段就已经存入调用类的常量池中)


主动和被动引用代码实践

主动引用之(new一个类的对象)

//public类Test,主函数入口
public class Test {
	public static void main(String[] args) {
		new ClassA(); //实例化ClassA类
	}
}

//非public类
class ClassA {
	static {System.out.println("ClassA loading");}
}

//output:
//ClassA loading

主动引用之(调用类的静态成员(变量或方法) | (除了static final的常量))

public class Test {
	public static void main(String[] args) {
		String name = ClassA.name; //调用ClassA类的静态变量
	}
}

class ClassA {
	static String name = "SnailMann"; //静态变量
	static {System.out.println("ClassA loading");}
}

//output:
//ClassA loading

主动引用之(使用java.lang.reflect包的方法对类进行反射调用

public class Test {
	public static void main(String[] args) throws ClassNotFoundException {
		Class.forName("com.snailmann.learn.jvm.memory.loadclass.ClassA"); //ClassA的路径
	}
}

class ClassA {
	static {System.out.println("ClassA loading");}
}

//output:
//ClassA loading

主动引用第4条之(当虚拟机启动时,如java hello,则一定会初始化hello类,也就是先启动main方法所在的类)

//Test类
public class Test {
	//静态代码块
	static {System.out.println("Test loading");}
	
	//主函数
	public static void main(String[] args) {
	}

}

//output:
//Test loading

主动引用之(当初始化一个类,如果其父类没有被初始化,则先初始化父类)

public class Test {
	public static void main(String[] args) {
		new ClassB();  //实例化子类
	}
}

class ClassA { //父类
	static {System.out.println("ClassA loading");}
}

class ClassB extends ClassA{ //子类
	static {System.out.println("ClassB loading");}
}

//output:
//ClassA loading
//ClassB loading

被动引用之(当访问一个静态域,只有真正声明着了语的类才会被初始化(通过子类调用父类的静态变量,不会导致子类初始化,只会初始化父类

public class Test {
	public static void main(String[] args) {
		String name = ClassB.name; //子类引用父类的静态域
	}
}

class ClassA { //父类
	static String name = "abc";  //静态变量
	static {System.out.println("ClassA loading");}
}

class ClassB extends ClassA{ //子类
	static {System.out.println("ClassB loading");}
}

//output:
//ClassA loading

被动引用之 (通过数组定义类引用,不会触发此类的初始化)

public class Test {
	public static void main(String[] args) {
		ClassA[] arrays = new ClassA[10];  //ClassA数组
	}
}

class ClassA {
	static {System.out.println("ClassA loading");}
}

//output:
//nothing   //不会加载ClassA类

不会加载com.snailmann.learn.jvm.memory.loadclass.ClassA 类,既我们定义的ClassA类
而是加载 [Lcom.snailmann.learn.jvm.memory.loadclass.ClassA,这个类是虚拟机自行生成的,直接继承于Object,创建动作由字节码指令newarray触发


被动引用之 ( 引用常量(static final)不会初始化类的初始化

public class Test {
	public static void main(String[] args) {
		new ClassB(); //实例化ClassB类
	}
}

class ClassA {  //ClassA类
	final static String name = "abc"; //常量,static final修饰
	static {System.out.println("ClassA loading");}
}

class ClassB {  //ClassB类
	static String name = ClassA.name; //ClassB类中引用了ClassA类的static final成员
	static {System.out.println("ClassB loading");}

}

//output:
//ClassB loading

结果就是不会加载ClassA类,这是为什么呢?因为一个static final所修饰的变量是一个常量

  • 编译器会在编译期间只要发现别的地方有这个常量的引用,就会用其字面量去替换他。
  • 常量在编译时被放入所属类的Class文件常量池中,如有别的类调用,也会被加入其常量池中。
    (所以例如A类调用B类的静态常量,在编译期间,B类的静态常量的字面量就会被放入A类的Class文件常量池中,相当于B类的东西copy了一份放到A类中,所以在运行期间效果就相当于A类调用自己的常量一样。)

所以ClassB类调用ClassA.name实际上只是从自己(ClassB)的常量池中获取ClassA.name的值,是不会调用到ClassA的


类加载的过程


类的生命周期

在这里插入图片描述

从上图,我们可以看到类的生命周期分为7个阶段:

粗略分法:
加载 -> 连接(链接) -> 初始化 -> 使用 -> 卸载
仔细分法:
加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载


类加载的过程(5个阶段)
  • 加载阶段
  • 验证阶段
  • 准备阶段
  • 解析阶段
  • 初始化阶段

而通常验证,准备,解析三个阶段可以统称为连接阶段,既加载、连接、初始化是类加载的三个部分。

注意:

  • 加载、验证、准备、初始化和卸载这个五个阶段先后开始顺序是固定的,而解析阶段则不一定,它在某些情况下可以在完成初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。

  • 这里的开始顺序指的是每个阶段开始的时间的顺序是固定的,但并非只有一个阶段完成了,下一个阶段开开始。而是每个阶段的开始时间遵循固定的顺序,但有可能某个阶段还在流程中,它的下一个阶段就开始了(开始时间没有冲突,只是部分工作会交叉进行)


加载阶段(Loading)
书上的话

在Java阶段,虚拟机需要完成三件事:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
  • 在内存中(堆)中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口(Class<XXX>类)
我的理解
  • 虚拟机首先通过某一途径获取到某个类的二进制字节流(获取二进制字节流的途径有很多种,比如从.class文件获取,zip包获取,网络获取,代理生成等等)。
  • 然后将根据这个类的二进制字节流转换为内存方法区中这个类的运行时数据结构(定义信息),既将外部二进制字节流按照虚拟机所需要的格式存储在方法区中(也可以说是一种转换格式的行为)
  • 然后再根据这个类在方法区的数据结构在堆中生成一个它的类类型对象(Class<xxx> 对象),作用是作为这个类在方法区存储的数据的访问入口。既要访问方法区中的数据,需要通过这个Class<xxx>对象来访问

简单的总结一下就是三个阶段:

  • 获取类的二进制字节流
  • 根据二进制字节流在方法区生成该类的类信息数据
  • 再根据方法区中该类的类信息在堆中生成该类的类类型对象,java.lang.Class对象(Class<XXXX>对象)
其他补充
  • 加载阶段相对于类加载过程的其他阶段,是开发人员可控性最强的,因为类加载阶段即可以使用系统提供的引导类加载器完成,也可以由用户自定义的类加载器去完成。(既通过自定义类加载器去控制获取字节流的方式,既重写一个类加载器的loadClass()方法)

  • 数组类与正常类的加载有些不一样,数组类本身不通过类加载器创建,它是由虚拟机直接创建的。但数组类和类加载器依然有密切的关系,因为数组类的元素类型最终还是要依靠类加载器去创建。

  • 加载阶段与连接阶段的部分内存(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹杂在加载阶段中的动作,任然是属于连接阶段的内容,这两个阶段的开始时间依然保持了固定的先后顺序,只是可能由些部分会交叉进行

注意:

  • “加载”“类加载” 过程的一个阶段,不要把加载阶段和类加载搞混了
  • 注意我这里把类类型对象都当做是在堆中分配,只是为了让大家更好的理解,实际情况不一定是在堆中分配,比如HotSpot虚拟机,虽然Class对象也是对象,但是却被分配在方法区

验证阶段(Verification)
验证阶段的目的
  • 验证阶段是连接阶段额第一步
  • 验证阶段的目的是为了确保类的二进制字节流(如Class文件的字节流)中包含的信息符合当前虚拟机的要求,并且不会危害自身的安全
为什么要有验证阶段?

因为字节流是可以改变的,比如我们编译了某个类的,得到一个Class文件,我们是可以自行篡改Class文件的内容。虚拟机为了保证加载的字节码是符合虚拟机安全规范的,所以需要一个验证阶段去验证读取的字节码是否符合规格,是否会对本身造成危害

验证阶段的过程(4个验证阶段)
第一阶段(文件格式验证):

第一阶段主要是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理

  • 是否以魔数oxCAFEBABE开头
  • 主、次版本号是否在当前虚拟机处理范围之内
  • 常量池的常量是否有不被支持的常量类型
  • 指向常量的各种索引值中是否有指向不存在的常量和不符合类型的常量
  • CONSTANT_Utf8_info型常量中是否含有不符合UTF8编码的数据
  • Class文件中各个部分以及文件本身是否有被删除的或附加的其他信息

等等…

只有通过了这个阶段的验证,字节流才会进入内存的方法区中进行存储,所以后面的三个验证阶段全部都是基于方法区的存储结构进行的,不会直接操作字节流

第二阶段(元数据验证阶段)

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求

  • 这个类是否具有父类
  • 这个类的父类是否继承了不允许被继承的类
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法

…等等

第三阶段(字节码验证阶段)

第三阶段是整个验证阶段最复杂的,主要目的是通过数据流和控制流分析确定程序语义是否合法和符合逻辑。该阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
  • 保证跳转指令不会跳转到方法体以外的字节码指令上

…等等

第四阶段(符号引用验证阶段)

第四个结点也就是最后的验证阶段,该校验发生在虚拟机将符号引用转换直接引用的时候,这个转化动作将在连接的第三阶段(解析阶段)发生。目的是对类自身以外的信息进行匹配性验证

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段

…等等


准备阶段(Preparation)
什么是准备阶段?

准备阶段是正式为加载的类的类变量分配内存并设置类变量初始值的过程,这些变量所使用的内存都将在方法区中分配。

准备阶段的目的

准备阶段目的是为加载的类的类变量,既静态变量分配内存并初始化的阶段。
例如我们定义了一个静态变量static String name = "jerry",这个阶段的目的就是为name这个静态变量在方法区中分配内存,并将该内存清零(赋予初始值),如这里是name是一个引用类型,所以其内存清零的结果就是name指向了null。如果是int类型呢?那么清零的结果就是0

DataTypeDefault Vaule
byte0
short0
int0
long0L
float0.0f
double0.0d
char‘\u0000’
booleanfalse
引用型变量null

可以参考我的这篇文章:
【Java笔记】静态变量、实例变量和局部变量以及final所修饰的变量的默认初始值问题

注意:

  • 注意是为静态变量分配内存和内存清零,不是实例变量,实例变量的内存是分配在堆中的对象里头

解析阶段(Resolution)
解析阶段的目的

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

符号引用和直接引用

符号引用:

  • 符号引用是一组符号用来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧义的定位到目标即可。
  • 符号引用于虚拟机的实现的内存布局无关,引用的目标不一定已经加载到内存中。
  • 各种虚拟机实现的内存布局各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中

直接引用:

  • 直接引用可以是直接指向目标的指针地址,相对偏移量或是能间接定位到目标的句柄。
  • 直接引用是于虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译的直接引用一般不会相同。
  • 如果有了直接引用,那引用的目标必定是已经在内存中存在的

初始化阶段(Initialization)

类初始阶段是类加载过程中最后一步,前面的类加载过程中,除了在加载阶段用户程序应用可以通过自定义类加载器参与之外,其余动作都是由虚拟机主导和控制的。到了初始化阶段才开始真正执行类中定义的Java程序代码

初始化阶段的目的

程序员通过程序制定的主观计划(代码)去初始化类变量和其他资源。换个角度就是执行类构造器<clinit>()的过程

简而言之就是根据程序员的代码去做指定的初始化

准备阶段的初始化和初始化阶段的初始化的区别
  • 准备阶段的初始化是仅仅是为类的静态变量分配内存空间,并且将内存清零,既赋予初始值
  • 初始化阶段的初始化则是根据程序员的要求去初始化

例子:
比如static String name = “jerry”;这一段话,准备阶段和初始化阶段分别做了什么?

  • 在准备阶段为name分配了空间并赋予初始值,既name = null。
  • 在初始化阶段,虚拟机根据程序员的主观要求初始化,name = “jerry”;所以name 指向了 "jerry"字面量
类构造器 - <clinit>()方法
  • 类构造器<clinit>()是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的
  • 类构造器(<clinit>())与类的构造函数(实例构造器<init>())不同,类构造器不需要显式的调用父类构造器,虚拟机会保证在子类的类构造器调用之前,父类的类构造器已经执行完毕。因此在虚拟机第一个被执行类构造器的类肯定是Object.
  • 由于父类的类构造器先执行,也就意味着父类的静态域赋值操作也要优先于子类的赋值操作
  • 类构造器(<clinit>())对于类或接口来说并不是必需的,如果一个类中没有静态代码块和对类变量的赋值操作,那么编译器可以不为这个类生成类构造器(<clinit>()方法)
  • 因为接口不能使用静态代码块,但任然有类变量的赋值操作,所以接口也有类构造器。但跟类不同的是,接口的类构造器不需要先执行于其父接口的构造器。只有当父接口中定义的变量被使用时,父接口才会开始初始化,调用父接口构造器。所以接口的实现类在初始化时也不会要其实现的接口的<clinit>()方法优先执行。除非使用到了接口的静态变量。
  • 虚拟机会保证一个类的<clinit>()方法在多线程中能被正确的加锁,同步。既如果多个线程同时执行某个类的类构造器,那么只会有一个线程去执行这个类的类构造器,其他线程会进入阻塞阶段。
通俗的小结一下

总之呢,初始化阶段就是虚拟机执行类的静态语句的阶段。 比如静态变量的赋值操作,还有静态代码块里面的语句~

注意:

需要注意的一个问题是,静态变量和静态代码块的执行顺序就是依赖程序员实现的顺序。但有一个例外就是。

public class Test{
	
	static {
		i = 2;                  //success
		System.out.println(i);  //error
	}
	static int  i = 1;
	
}

虽然静态变量的定义在静态代码块之后,但是静态代码块依然可以使用到后面定义的静态变量,这是为什么呢?

  • 因为在准备阶段的时候,这个int i 已经别分配了内存,并且赋予了0值。此时的int i = 0
  • 然后到了初始化阶段,虽然定义int i 变量的操作在代码块的后面,实际上i这个变量已经分配好内存了。所以 i 可以被赋值,又再一次的赋予了0值,此时的i = 2
  • 然后最后才执行int i = 1的赋值操作,此时的i = 1

而为什么不能输出i呢?emmm,这个我就暂时没想到了。总之总结起来就是静态变量的定义如果在静态代码块之后,那么静态代码块只能用来进行赋值操作,但不能访问它


类加载过程的小结
  • 加载、验证、准备、初始化四个阶段的开始顺序是固定的,但是仅仅是开始时间有顺序,不需要执行完一个才开始另一个,而是开始时间有顺序,运行过程交叉运行

  • 解析阶段有可能在初始化阶段之后运行

  • 加载阶段是加载字节码到内存方法区的过程,验证几乎是贯穿整个加载过程的验证行为,准备是为静态变量分配内存和清零的过程,初始化是执行静态域语句的过程


参考资料


  • 《深入理解Java虚拟机》
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值