类的加载过程:
Java中ClassLoader(抽象类)的主要职责就是负责将各种class文件加载到JVM中,生成这个类的各种数据结构,然后分布到JVM对应的内存区域中。
类的加载过程一般分为三个阶段:加载阶段、连接阶段(验证、准备、解析)、初始化阶段。
1. 加载阶段:主要负责查找并且加载类的二进制数据文件(class文件)
2. 连接阶段:连接阶段做的比较多,细分为三个阶段:
- 验证:主要确保文件的正确性,比如class的版本,class的魔术因子是否正确
- 准备:为类的静态变量分配内存,并且为其初始化默认值
- 解析:把类中的符号引用转换为直接引用
3. 初始化阶段:为类的静态变量赋予正确的初始值(代码编写阶段赋予的值)
类的主动使用和被读使用:
JVM虚拟机规范规定了每个类或者接口被Java程序首次主动使用才会对其初始化,当然随着JIT技术成熟,不排除JVM在运行期间提前预判初始化某各类。
JVM同时规范了6中主动使用类的场景,具体如下:
- 通过new关键字导致类的初始化
- 访问静态变量
- 访问类的静态方法
- 对类进行反射操作
- 初始化子类导致父类也初始化(子类.父类属性不会导致子类初始化)
- 启动类 执行main函数
//父类
public class demo2 {
public static int num = 20;
static{
System.out.println ("我是静态代码块demo2");
}
static void test(){
System.out.println ("我是静态方法demo2");
}
}
//子类
public class demo2_son extends demo2{
public static int num_son;
public demo2_son() {
System.out.println ("demo2_son的构造方法");
}
static {
System.out.println ("demo2_son的静态代码块");
}
}
//测试类
public class test {
public static void main(String[] args) {
//2:访问类的静态变量导致类的初始化
System.out.println (demo2.num);//我是静态代码块demo2 20
//3:访问类的静态方法导致类的初始化
demo2.test();//我是静态代码块demo2 我是静态方法demo2
//4:对某个类进行反射操作
Class.forName ("AtomicIntegerTest.demo2");//我是静态代码块demo2
//初始化子类导致父类初始化
System.out.println (demo2_son.num_son);//我是静态代码块demo2 demo2_son的静态代码 0
System.out.println (demo2_son.num);//我是静态代码块demo2 20 不会导致父类的初始化
//6:启动类:执行main喊函数所在类
}
}
除了上述6中情况,其余的都称为被动使用,不会导致类的加载和初始化,关于类的主动和被动有几个易混淆的地方:
- 构造某个类的数组不会导致类的初始化: Simple[] sp = new Simple[10];不要因为关键字new就以为主动使用,只不过是在堆内存开辟了一段连续地址空间4bytex10
- 引用类的静态常量不会导致类的初始化: 如果把demo2_son中的num改为静态常量,在其他类中直接访问此类不会被加载(父类肯肯定也不会)
首先先看一个案例,进入类是如何一步一步加载的:
public class Singleton {
private static int x = 0;
private static int y;
private static Singleton singleton = new Singleton ();//此处移到最上面,结果又如何?
private Singleton(){
x++;
y++;
System.out.println ("构造");
}
public static Singleton getInstance(){
return singleton;
}
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance ();
System.out.println (singleton.x);
System.out.println (singleton.y);
}
}
结果何如,为啥不一样?
类的加载阶段:
简单来说类的加载就是将class文件的二进制数据读取到内存中,然后将该字节流所代表的静态存储结构转为方法区中运行时的数据结构,并在堆内存生成一个该类的java.lang.Class对象,最为方法访问的数据结构入口,如下图
类的加载最终产物就是堆内存的class对象,对同一个ClassLoder来讲。不管某个类加载过多少次,对应的堆内存class对象始终是一个,而且我们这里讲的是类加载的第一阶段,并不代表整个类加载完成。
类的连接阶段:
①验证:
验证在连接阶段的主要目的是确保class文件的字节流信息所包含的内容符合当前jvm的规范要求,当字节流的信息不符合要求时则会抛出VerifyError异常或其子类异常,验证信息内容有以下几方面
(1)验证文件格式:
- 在很多二进制文件中,文件头部存在魔术因子,该因子决定了文件属于何种类型,class文件件的魔术因子:0xCAFEBABE
- 主版本号,java的版本号在不断升级,jvm的规范同样在不断说升级,比如你用好版本的jdk编译的class就不能被低版本的jvm所兼容。在验证过程中会查看当前的class文件版本是否符合当前jdk所处理的范围
- 构成class文件的字节流是否存在残缺或者其他附加信息,主要看class的MD5指纹(每一个类在编译阶段经过MD5摘要算法后都会将结果一并给class字节流,作为字节流的一部分)
- 常量池的常量是否存在不被支持的变量类型如int64
- 指向常量中的引用是否指到了不存在的常量或者改常量的类型不被支持
当然了,jvm对class字节流的验证远不止如此,由于获得class字节流的来源各种各样,甚至可以根据jvm规范编写一个二进制字节流,对文件的格式验证可以在class被初始化之前将一些不符合规范的、恶意的字节拒之门外,文件格式的验证相当于先锋关卡。
(2)元数据的验证:
- 检查这个类是否存在父类,是否继承了某个接口,这些父类和接口是否合法或者是否真实存在。
- 检查该类是否继承了被final修饰的类,被final修饰的类是不允许被继承并且其中的方法是不允许被override的
- 检查该类是否为抽象类,如果不是抽象类,那么它是否实现了父类的抽象方法或者接口中所有的方法
- 检查方法重载的合法性,比如相同的方法名称、相同的参数,但是返回类型不同这都是不允许的
- 其它语法验证
(3)字节码验证:
经过了问价格式验证和元数据验证还会对字节码进行验证,该部分比较复杂,主要验证的是程序的控制流程比如循环、分支等
- 保证当前线程在程序计数器的指令不会跳转到不合法的字节码指令中去
- 保证类型的转换时合法的,比如用A声明的引用,不能用B进行强制类型转化(转换异常)
- 保证任意时刻虚拟机栈类型与指令代码都能正确的被执行,比如在压栈的时候传入的是A类型的引用,在使用的时候却将B类型载入到了本地变量表
- 其它验证
(4)符号引用验证
我们说过在类的加载过程中,有些阶段是可以交叉运行的,比如早加载阶段尚未结束之前,连接阶段可能开始工作了,这样做的好处是提高效率,同样符号的引用转换主要是验证符号引用转换为直接引用时的合法性
- 通过符号引用描述的字符串限定名称是否能够找到相关的类
- 符号引用中的类、字段、方法是否对当前类可见,比如不能访问引用类的私有方法
- 其它
符号引用验证的目的是保证解析动作的顺利执行,比如如果某各类的字段不存在。则会抛出NoSuchFileError,若方法不存在则爬出NoSuchMethodError等,我们在反射的时候会遇到这样的信息
②准备
当一个class的字节流通过了所有验证过程之后,就开始为该类对象的类变量(静态变量)分配内存,并且设置了初始值,类变量的内存会分配到方法区,不同于实际变量会分配到堆内存之中,所谓设置初始值,其实就是为相应的类变量给定一个相关类型在没有设置值时的默认值,不同数据类型的初始值见表:
数据类型 |
|
Byte |
(byte)0 |
Char |
‘\u0000’ |
Short |
(short)0 |
Int |
0 |
Float |
0.0F |
Long |
0L |
Boolearn |
false |
引用类型 |
null |
为类变量设置初始值的代码如下:public class Demo{private static int a = 10;private static final int b = 20;}其中a在准备阶段是0而不是10,而b在准备阶段是20,因为静态常量不会导致类的初始化,是一种被动引用,因此你也就不存在连接阶段了,当然更严谨的说法是b在类编译阶段会将其生成的value生成ConstValue属性直接赋予20
③解析
在类的解析过程中照样会交叉用到一些验证的过程,比如符号验证,所谓解析就是在常量池中寻找类、接口、字段、和方法的符号引用,并且将这些符号引用替换成直接引用的过程:
public class Test{
static Simple simple = new Simple();
public static void main(String[] args){
System.out.println(simple);
}
}
上面代码中用到了Simple类,我们编写程序的时候可以直接使用simple这个引用去访问它可见的方法和属性,但是在class字节码中可不是这样简单,它会被编译成相应的助记符,这些助记符成为符号引用,在类的解析过程中,助记符还需要进一步解析才能正确的找到堆内存中的Simple数据结构。下面是一段Test的字节码信息片段:
在常量池中通过getstatic指令获取PringtStrean和Simple,然后通过invokvirtual指令传递给PrintStream的println方法,在字节码的执行过程中,getstatic被执行之前需要进行解析。虚拟机规定了,anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokevirtual、multianewarray、new、putfield、putstatic这13个操作符号引用的字节码之前,必须对所有的符号提前进行解析。解析过程主要针对类接口、字段、类方法和接口方法这四类进行,分别对应到常量池中的CONSTANT_Class_info、CONSTANT_Field_info、Constant_Methodref_info和Constant_InterfaceMethodred_info这四种类型常量
类接口解析:
- 假设前文代码中的Simple不是一个数组类型,则在加载过程中,需要先完成对Simple类的加载,同样需要经历所有的类加载阶段
- 如果simple是一个数组类型,则虚拟机不需要完成对simple的加载,只需要在虚拟机生成一个能代表该类型的数据对象,并且在堆内存中开辟一块连续的地址空间
- 类接口解析完成后,还需要对符号引用的验证
字段的解析:
所谓的字段解析就是你访问类或者接口中的字段,在解析类和或者变量的时候,如果该字段不存在或者出现错误,就会抛出异常不在进行下面的解析
- 如果simple类本身包含某个字段,则直接返回这个字段的的引用,当然也要对该字段所属的类提前提前进行类加载
- 如果simple类本身包不含某个字段,则会根据继承关系自下而上查找父类或者接口的字段,找到返回即可,同样需要提前对找到的字段进行类加载过程
- 如果Simple类中没有字段,一直找到Object还是没有,则表示查找失败了,也就不再进行任何解析,直接抛出NoSuchFidldError异常(反射会遇到)
这样就解释了子类为什么重写了父类的字段后能够生效的原因了,因为找到子类的字段就直接初始化并返回了(自下而上查找:自身有就不往上继续查找了)
类方法的解析:类方法和接口方法有所不同,类方法可以直接使用该类进行调用,而接口方法必须要有相应的继承类才能够调用
- 若在类方法表中发现class_index中索引的Simple是一个接口而不是一个类,则直接返回错误。
- 在Simple类中查找是否有方法描述和目标方法完全一致的方法,有则直接返回这个方法的引用,无则往上继续查找。
- 如果父类中仍然没有找到,则意味着查找失败,程序抛出NoSuchMethodError异常。
- 如果当前类或者父类中找到了和目标方法一致的方法,但是它是一个抽象方法,则抛出AbstractMethodError异常。
在查找过程中也出现了大量的检查和验证。
接口方法的解析:接口不仅可以定义方法,还可以继承其它接口
- 在接口方法中发现class_index中索引的simple是一个类而不是一个接口,则会直接返回错误,因为方法接口表和类接口表所容纳的类型应该是不一样的,这也是为什么在常量池中必须要有Constant_Methodef_info和Constant_InterfaceMethodred_info两个不同的类型
- 接下来的查找方法就和方法解析比较类似了,自下而上查找,或者没找到则抛出NosuchMethodError异常
类的初始化:
经过重重关卡终于来到了类的初始化阶段,类的初始化阶段是整个类加载过程中最后一个阶段,在初始化阶段做的最主要的一件事情就是执行了<clinit>()方法的过程,其字面含义就是class initialize,在<clinit>()方法中所有的类变量都会被赋予正确的值,也就是在程序编写的时候指定的值。
<clinit>()方法是在编译阶段生成的,也就是说它已经包含在class文件中了,此方法包含了所有类变量的赋值动作和静态代码块的执行代码,编译器收集的顺序由执行语句在源文件中出现顺序所决定的(<clinit>()方法能够保证顺序性),另外需要注意一点就是静态语句块只能对后面的静态变量进行赋值,但是不能进行对其访问。否则编译无法通过,只负责赋值和执行静态代码块
public class StaticTest {
static {
System.out.println (x);//x = 20
}
private static int x = 10;
}
另外<clinit>()方法与类的构造方法有所不同,它不需要显示调用父类的构造器,虚拟机会保证父类的<clinit>()方法最先执行,因此父类的静态变量总是能够得到优先赋值
public class StaticTest {
//父类中定义类变量
static class Parent{
static int value = 1;
static{
value = 2;
System.out.println ("父类代码块");
}
}
//子类使用父类的静态变量为自己的静态变量赋值
static class Child extends Parent{
static int val = value;
}
public static void main(String[] args) {
System.out.println (Child.val);//2
}
}
上面程序输出为2,而不是1,因为父类的<clinit>()方法优先得到了执行,虽然说Java编译器会帮助class生成<clinit>()方法,但是该方法并不是总会生成,比如某类中没有静态代码块和静态变量,那么他就不会生成此方法,接口也同样如此,由于接口天生不能定义静态代码块,只有当接口中有变量的初始化操作时才能生成此方法。
<clinit>()方法虽然真是存在,但是只能够被虚拟机执行,在主动使用出发了类的初始化之后就会调用这个方法,如果有多个线程访问这个方法,那么会不会引起线程安全问题呢?
public class ClassInit {
static{
try {
System.out.println ("初始化");
TimeUnit.SECONDS.sleep (5);
} catch (InterruptedException e) {
e.printStackTrace ();
}
}
public static void main(String[] args) {
IntStream.range (0,5).forEach (value->new Thread (ClassInit::new).start ());
}
}
运行上面代码你会发现同一个时间只有一个线程执行到静态代码块的内容,并且静态代码块仅仅会被执行一次,JVM保证了<clinit>()在多线程环境下的同步语义。
好的,再来回顾上面遗留的问题?在连接阶段准备过程中,为每一个类变量赋予相应的初始值(内存存于方法区)x = 0 y = 0 singleton = null;跳过连接的解析过程,看一下初始化阶段,这里需要注意,在本类中调用main触发了此类的初始化操作,然后为每一个类变量赋予正确的值 x = 0 y = 0 singleton = new Singleton(),然后 new Singleton()触发了构造,导致自增,这其实是第二次初始化,然后再main函数中有调用了类.静态方法。第三次初始化。那么三次初始化意味着三次调用了clinit方法呢?如果是则输出为3了,但输出为1,1说明这个类只被初始化一次了。然而放在上面依然,先执行准备阶段为每个值赋予默认初始值,初始化阶段,由于clinit的顺序性,先进入构造方法自增,然后为x初始化赋值,而后y由于没有给定初始值,在构造函数中所得到的值就是所谓的正确赋值(clinit执行代码块个给类变量赋值),所以结果又变为0,1