Java中ClassLoader(抽象类)的主要职责就是负责将各种class文件加载到JVM中,生成这个类的各种数据结构,然后分布到JVM对应的内存区域中。
类的加载过程一般分为三个阶段:加载阶段、连接阶段(验证、准备、解析)、初始化阶段。
1. 加载阶段:主要负责查找并且加载类的二进制数据文件(class文件)
2. 连接阶段:连接阶段做的比较多,细分为三个阶段:
3. 初始化阶段:为类的静态变量赋予正确的初始值(代码编写阶段赋予的值)
JVM虚拟机规范规定了每个类或者接口被Java程序首次主动使用才会对其初始化,当然随着JIT技术成熟,不排除JVM在运行期间提前预判初始化某各类。
JVM同时规范了6中主动使用类的场景,具体如下:
//父类
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中情况,其余的都称为被动使用,不会导致类的加载和初始化,关于类的主动和被动有几个易混淆的地方:
首先先看一个案例,进入类是如何一步一步加载的:
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)验证文件格式:
当然了,jvm对class字节流的验证远不止如此,由于获得class字节流的来源各种各样,甚至可以根据jvm规范编写一个二进制字节流,对文件的格式验证可以在class被初始化之前将一些不符合规范的、恶意的字节拒之门外,文件格式的验证相当于先锋关卡。
(2)元数据的验证:
(3)字节码验证:
经过了问价格式验证和元数据验证还会对字节码进行验证,该部分比较复杂,主要验证的是程序的控制流程比如循环、分支等
(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这四种类型常量
类接口解析:
字段的解析:
所谓的字段解析就是你访问类或者接口中的字段,在解析类和或者变量的时候,如果该字段不存在或者出现错误,就会抛出异常不在进行下面的解析
这样就解释了子类为什么重写了父类的字段后能够生效的原因了,因为找到子类的字段就直接初始化并返回了(自下而上查找:自身有就不往上继续查找了)
类方法的解析:类方法和接口方法有所不同,类方法可以直接使用该类进行调用,而接口方法必须要有相应的继承类才能够调用
在查找过程中也出现了大量的检查和验证。
接口方法的解析:接口不仅可以定义方法,还可以继承其它接口
经过重重关卡终于来到了类的初始化阶段,类的初始化阶段是整个类加载过程中最后一个阶段,在初始化阶段做的最主要的一件事情就是执行了
public class StaticTest {
static {
System.out.println (x);//x = 20
}
private static int x = 10;
}
另外
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,因为父类的
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保证了
好的,再来回顾上面遗留的问题?在连接阶段准备过程中,为每一个类变量赋予相应的初始值(内存存于方法区)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