观前提示,本文需要对JVM中java内存有一定认识。
一、什么是类加载?
运行在Java虚拟机之上的语言,比如Java、Scala、Groovy、JRuby等,会被各自的编辑器编译为Class文件,这些Class文件需要被加载进Java虚拟机才能运行。
而一个完整的类,其生命周期是:加载,验证,准备,解析,初始化,使用,卸载。如图:
其中,验证,准备,解析合称为链接。而因为(正常情况下)加载,链接,初始化这三步是连续进行的,又被成为类加载或类初始化。所以,注意加载与类加载的区别。
二、类加载过程
1.加载
- 通过一个类的的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 将java堆内存中生成一个代表这个类的java.lang.Class对象,作为方法区的各种数据的访问入口。
2.链接
将Java类的二进制代码合并到JVM的运行状态之中的过程。
2.1验证
目的 :确保加载的类信息符合JVM规范,没有安全方面的问题。
验证阶段是Java非常重要的一个阶段,它会直接的保证应用是否会被恶意入侵的一道重要的防线,越是严谨的验证机制越安全。验证的目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。
分类:其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
文件格式验证:主要验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理。
例如:主,次版本号是否在当前虚拟机处理的范围之内。常量池中是否有不被支持的常量类型。
指向常量的中的索引值是否存在不存在的常量或不符合类型的常量。
元数据验证:对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。
字节码验证:最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。
主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。
符号引用验证:主要是针对符号引用转换为直接引用的时候,是会延伸到第三解析阶段,
主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。
2.2准备
准备阶段是正式为类中定义的变量(静态变量)分配内存并设置变量初始值的阶段
注意:虽然从概念上来说,这些类变量使用的内存应该在方法区中分配,但是方法区本身是一个逻辑上的区域。在JDK8之后,类变量会随着Class对象一起存放在java堆里。这里图片暂时画在方法区里
仍然需要注意的是:假设一个类变量定义是 public static int i = 123;
它在准备阶段被赋初值时,是该数据类型的零值。 而赋值为123,是putstatic指令程序被编译之后,存放于类构造器< clinit >()方法之中。即类的初始化阶段才赋值为123.
当然特殊情况: public static final int i = 123;
i成为了ConstantValue属性值,常量。就直接在准备阶段设置为123了。
2.3解析
虚拟机常量池内的符号引用(常量名)替换为直接引用(地址)的过程。
符号引用
符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,
只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。
直接引用
直接引用可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
直接引用是和虚拟机实现的布局内存有关,同一个符号引用在不同虚拟机示例上翻译出来的直接引用一般不同。
如果有了直接引用,那引用的目标必定已经在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型方法句柄和调用点限定符7类符号引用进行。
3.初始化
- 执行类构造器< clinit >()方法的过程,
类构造器< clinit >()方法是由编译期自动收集类中所有类变量的复制动作会和静态代码块中的语句合并产生的。(类构造器是构造类信息的,不是构造该类对象的构造器) - 当初始化一个类的时候,如果发现其父类还没有初始化,则先需要初始化其父类。
- 虚拟机会保证一个类的< clinit >()方法在多线程环境下被正确加锁和同步。
解读:
< clinit >()方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的一个方法。
编辑器的收集顺序由源文件顺序决定。 这也是为什么静态语句块中只能访问到定义在静态语句块以前的变量。定义在静态语句块之后的变量,可以在静态语句块中赋值,但是不能访问。
虚拟机会保证在()方法执行之前,其父类的()已经执行完毕。所以虚拟机中第一个被执行的()肯定是java.lang.Object。
由于父类的()先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
public class Test {
public static void main(String[] args) {
B b = new B();
System.out.println("A.num:"+A.num);
System.out.println("B.bNum:"+B.bNum);
}
}
class A{
public static int num = 1;
static {
System.out.println("这里执行父类静态块");
num = 10;
}
public A() {
System.out.println("这里进行父类构造方法");
}
}
class B extends A{
static {
bNum = 100;
System.out.println("这里执行子类静态块");
}
public static int bNum = num;
public B() {
System.out.println("这里进行子类构造方法");
}
}
结果如图:
子类B的静态块,显示执行时,证明bNum已经被赋值为100,之后,其静态变量又对bNum赋值,且用的是其父类A的静态成员num的值,最后输出bNum为10,证明静态块,静态变量的执行,是按照源文件顺序来的。。
显示来看,
- 静态块的执行早于构造方法。
- 初始一个子类时,如果父类为初始化,一定要先初始化一下其父类。
- 静态块,静态变量的执行,是按照源文件顺序来的。
三、结合内存来理解类初始化
执行main方法,出现一个new T1时,new就说明向堆申请一块空间(如箭头1所示),用于实例化对象。但是有空间该怎么实例化呢?这是通过java.lang.Class(如箭头2所示)去获得如何类实例化具体内容。在类对象找到了之后,就真的开始在实例化了(如箭头3所示)。这时一个new 出来的实例就出现了。
再看,实例T1,实例T2,是申请不同的空间,这里就解释了 T1.a 和 T2.a不是同一变量。