一、类加载机制概述
通过Class字节码那两篇文章,我们介绍了Java类编译成Class字节码的相关描述和信息,但是java虚拟机如何才能按照class字节码中描述的内容进行运用和使用呢?这个就需要JVM的类加载机制对其进行规范和约束;所以虚拟机把类的数据从Class文件(这里的Class文件可以是javac编译成的class文件,也可以是反射或者动态代理生成的class二进制流,或者网络传输的二进制流等等)加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的过程就是虚拟机的类加载机制。本文我们将重点从类加载的时机和生命周期重点解析类的加载机制。
JVM类加载机制主要包括两个问题:类加载的时机与步骤和类加载的方式。
与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态链接这个特点实现的。例如,如果编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类;用户可以通过Java预定义的和自定义类加载器,让一个本地的应用程序可以再运行时从网络或其他地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于Java程序之中。那么,对于Java的类加载会产生如下问题:
- 虚拟机什么时候才会加载Class文件并初始化类呢?(
类加载和初始化时机
) - 虚拟机如何加载一个Class文件呢?(
Java类加载的方式:类加载器、双亲委派机制
) - 虚拟机加载一个Class文件要经历那些具体的步骤呢?(
类加载过程与步骤
)
对于第一个和第三个问题将在本文中重点描述,第二个问题将会在类加载器的文章中重点描述;
二、类加载的时机
Java类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。其中准备、验证、解析三个部分统称为连接(Linking),这7个阶段的发生顺序如下图所示。
加载、验证、准备、初始化和卸载5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以再初始化阶段后再开始,这是为了支持Java语言的运行时绑定(也称动态绑定或晚期绑定)。注意:类的加载过程必须按照这种顺序按部就班地开始,而不是按部就班地进行或完成,因为这些阶段通常都是相互交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。
在了解了类的生命周期之后,那么JVM在什么情况下需要开始类加载过程的第一个阶段:加载?
二、类加载的时机
2.1 类加载的时机
Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始),具体5种情况如下图所示:
其中第一点主要解释为以下四个方面:1、使用 new 关键字实例化对象时;2、读取类的静态变量时(被 final修饰,已在编译期把结果放入常量池的静态字段除外);3、设置类的静态变量时;4、调用一个类的静态方法时。需要注意:newarray指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,
比如,new String[]只会直接触发String[]类的初始化,也就是触发对类[Ljava.lang.String的初始化,而直接不会触发String类的初始化。生成这四条指令最常见的Java代码场景是:
- 使用new关键字实例化对象的时候;
- 读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;
- 调用一个类的静态方法的时候;
对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
需要特别指出的是,类的实例化和类的初始化是两个完全不同的概念:
- 类的实例化是指创建一个类的实例(对象)的过程;
- 类的初始化是指为类各个成员赋初始值的过程,是类生命周期中的一个阶段。
2.2 被动引用常见的三种场景
- 通过子类引用父类的静态字段,不会导致子类初始化
package com.sunny.jvm.classload.passivereference;
/**
* <Description> 输出:Initialize class Dgrandpa
Initialize class Dfather<br>
* 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,
* 只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机中并未明确规定,
* 这点取决于虚拟机的具体实现。对于Sun HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数观察到此操作
* 会导致子类的加载。
* @author Sunny<br>
* @version 1.0<br>
* @taskId: <br>
* @createDate 2018/11/21 9:11 <br>
* @see com.sunny.jvm.classload <br>
*/
public class PrTest1 {
public static void main(String[] args) {
int x = Dson.count;
}
}
class Dgrandpa {
static {
System.out.println("Initialize class Dgrandpa");
}
}
class Dfather extends Dgrandpa{
static int count = 1;
static{
System.out.println("Initialize class Dfather");
}
}
class Dson extends Dfather{
static{
System.out.println("Initialize class Dson");
}
}
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机中并未明确规定,这点取决于虚拟机的具体实现。对于Sun HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数观察到此操作会导致子类的加载。上面的例子中,由于count字段是在Dfather类中定义的,因此该类会被初始化,此外,在初始化类Dfather的时候,虚拟机发现其父类Dgrandpa还没被初始化,因此虚拟机将先初始化其父类Dgrandpa,然后初始化子类Dfather,而Dson始终不会被初始化;
- 通过数组定义来引用类,不会触发此类的初始化
/**
* <Description> 没有任何输出<br>
* 通过数组来定义引用类,不会触发此类的初始化
*
* @author Sunny<br>
* @version 1.0<br>
* @taskId: <br>
* @createDate 2018/11/29 22:16 <br>
* @see com.sunny.jvm.classload.passivereference <br>
*/
public class PrTest2 {
public static void main(String[] args) {
E[] e = new E[10];
}
}
class E{
static{
System.out.println("Initialize class E");
}
}
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
package com.sunny.jvm.classload.passivereference;
/**
* <Description> 输出:1 <br>
* 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,
* 因此不会触发定义常量的类的初始化
*
* @author Sunny<br>
* @version 1.0<br>
* @taskId: <br>
* @createDate 2018/11/29 22:42 <br>
* @see com.sunny.jvm.classload.passivereference <br>
*/
public class PrTest3 {
public static void main(String[] args) {
System.out.println(ConstClass.COUNT );
}
}
class ConstClass{
static final int COUNT = 1;
static{
System.out.println("Initialize class ConstClass");
}
}
上述代码运行之后,只输出“1”,这是因为虽然在Java源码中引用了ConstClass类中的常量COUNT,但是编译阶段将此常量的值“1”存储到了PrTest3常量池中,对常量ConstClass.COUNT的引用实际都被转化为PrTest3类对自身常量池的引用了。也就是说,实际上PrTest3的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译为Class文件之后就不存在关系了。
三、类的加载过程
在类的加载时机中,我们已经通过图看到了类的生命周期,也即类的加载过程;然后这一节将详细讲解一下JVM在加载、验证、准备、解析和初始化五个阶段是如何对每个类进行操作的。
3.1 加载(Loading)
在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,如:网络、动态生成、数据库等);
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构;
- 在内存中(对于HotSpot虚拟机而言就是方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口;
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在夹在阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
3.2 验证(Verification)
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会尾号虚拟机自身的安全。验证阶段大致会完成资格阶段的检验动作:
- 文件格式验证:验证字节流是否符合Class文件格式的规范(如:是否以魔数0xCAFEBABE开头,主次版本号是否在当前虚拟机的处理范围之内、常量池中是否有不被支持的类型)
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求(如:这个类是否有父类,除了java.lang.Object之外)
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的;
- 符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响。
如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
3.3 准备(Preparation)
准备阶段是正式为类变量(static成员变量)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配。
这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。其次,这里所说的初始值通常情况下是数据类型的零值,假设一个类变量定义为:
public static int value = 123;
那么,变量value在准备阶段过后的值为0而不是123。因为这时候尚未开始执行任何java方法,而把value复制为123的putstatic指令时程序被变异后,存放于类构造器方法<clinit>()之中,所以把value赋值为123的动作将在初始化阶段才会执行。至于“特殊情况”是指:当类字段的字段属性是ConstantValue时,会在准备阶段初始化为指定的值,所以标注为final之后,value的值在准备阶段初始化为123而非0;
public static final int value = 123;
3.4 解析(Resolution)
解析阶段是把常量池内的符号引用替换成直接引用的过程,符号引用就是Class文件中的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量。下面我们看符号引用和直接引用的定义。
符号引用(Symbolic References)
:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以唯一定位到目标即可。符号引用于内存布局无关,所以所引用的对象不一定需要已经加载到内存中。各种虚拟机实现的内存布局可以不同,但是接受的符号引用必须是一致的,因为符号引用的字面量形式已经明确定义在Class文件格式中。
直接引用(Direct References)
:直接引用时直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机上翻译出来的直接引用一般不会相同。如果有了直接引用,那么它一定已经存在于内存中了。
以下Java虚拟机指令会将符号引用指向运行时常量池,执行任意一条指令都需要对它的符号引用进行解析:
对同一个符号进行多次解析请求是很常见的,除了invokedynamic指令以外,虚拟机基本都会对第一次解析的结果进行缓存,后面再遇到时,直接引用,从而避免解析动作重复。
对于invokedynamic指令,上面规则不成立。当遇到前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令同样生效。这是由invokedynamic指令的语义决定的,它本来就是用于动态语言支持的,也就是必须等到程序实际运行这条指令的时候,解析动作才会执行。其它的命令都是“静态”的,可以再刚刚完成记载阶段,还没有开始执行代码时就解析。
下面来看几种基本的解析:类与接口的解析
: 假设Java虚拟机在类D的方法体中引用了类N或者接口C,那么会执行下面步骤:
- 如果C不是数组类型,D的定义类加载器被用来创建类N或者接口C。加载过程中出现任何异常,可以被认为是类和接口解析失败。
- 如果C是数组类型,并且它的元素类型是引用类型。那么表示元素类型的类或接口的符号引用会通过递归调用来解析。
- 检查C的访问权限,如果D对C没有访问权限,则会抛出java.lang.IllegalAccessError异常。
字段解析:
要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info
符号引用进行解析,这边记不清的可以继续回顾Java class文件结构,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段解析失败。如果解析完成,那将这个字段所属的类或者接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。
1 . 如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。
2 . 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3 . 再不然,如果C不是java.lang.Object
的话,将会按照继承关系从下往上递归搜索其父类,如果在类中包含
了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4 . 如果都没有,查找失败退出,抛出java.lang.NoSuchFieldError
异常。如果返回了引用,还需要检查访问权限,如果没有访问权限,则会抛出java.lang.IllegalAccessError
异常。
在实际的实现中,要求可能更严格,如果同一字段名在C的父类和接口中同时出现,编译器可能拒绝编译。
类方法解析
类方法解析也是先对类方法表中的class_index项中索引的方法所属的类或接口的符号引用进行解析。我们依然用C来代表解析出来的类,接下来虚拟机将按照下面步骤对C进行后续的类方法搜索。
1 . 首先检查方法引用的C是否为类或接口,如果是接口,那么方法引用就会抛出IncompatibleClassChangeError
异常
2 . 方法引用过程中会检查C和它的父类中是否包含此方法,如果C中确实有一个方法与方法引用的指定名称相同,并且声明是签名多态方法(Signature Polymorphic Method),那么方法的查找过程就被认为是成功的,所有方法描述符所提到的类也需要解析。对于C来说,没有必要使用方法引用指定的描述符来声明方法。
3 . 否则,如果C声明的方法与方法引用拥有同样的名称与描述符,那么方法查找也是成功。
4 . 如果C有父类的话,那么按照第2步的方法递归查找C的直接父类。
5 . 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在相匹配的方法,说明类C时一个抽象类,查找结束,并且抛出java.lang.AbstractMethodError
异常。
- 否则,宣告方法失败,并且抛出
java.lang.NoSuchMethodError
。
最后的最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,那么会抛出java.lang.IllegalAccessError
异常。
接口方法解析
接口方法也需要解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。
1 . 与类方法解析不同,如果在接口方法表中发现class_index对应的索引C是类而不是接口,直接抛出java.lang.IncompatibleClassChangeError
异常。
2 . 否则,在接口C中查找是否有简单名称和描述符都与目标匹配的方法,如果有则直接返回这个方法的直接引用,查找结束。
3 . 否则,在接口C的父接口中递归查找,直到java.lang.Object
类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4 . 否则,宣告方法失败,抛出java.lang.NoSuchMethodError
异常。
由于接口的方法默认都是public的,所以不存在访问权限问题,也就基本不会抛出java.lang.IllegalAccessError
异常。
3.5 初始化(Initialization)
类初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码(字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值(零值);而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者更直接地说:初始化阶段是执行类构造器<clinit>()方法的过程。
<clinit>()方法时由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。如下:
public class Test{
static{
i=0;
System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前应用)
}
static int i=1;
}
那么注释报错的那行代码,改成下面情形,程序就可以编译通过并可以正常运行了。
public class Test{
static{
i=0;
//System.out.println(i);
}
static int i=1;
public static void main(String args[]){
System.out.println(i);
}
}/* Output:
1
*///:~
类构造器<clinit>()与实例构造器<init>()不同,它不需要程序员进行显式调用,虚拟机会保证在子类类构造器<clinit>()执行之前,父类的类构造<clinit>()执行完毕。由于父类的构造器<clinit>()先执行,也就意味着父类中定义的静态语句块/静态变量的初始化要优先于子类的静态语句块/静态变量的初始化执行。特别地,类构造器<clinit>()对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生产类构造器<clinit>()。
虚拟机会保证一个类的类构造器<clinit>()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器<clinit>(),其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。
特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行<clinit>()方法,因为 在同一个类加载器下,一个类型只会被初始化一次。
如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的,如下所示:
package com.sunny.jvm.classload;
/**
* <Description> <br>
*
* @author Sunny<br>
* @version 1.0<br>
* @taskId: <br>
* @createDate 2018/11/29 23:56 <br>
* @see com.sunny.jvm.classload <br>
*/
public class DealLoopTest {
static{
System.out.println("DealLoopTest...");
}
static class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread()
+ "init DeadLoopClass");
while (true) { // 模拟耗时很长的操作
}
}
}
}
public static void main(String[] args) {
Runnable script = new Runnable() { // 匿名内部类
public void run() {
System.out.println(Thread.currentThread() + " start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
}
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}
/* Output:
DealLoopTest...
Thread[Thread-1,5,main] start
Thread[Thread-0,5,main] start
Thread[Thread-1,5,main]init DeadLoopClass
*///:~
如上述代码所示,在初始化DeadLoopClass类时,线程Thread-1得到执行并在执行这个类的类构造器<clinit>() 时,由于该方法包含一个死循环,因此久久不能退出。
四、典型实例分析
在Java中,创建一个对象常常需要经历如下几个过程:父类的类构造器<clinit>() -> 子类的类构造器<clinit>()->父类的成员变量和实例代码块->父类的构造函数->子类的成员变量和实例代码块->子类的构造函数
。至于为什么是这样的一个过程,后面的文章会重点分析;
首先看一个例子:
package com.sunny.jvm.classload;
/**
* <Description> <br>
*
* @author Sunny<br>
* @version 1.0<br>
* @taskId: <br>
* @createDate 2018/11/30 9:50 <br>
* @see com.sunny.jvm.classload <br>
*/
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; // 静态变量
}
/* Output:
2
3
a=110,b=0
1
4
*///:~
因为在初始化阶段,当JVM对类StaticTest进行初始化时,首先会执行下面的语句:
static StaticTest st = new StaticTest();
也就是实例化StaticTest对象,但这个时候类都没有初始化完毕,能直接进行实例化么?事实上,这涉及到一个根本问题就是:实例初始化不一定要在类初始化结束之后才开始初始化。
下面我们结合类的加载过程说明这个问题。
我们知道类的生命周期是:加载->验证->准备->解析->初始化->使用->卸载,并且只有在准备阶段和初始化阶段才会涉及类变量的初始化和赋值,因此我们只针对这两个阶段进行分析:
首先,在类的准备阶段需要做的是为类变量(static变量)分配内存并设置默认值(零值),因此在该阶段结束后,类变量st将变为null、b变为0。特别需要注意的是,如果类变量是final的,那么编译器在编译时就会为value生成ConstantValue属性,并在准备阶段虚拟机就会根据ConstantValue的设置将变量设置为指定的值。也就是说,如果上述程度对变量b采用如下定义方式时:
static final int b=112
那么,在准备阶段b的值就是112,而不再是0了。
此外,在类的初始化阶段需要做的是执行类构造器<clinit>(),需要指出的是,类构造器本质上编译器收集所有静态语句块和类变量的赋值语句在源码中的顺序合并生成类构造器<clinit>()。因此,对上述程序而言,JVM将先执行第一条静态变量的赋值语句:
st = new StaticTest ();
此时,就碰到了笔者上面的疑惑,即“在类都没有初始化完毕之前,能直接进行实例化相应的对象吗?”。事实上,从Java角度看,我们知道一个类初始化的基本常识,那就是:在同一个类加载器下,一个类型只会被初始化一次。所以,一旦开始初始化一个类型,无论是否完成,后续都不会再重新触发该类型的初始化阶段了(只考虑在同一个类加载器下的情形)。因此,在实例化上述程序中的st变量时,实际上是把实例初始化嵌入到了静态初始化流程中,并且在上面的程序中,嵌入到了静态初始化的起始位置。
这就导致了实例初始化完全发生在静态初始化之前,当然,这也是导致a为110b为0的原因。
因此,上述程序的StaticTest类构造器<clinit>()的实现等价于:
public class StaticTest {
<clinit>(){
a = 110; // 实例变量
System.out.println("2"); // 实例代码块
System.out.println("3"); // 实例构造器中代码的执行
System.out.println("a=" + a + ",b=" + b); // 实例构造器中代码的执行
类变量st被初始化
System.out.println("1"); //静态代码块
类变量b被初始化为112
}
}
因此,上述程序会有上面的输出结果。下面,我们对上述程序稍作改动,如下所示:
/**
* <Description> <br>
*
* @author Sunny<br>
* @version 1.0<br>
* @taskId: <br>
* @createDate 2018/11/30 10:33 <br>
* @see com.sunny.jvm.classload <br>
*/
public class StaticTest {
public static void main(String[] args) {
staticFunction();
}
static StaticTest st = new StaticTest();
static {
System.out.println("1");
}
{
System.out.println("2");
}
StaticTest2() {
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;
static StaticTest st1 = new StaticTest();
}
在程序最后的一行,增加以下代码行:
static StaticTest st1 = new StaticTest();
那么,此时程序的输出又是什么呢?如果你对上述的内容理解很好的话,不难得出结论(只有执行完上述代码行后,StaticTest类才被初始化完成),即:
2
3
a=110,b=0
1
2
3
a=110,b=112
4
另外,下面这道经典题目也很有意思,如下:
class Foo {
int i = 1;
Foo() {
System.out.println(i);
int x = getValue();
System.out.println(x);
}
{
i = 2;
}
protected int getValue() {
return i;
}
}
//子类
class Bar extends Foo {
int j = 1;
Bar() {
j = 2;
}
{
j = 3;
}
@Override
protected int getValue() {
return j;
}
}
public class ConstructorExample {
public static void main(String... args) {
Bar bar = new Bar();
System.out.println(bar.getValue());
}
}
那么,这个程序的输出又是什么呢?
参考资料
周志明《深入理解Java虚拟机:JVM高级特性与最佳实践(最新第二版)》