java虚拟机的类加载机制
public class Singleton { private static Singleton singleton = new Singleton(); public static int count_1; public static int count_2 = 0; static { count_1++; count_2++; } private Singleton() { count_1++; count_2++; } public static Singleton getInstance() { return singleton; } public static void main(String[] args) { System.out.println(Singleton.count_1); System.out.println(Singleton.count_2); } } 输出:2 1
Singleton单例模式的类,两个静态变量,在静态代码块中对两个静态变量做自增运算,在私有构造方法中,再对这两个静态变量做自增运算,最后打印出来的结果是多少呢?正确答案不是2和2,而是2和1。我们带着这个问题去分析虚拟机是如何加载一个类的。
类的生命周期

整个生命周期包括:加载、连接(验证、准备、解析)、初始化、使用和卸载7个阶段。其中加载、验证、准备、初始化和卸载这5个阶段的顺序是确定,类的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定:再某些情况下可以在初始化阶段之后再开始。
加载
在加载阶段,java虚拟机需要完成以下3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中(堆中)生成一个代表这个类的java.lang.Class对象,用来封装类在方法区里的数据结构,作为方法区中这个类的各种数据的访问入口
验证
验证为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
一般是通过java文件编译生成的class文件,这是没有什么问题的,但是class文件并不一定要求用java源码编译而来,可以使用很多其它的途径,比如用十六进制编辑器直接编写来产生class文件。虚拟机如果不检查输入的字节流,对其完全信任的话,可能就会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机的一项重要的工作。
准备
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段
类变量:即被static修饰的静态变量
初始值:指的是该数据类型所对应的“零值”
准备阶段是为静态变量分配内存,并且对其初始化为零值。不包含用final修饰的static,final在编译的时候就会分配,不包括静态代码块和实例变量,静态代码块在下面初始化阶段执行,实例变量会在对象实例化的时候随着对象一起分到到java堆中。
public static int value = 123;
准备阶段,value的值为0,并非123!如果是boolean型数据,则为false
基本数据类型对于类变量(static)和全局变量,不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,对于局部变量来说,使用前必须显式地为其赋值,否则编译时不通过
对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值
如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
假设上面的类变量value被定义为: public static final int value = 3;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用和直接引用有什么关联呢?
解析阶段可能开始于初始化之前,也可能在初始化之后,虚拟机会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四种常量类型。
1、类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
2、字段解析:对字段进行解析时,先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束,查找流程如下图所示:

