本文共 9568 字,大约阅读时间需要 31 分钟。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们开始的顺序如下图所示:
其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
这里简要说明下Java中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对java来说,绑定分为静态绑定和动态绑定:
注意,这里第1条中的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在内存中实例化一个java.lang.Class类的对象(并没有明确在堆中,对于HotSpot而言,Class对象比较特殊,它放在方法区里面),这样便可以通过该对象访问方法区中的这些数据。
验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
假设一个类变量的定义为:
public static int value = 3;那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器< clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
假设上面的类变量value被定义为:
public static final int value = 3;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中。
下表列出了Java中所有基本数据类型以及reference类型的默认零值:
这里还需要注意如下几点:
解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。
初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器< clinit>()方法的过程。
这里简单说明下< clinit>()方法的执行规则:
1、< clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。 2、< clinit>()方法与实例构造器< init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的< clinit>()方法执行之前,父类的< clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的< clinit>()方法的类肯定是java.lang.Object。 3、< clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成< clinit>()方法。 4、(忽略!!)接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作(实践发现不会有< clinit>方法),因此接口与类一样会生成< 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.如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则1,执行Father的< clinit>()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int a = 1;”语句。
另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。
虚拟机规范严格规定了有且只有5中情况(jdk1.7)必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
例1:
/** * 被动使用类字段 * 通过子类引用父类的静态字段,不会导致子类初始化 */public class SSClass{ static { System.out.println("SSClass"); }} public class SuperClass extends SSClass{ static { System.out.println("SuperClass init!"); } public static int value = 123; public SuperClass() { System.out.println("init SuperClass"); }}public class SubClass extends SuperClass{ static { System.out.println("SubClass init"); } static int a; public SubClass() { System.out.println("init SubClass"); }}public class NotInitialization{ public static void main(String[] args) { System.out.println(SubClass.value); //非主动使用类字段 }}
运行结果:
SSClassSuperClass init!123
对于静态字段,只要直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。是否加载和验证了子类?这取决于虚拟机的具体实现。对于HotSpot虚拟机而言,java -XX:+TraceClassLoading NotInitialization 来观察到此操作会导致子类的加载。
....[Loaded NotInitialization from file:/Users/mook/][Loaded java.lang.Void from /Library/Java/JavaVirtualMachines/jdk1.7.0_80.jdk/Contents/Home/jre/lib/rt.jar][Loaded SSClass from file:/Users/mook/][Loaded SuperClass from file:/Users/mook/][Loaded SubClass from file:/Users/mook/]SSClassSuperClass init!123[Loaded java.lang.Shutdown from /Library/Java/JavaVirtualMachines/jdk1.7.0_80.jdk/Contents/Home/jre/lib/rt.jar][Loaded java.lang.Shutdown$Lock from /Library/Java/JavaVirtualMachines/jdk1.7.0_80.jdk/Contents/Home/jre/lib/rt.jar]....
例2:
通过数组定义来引用类,不会触发此类的初始化public class NotInitialization{ public static void main(String[] args) { SuperClass[] sca = new SuperClass[10]; }}
例3:
常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化public class ConstClass{ static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world";}public class NotInitialization{ public static void main(String[] args) { System.out.println(ConstClass.HELLOWORLD); }}
运行结果:hello world
最后看一下接口的初始化过程与类初始化过程的不同:
接口也有初始化过程,上面的代码中我们都是用静态语句块来输出初始化信息的,而在接口中不能使用“static{}”语句块,但编译器仍然会为接口生成< clinit>类构造器,用于初始化接口中定义的成员变量(实际上是static final修饰的全局常量)。
二者在初始化时最主要的区别是:当一个类在初始化时,要求其父类全部已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量),才会初始化该父接口。这点也与类初始化的情况很不同,回过头来看第2个例子就知道,调用类中的static final常量时并不会 触发该类的初始化,但是调用接口中的static final常量时便会触发该接口的初始化。
public class Test { public static void main(String[] args) { func(); } static Test st = new Test(); static void func(){}}
根据上面的代码,有以下步骤:
static {}; flags: ACC_STATIC Code: stack=2, locals=0, args_size=0 0: new #3 // class Test 3: dup 4: invokespecial #4 // Method "":()V 7: putstatic #5 // Field st:LTest; 10: return LineNumberTable: line 5: 0
再看一个案例:
public class StaticTest{ public static void main(String[] args) { staticFunction(); } static StaticTest st = new StaticTest(); static { System.out.println("1"); } { System.out.println("2"); } StaticTest() { System.out.println("3"); System.out.println("a="+a+",b="+b); } public static void staticFunction(){ System.out.println("4"); } int a=110; static int b =112;}
23a=110,b=014
实例初始化不一定要在类初始化结束之后才开始初始化。
类的生命周期是:加载->验证->准备->解析->初始化->使用->卸载,只有在准备阶段和初始化阶段才会涉及类变量的初始化和赋值,因此只针对这两个阶段进行分析;类的准备阶段需要做是为类变量分配内存并设置默认值,因此类变量st为null、b为0;(需要注意的是如果类变量是final,编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将变量设置为指定的值,如果这里这么定义:static final int b=112,那么在准备阶段b的值就是112,而不再是0了。)
类的初始化阶段需要做是执行类构造器(类构造器是编译器收集所有静态语句块和类变量的赋值语句按语句在源码中的顺序合并生成类构造器,对象的构造方法是< init>(),类的构造方法是< clinit>(),可以在堆栈信息中看到),因此先执行第一条静态变量的赋值语句即st = new StaticTest (),此时会进行对象的初始化,对象的初始化是先初始化成员变量再执行构造方法,因此设置a为110->打印2->执行构造方法(打印3,此时a已经赋值为110,但是b只是设置了默认值0,并未完成赋值动作),等对象的初始化完成后继续执行之前的类构造器的语句,接下来就不详细说了,按照语句在源码中的顺序执行即可。
整个类加载过程中,除了在加载阶段用户应用程序可以自定义类加载器参与之外,其余所有的动作完全由虚拟机主导和控制。到了初始化才开始执行类中定义的Java程序代码(亦及字节码),但这里的执行代码只是个开端,它仅限于< clinit>()方法。类加载过程中主要是将Class文件(准确地讲,应该是类的二进制字节流)加载到虚拟机内存中,真正执行字节码的操作,在加载完成后才真正开始。