从下面一段代码的执行结果中很容易看出来字段解析的搜索顺序:
class Super{ public static int m = 11; static{ System.out.println("执行了super类静态语句块"); } } class Father extends Super{ public static int m = 33; static{ System.out.println("执行了父类静态语句块"); } } class Child extends Father{ static{ System.out.println("执行了子类静态语句块"); } } public class StaticTest{ public static void main(String[] args){ System.out.println(Child.m); } }
执行结果如下:
执行了super类静态语句块
执行了父类静态语句块
33
如果注释掉Father类中对m定义的那一行,则输出结果如下:
执行了super类静态语句块
11
另外,很明显这就是上篇博文中的第1个例子的情况,可以分析如下:static变量发生在静态解析阶段,也即是初始化之前,此时已经将字段的符号引用转化为了内存引用,也便将它与对应的类关联在了一起,由于在子类中没有查找到与m相匹配的字段,那么m便不会与子类关联在一起,因此并不会触发子类的初始化。
最后需要注意:理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译。如果对上面的代码做些修改,将Super改为接口,并将Child类继承Father类且实现Super接口,那么在编译时会报出如下错误:
StaticTest.java:24: 对 m 的引用不明确,Father 中的 变量 m 和 Super 中的 变量 m
都匹配
System.out.println(Child.m);
^
符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局是无关的,
引用的目标不一定已经加载到内存中。
直接引用:指的是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,引用的目标必定已经在内存中存在。
初始化
初始化是类加载过程的最后一步,在前面的过程中,除了第一步加载阶段用户可以通过指定自定义类加载器参与外,其余过程完全是由虚拟机自己主导控制的。到了初始化阶段,才真正开始执行类中定义的java程序代码了(或者说是字节码)。
由上面分析可知,在准备阶段,静态变量已经赋过一次值了,只不过是系统要求的初始值而已,而在初始化阶段,为类的静态变量赋予程序中指定的初始值,还有执行静态代码块中的程序。
关于类的初始化这个阶段,可以再分析的深入一点,刚刚说初始化的阶段是为类的静态变量赋实际值的阶段,我们也可以从另外的一个角度去表达:初始化阶段是执行类构造器方法(注意:不是我们平时说的类的构造方法)的过程,构造器方法是<cinit>()方法,它是由编译器自动收集类中所有的静态变量的赋值动作和静态代码块中的语句合并产生的,所以也就清楚了,为啥初始化阶段也可以叫做类构造器方法执行的过程。
<clinit>()方法的执行规则:
1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口与类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
下面给出一个简单的例子,以便更清晰地说明如上规则:
class Father{ public static int a = 1; static{ a = 2; } } class Child extends Father{ public static int b = a; } public class ClinitTest{ public static void main(String[] args){ System.out.println(Child.b); } }
执行上面的代码,会打印出2,也就是说b的值被赋为了2。
我们来看得到该结果的步骤。首先在准备阶段为类变量分配内存并设置类变量初始值,这样A和B均被赋值为默认值0,而后再在调用<clinit>()方法时给他们赋予程序中指定的值。当我们调用Child.b时,触发Child的<clinit>()方法,根据规则2,在此之前,要先执行完其父类Father的<clinit>()方法,又根据规则1,在执行<clinit>()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Father的<clinit>()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,而后再执行Child类的<clinit>()方法,这样便会将b的赋值为2.
需要注意的是,编译器收集的顺序是由语句在程序中出现的顺序所决定的,静态代码块中只能访问到定义在静态代码块之前的变量,定义在它之后的变量,在前面的静态代码块中可以赋值,但是不能访问。可以举个例子:
public class Test { static { i = 0; //给变量赋值可以正常通过编译 System.out.print(i); //但是不能访问,这句编译会提示非法向前引用 } static int i = 1;
<cinit>()方法与类的构造函数不同,它不需要显示的调用父类的构造器,虚拟机会保证在子类的<cinit>()方法执行前,父类的<cinit>()方法已经执行完毕,所以在虚拟机中第一个被执行的<cinit>()方法的类肯定是java.lang.Object。
由于父类的<cinit>()方法先执行,也就意味着父类中定义的静态代码块要优先于子类的静态变量赋值操作,看一个例子:
//演示虚拟机<cinit>方法执行的过程 public class CinitMethod { static class Parent{ public static int A = 1; static { A = 2; } } static class Sub extends Parent { public static int B = A; } public static void main(String[] args) { System.out.println(Sub.B); } }
这段程序中,在准备阶段,先将A赋为0,B赋为0,在初始化阶段,先执行父类的<cinit>()方法,所以会执行A=1;然后A=2,然后执行子类的<cinit>()方法,执行B=A,所以打印出来是2。
虚拟机会保证一个类的<cinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<cinit>()方法,其它线程都需要阻塞等待,直到活动线程执行完该方法。
到这里,一个类的加载过程就算完毕了,类加载的最终产品是位于堆中的Class对象,封装了类在方法区内的数据结构,并向java程序员提供了访问方法区内数据结构的接口。所以程序员就可以使用可以使用这个类去获取与该类相关的信息了。
要注意的是,这是类加载完毕了,跟类的对象是没有关系的,到目前只能使用类的静态变量和静态方法,类的对象需要我们去产生的,有了对象才能操作其中的普通成员变量和方法。
在准备阶段,java虚拟机将Singleton赋为空,count_1和count_2赋为0(count_2赋为0不是程序中赋的0,是int的默认值)。 在初始化阶段,java虚拟机按照顺序执行static代码, 首先实例化Singleton,执行构造方法中的代码,count_1和cout_2变成1; 然后按顺序执行static代码,count_1没有赋值,还是1,count_2被赋值为0; 最后执行静态代码块中的代码,count_1和count_2各自增1,所以count_1=2,count_2=1。
虚拟机规范严格规定了有且只有四种情况必须立即对类进行初始化:
遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的Java代码场景是:使用new关键字实例化对象时、读取或设置一个类的静态字段(static)时(被static修饰又被final修饰的,已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法时。
使用Java.lang.refect包的方法对类进行反射调用时,如果类还没有进行过初始化,则需要先触发其初始化。
当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。
当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先执行该主类。
虚拟机规定只有这四种情况才会触发类的初始化,称为对一个类进行主动引用,除此之外所有引用类的方式都不会触发其初始化,称为被动引用。下面举一些例子来说明被动引用。
1、通过子类引用父类中的静态字段,这时对子类的引用为被动引用,因此不会初始化子类,只会初始化父类
class Father{ public static int m = 33; static{ System.out.println("父类被初始化"); } } class Child extends Father{ static{ System.out.println("子类被初始化"); } } public class StaticTest{ public static void main(String[] args){ System.out.println(Child.m); } }
父类被初始化
33
常量在编译阶段会存入调用它的类的常量池中,本质上没有直接引用到定义该常量的类,因此不会触发定义常量的类的初始化
class Const{ public static final String NAME = "我是常量"; static{ System.out.println("初始化Const类"); } } public class FinalTest{ public static void main(String[] args){ System.out.println(Const.NAME); } }
执行后输出的结果如下:
我是常量
通过数组定义来引用类,不会触发类的初始化
class Const{ static{ System.out.println("初始化Const类"); } } public class ArrayTest{ public static void main(String[] args){ Const[] con = new Const[5]; } }
执行后不输出任何信息,说明Const类并没有被初始化。
接口也有初始化过程,上面的代码中我们都是用静态语句块来输出初始化信息的,而在接口中不能使用“static{}”语句块,但编译器仍然会为接口生成<clinit>类构造器,用于初始化接口中定义的成员变量(实际上是static final修饰的全局常量)。
二者在初始化时最主要的区别是:当一个类在初始化时,要求其父类全部已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量),才会初始化该父接口。这点也与类初始化的情况很不同,回过头来看第2个例子就知道,调用类中的static final常量时并不会 触发该类的初始化,但是调用接口中的static final常量时便会触发该接口的初始化。
参考:
https://blog.csdn.net/eson_15/article/details/51755023
https://blog.csdn.net/javazejian/article/details/73413292(推荐)
浙公网安备 33010602011771号