以前在网上看到一道面试题,很形象的描述了类的加载初始化过程。要彻底理解这道题,就不得不深刻理解类加载的过程。面试题以下:html
class SingleTon { private static SingleTon singleTon = new SingleTon(); public static int count1; public static int count2 = 0; private SingleTon() { count1++; count2++; } public static SingleTon getInstance() { return singleTon; } } public class Test { public static void main(String[] args) { SingleTon singleTon = SingleTon.getInstance(); System.out.println("count1=" + singleTon.count1); System.out.println("count2=" + singleTon.count2); } }
这道题的正确答案为 :java
count1=1面试
count2=0数组
至于为何会是这个答案,这就涉及到了 JVM 类加载的过程。安全
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括加载、验证、准备、解析、初始化、使用和卸载 7 个阶段,其中验证、准备和解析 3 个阶段统称为链接,这 7 个阶段发生的顺序以下图所示。网络
加载、验证、准备、初始化和卸载这 5 个阶段的顺序是肯定的。解析阶段则不必定,因为支持运行时绑定,类能够在初始化以后再开始进行解析。同时这些阶段只是按照顺序进行开始,并不必定会按照顺序进行或者结束,由于这些阶段一般都是互相交叉地混合式进行的,一般会在一个阶段执行过程当中调用、激活另一个阶段。数据结构
加载是类加载过程的一个阶段,是根据特定名称查找类或接口类型的二进制表示(binary representation),并由此二进制表示来建立类或接口的过程。在加载阶段,虚拟机须要完成 3 件事:多线程
虚拟机加载的是类的二进制流,只是对内容格式作了限制,并无指名要从哪里去获取、怎样获取一个类的二进制流,比较常见的有一下几种:oracle
加载阶段完成后,二级制字节流就按照虚拟机所需的格式储存在方法区之中,而后在内存中实例化一个 java.lang.Class 对象,将这个对象做为程序访问方法区中的这些类型数据的外部接口。jvm
验证时链接阶段的第一步,这一步是为了保证 Class文件二进制字节流符合虚拟机规范,而且不会危害虚拟机自身的安全。Java 虚拟机规范有大量的约束和验证规则,详细的描述的验证过程。验证过程主要仍是围绕 Class 文件格式对各部分进行验证。Class 文件格式课参考另外一篇博文字节码文件结构详解。但从总体上看,验证阶段大体会完成下面 4 个阶段的验证动做。
第一阶段要验证字节流是否符合 Class文件格式规范,而且能被当前版本的虚拟机处理。这一阶段可能包括下面验证点:
第一阶段的验证远不止这些,该阶段的主要目的是保证输入的字节流能正确的解析并存储于方法区内。这阶段的验证是基于二进制字节流进行的,只有经过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,因此后面的3个验证阶段所有是基于方法区的存储结构进行的,不会再直接操做字节流。
元数据验证是对字节码描述的信息进行语义分析,确保其描述的信息符合 Java 语言规范的要求,这个阶段可能包括的验证点以下:
字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会作出危害虚拟机安全的事件。
若是一个方法经过了字节码验证,也不能说明其必定就是安全的。
符号引用验证能够看作是对类自身之外(常量池中的各类符号引用)的信息校验,一般须要校验一下内容:
若是一个类没法经过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError
异常的子类,如常见的java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些类变量所使用的内存都将在方法区中进行分配。此处须要明确类变量
的含义,即被static
修饰的变量,而不包括实例变量,实例变量会在初始化阶段随着对象一块儿分配在 Java 堆中。此时分配的初始值是数据类型的零值
,并非咱们定义的初始值。此处还要明确一个概念,若是变量被final
修饰,则此字段的字段属性表存在 ConstantValue 属性,那么在准备阶段变量就会被初始化为 ConstantValue属性所指定的值。可经过下例代码来对照理解:
public static int a = 10; public static final int B = 20;
其部分汇编字节码为:
Constant pool: #2 = Fieldref #3.#21 // tech/techstack/blog/Test.a:I #3 = Class #22 // tech/techstack/blog/Test #5 = Utf8 a #6 = Utf8 I #21 = NameAndType #5:#6 // a:I #22 = Utf8 tech/techstack/blog/Test public static int a; descriptor: I flags: ACC_PUBLIC, ACC_STATIC public static final int B; descriptor: I flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: int 20 static {}; descriptor: ()V flags: ACC_STATIC Code: stack=1, locals=0, args_size=0 0: bipush 10 2: putstatic #2 // Field a:I 5: return LineNumberTable: line 8: 0
从上述代码能够看出,B 字段对应的 field_info 与 a 字段对应的 field_info 相比对了一个 Constant_Value 属性,而 Constant_Value 属性的值 20 就会在准备阶段直接赋给字段 B。同时在字节码第 19 行有一个 static {};
方法,此方法对应的就是类的构造方法<clinit>
在初始化阶段执行,它的Code
属性中对应的字节码指令bipush 10
为往操做数栈压入 10,putstatic
则是将值 10 赋值给 a 字段。
基本数据类型的零值:
数据类型 零值 byte (byte)0 char '\u0000' short (short)0 boolean false int 0 long 0l float 0.0f double 0.0 reference null
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在 Class 文件中以 CONSTANT_Class_info、CONSTANT_Feildref_info、CONSTANT_Methodref_info 等类型的常量出现,具体能够参考博文字节码文件结构详解。此处有符号引用和直接引用两个概念须要了解一下.
符号引用以一组符号来描述所引用的目标,符号引用能够是任何形式的字面量,只要使用能无歧义地定义到目标便可。符号引用与虚拟机实现的内存布局无关,引用的目标不必定已经加载到内存中。各类虚拟机实现的内存布局能够各不相同,可是它们能接受的符号引用必须都是一致的。符号引用的字面量形式须要明确的定义在 Class 文件格式中。
直接引用能够是直接执行目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不一样的虚拟机实例上翻译出来的直接引用通常不会相同。若是有了直接引用,那引用目标一定已经出如今内存中。
虚拟机规范并未规定解析发生的具体时间,只要求在执行anewarray
、checkcast
、getfield
、getstatic
、instanceof
、invokedynamic
、invokeinterface
、invokespecial
、invokestatic
、invokevirtual
、ldc
、ldc_w
、multianewarray
、new
、putfield
和 putstatic
这 16 个用于操做符号引用的字节码指令以前,先对他们所使用的符号引用进行解析。因此虚拟机实现能够根据须要来判断究竟是在类被加载和加载时就对常量池中的符号引用进行解析仍是等到一个符号引用将要被使用前才去解析它。
加载过程当中的解析阶段为静态的将符号引用替换为直接引用的过程。可与虚拟机栈内存中的动态连接参照记忆。
解析动做主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行,分别对应于常量池的CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
、CONSTANT_InterfaceMethodref_info
、CONSTANT_MethodType_info
、CONSTANT_MethodHandle_info
和CONSTANT_InvokeDynamic_info
7种常量类型。
初始化阶段是类加载过程当中的最后一步,此阶段才是真正执行类中定义的 Java 程序代码。初始化阶段和准备阶段的初始化是不一样概念的,准备阶段的初始化是给类字段赋值零值的过程,而类加载过程当中的初始化阶段能够看作是类对象的初始化。对于类的初始化反映到字节码中就是类的<clinit>()
方法。从另一个角度来说,能够将初始化阶段理解成是执行类构造器<clinit>()方法的过程。同时对于<clinit>()
方法,有几个概念要弄清楚。
<clinit>()
方法是由编译器自动收集类中的全部类变量的赋值动做和静态语句块(static {})中的语句合并产生的。<clinit>()
方法以前,虚拟机会确保子类的<clinit>()
方法已经执行完毕。所以在虚拟机中第一个被执行的<clinit>()方法的类确定是java.lang.Object。<clinit>()
方法。与类不一样的是,执行接口<clinit>()
方法不须要先执行父接口的<clinit>()
方法。只有当父接口中定义的变量使用时,父接口才会初始化。接口的实现类在初始化时也不会执行接口的<clinit>()
方法。<clinit>()
方法只被一个线程调用,其它线程会被阻塞。同时,在一个类加载器下,一个类的<clinit>()
方法只会被执行一次。注:
本文所说的类对象与类实例不是一个概念。关于类对象与类实例以及 java.lang.Class 对象之间的关系,此处能够引用
R 大
的一个回答传送门:在HotSpot VM中,对象(类的实例对象)、类的元数据(InstanceKlass)、类的Java镜像(java.lang.Class 实例),三者之间的关系是这样的:
Java object InstanceKlass Java mirror [ _mark ] (java.lang.Class instance) [ _klass ] --> [ ... ] <-\ [ fields ] [ _java_mirror ] --+> [ _mark ] [ ... ] | [ _klass ] | [ fields ] \ [ klass ]每一个Java对象的对象头里,_klass字段会指向一个VM内部用来记录类的元数据用的InstanceKlass对象;InsanceKlass里有个_java_mirror字段,指向该类所对应的Java镜像——java.lang.Class实例。HotSpot VM会给Class对象注入一个隐藏字段“klass”,用于指回到其对应的InstanceKlass对象。这样,klass与mirror之间就有双向引用,能够来回导航。这个模型里,java.lang.Class实例并不负责记录真正的类元数据,而只是对VM内部的InstanceKlass对象的一个包装供Java的反射访问用。
经过上面的引用,能够清楚的知道 Java Object, InstanceKlass, Java mirror(java.lang.Class instance)在内存中的分布了。
对于初始化阶段能够经过代码来理解一下:
public class SuperClass { public static int superClassField = 1; static { System.out.println("supper class static code"); } public SuperClass() { System.out.println("supper class constructor"); } } public interface SuperInterface { int superInterfaceField = 10; } public class SubClass extends SuperClass implements SuperInterface { public static int subClassField = 20; static { System.out.println("sub class static code."); } public SubClass() { System.out.println("sub class constructor"); } } public class TestClassLoad { static { System.out.println("test class load"); } } public class App { static { System.out.println("App main static code"); } public static void main(String[] args) { System.out.println(SubClass.superClassField); System.out.println("----------------------"); new Thread(SubClass::new).start(); } }
在运行 SubClass 的时候加上 -XX:+TraceClassLoading
参数,打印出来运行过程当中加载的类。上述代码运行结果为结果 1:
// 类加载日志(节选) [Loaded tech.stack.App from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperInterface from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SubClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] App main static code supper class static code 1 ---------------------- sub class static code. supper class constructor sub class constructor
注释掉new Thread(SubClass::new).start();
从新运行程序,获得一下输出结果 2:
[Loaded tech.stack.App from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperInterface from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SubClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] App main static code supper class static code 1 ----------------------
而后将 System.out.println(SubClass.superClassField);
替换为 System.out.println(SubClass.subClassField);
再次运行程序,获得输出结果 3:
[Loaded tech.stack.App from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperInterface from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SubClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] App main static code supper class static code sub class static code. 20 ----------------------
这几段代码信息量很大,根据上文所讲慢慢分析:
TestClassLoad
类始终都没有被加载。而App
、SuperInterface
、SuperClass
、SubClass
始终被加载,是否是能够证实属于 Applicatin 做用域范围内的类会在首次使用时加载。SuperInterface
任何方法、变量能够看出对于子类来讲,在加载子类时首先要加载实现的接口以及父类。当虚拟机启动时,用户须要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
结果 1 结果 3 都代表父类的<clinit>()
方法在子类<clinit>()
方法以前调用。
结果 1 结果 2 对比代表经过子类调用父类的静态的变量只会引发父类的初始化并不会使子类初始化。
对比结果 1 和结果 2 说明在多线程的状况况下只要类加载器相同,类只初始化一次。
对比结果 一、二、3 能得出一个实例的初始化顺序
<clinit>()
方法。<clinit>()
方法。<init>()
方法。<init>()
方法、注:
关于类实例的初始化过程即对象的实例化过程会专门在另外一篇博客进行讲解。
关于"接口中不能使用静态代码块,但仍有变量初始化的赋值操做,所以接口也会生成
<clinit>()
方法。" 在接口中变量初始化赋值操做可参考以下代码:public interface SuperInterface { int superInterfaceField = 10; SuperClass su = new SuperClass(); } // bytecode { public static final int superInterfaceField; descriptor: I flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: int 10 public static final tech.stack.SuperClass su; descriptor: Ltech/stack/SuperClass; flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL static {}; descriptor: ()V flags: ACC_STATIC Code: stack=2, locals=0, args_size=0 0: new #1 // class tech/stack/SuperClass 3: dup 4: invokespecial #2 // Method tech/stack/SuperClass."<init>":()V 7: putstatic #3 // Field su:Ltech/stack/SuperClass; 10: return LineNumberTable: line 10: 0 }
关于类在何时加载,咱们能够有上面的代码窥见一斑。可是这只是在JDK1.8, Hotspot 虚拟机测试的状况下得出的结论,也不必定会是正确的,由于 Java 虚拟机规范中并无进行强制约束,关于加载阶段,都是根据虚拟机的具体实现来自由把握。可是对于初始化阶段,虚拟机严格规定了有且只有
5 种状况必须当即对类进行初始化
(而加载、验证、准备天然须要再次以前开始):
new
、getstatic
、putstatic
或invokestatic
这4条字节码指令时,若是类没有进行过初始化,则须要先触发其初始化。生成这4条指令的最多见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候(已经过上文代码验证)。尝试着补充解释一下这几条其中的原理,对于 new
关键字天然不用多说,new
关键字实例化类的实例对象以前天然会执行类的初始化操做,以完成 Java 程序对类的一些操做。getstatic
putstatic
指令的含义为读取或设置一个类的静态字段,此处仍是应用R大
的回答,原文与上处引用出自同一处:
从JDK 1.3到JDK 6的HotSpot VM,静态变量保存在类的元数据(InstanceKlass)的末尾。而从JDK 7开始的HotSpot VM,静态变量则是保存在类的Java镜像(java.lang.Class实例)的末尾。假若有这样的A类:
class A { static int value = 1; }那么在JDK 6或以前的HotSpot VM里:
Java object InstanceKlass Java mirror [ _mark ] (java.lang.Class instance) [ _klass ] --> [ ... ] <-\ [ fields ] [ _java_mirror ] --+> [ _mark ] [ ... ] | [ _klass ] [ A.value ] | [ fields ] \ [ klass ]能够看到这个A.value静态字段就在InstanceKlass对象的末尾存着了。而在JDK 7或以后的HotSpot VM里:
Java object InstanceKlass Java mirror [ _mark ] (java.lang.Class instance) [ _klass ] --> [ ... ] <-\ [ fields ] [ _java_mirror ] --+> [ _mark ] [ ... ] | [ _klass ] | [ fields ] \ [ klass ] [ A.value ]能够看到这个A.value静态字段就在java.lang.Class对象的末尾存着了。
据此咱们应该就能得出结论,在设置静态变量的时候已经须要根据InstanceKlass
生成java.lang.Class
对象了,而静态变量已经不能在方法区经过读取类元信息进行获取或者储存。而生成 Java mirror 必然要经过完整的类元信息,所以须要进行初始化动做。对于java.lang.reflect
包的反射方法,其根据的就是 java.lang.Class
对象。对于子类初始化时,由于 Java 的继承特性,继承的是父类完整的类信息。父类进行初始化也是理所固然的。
上述 5 种场景中的行为称为对一个类的主动引用。除此以外,全部的引用类的方式都不会触发初始化,称为被动引用。例:
经过子类调用父类的静态字段(变量+常量),不会致使子类的初始化。代码可参考上文。
经过数组定义来引用类,不会触发此类的初始化
public class App { public static void main(String[] args) { SuperClass[] superClasses = new SuperClass[10]; } }
常量在编译阶段会存入调用类的常量池中,本质上并无直接引用到定义常量的类,所以不会触发定义常量的类的初始化。
public class ConstantClass { public static final String HELLO_WORLD = "hello world !"; } public class App { public static void main(String[] args) { System.out.println(ConstantClass.HELLO_WORLD); } }
这是由于虽然在Java源码中引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段经过常量传播优化,已经将此常量的值"hello world !"存储到了App类的常量池中,之后App对常量HELLO_WORLD的引用实际都被转化为App类对自身常量池的引用了。也就是说,实际上App的Class文件之中并无ConstantClass类的符号引用入口,这两个类在编译成Class以后就不存在任何联系了。能够看一下App的字节码。
Constant pool: #4 = String #25 // hello world ! #25 = Utf8 hello world ! { public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #4 // String hello world ! 5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 10: 0 line 11: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 args [Ljava/lang/String; }
其实讲到这里,这道题也算是分析完了,那么就根据上面所讲,总结一下这道题:
Test
类的 main 方法,回顾上文确定要先加载、验证、初始化 Test
类(因为加载、验证必然发生在初始化以前,下面分析就忽略这两个阶段)。SingleTon.getInstance()
为 Test
类调用 SingleTon
类的静态方法,必然引发 SingleTon
类的初始化。SingleTon
类存在 singleTon
count1
count2
三个静态变量,所以这三个静态变量会被编译器顺序收集值到<clinit>()
方法中。<clinit>()
开始就是 new SingleTon()
会建立 SingleTon
类的实例 singleTon
,此时 ``singleTon.count1
singleTon.count2` 值都为 1。<clinit>()
操做完第一个变量 singleTon
以后即是对第二个变量 count1
操做,此时就会将 1 赋值给 SingleTon
变量 count1
。<clinit>()
后续操做即是执行 count2 = 0
即经过操做数栈将 0 赋值给SingleTon
变量 count2
。查看SingleTon
的字节码:
{ static {}; descriptor: ()V flags: ACC_STATIC Code: stack=2, locals=0, args_size=0 0: new #5 // class tech/stack/SingleTon 3: dup 4: invokespecial #6 // Method "<init>":()V 7: putstatic #4 // Field singleTon:Ltech/stack/SingleTon; 10: iconst_0 11: putstatic #3 // Field count2:I 14: return LineNumberTable: line 4: 0 line 6: 10 }
其 static{}
方法执行流程正如上文分析。不妨想一下若是将private static SingleTon singleTon = new SingleTon();
移动到public static int count2 = 0;
下面将会输出什么结果?
参考:
[1] 周志明.深刻理解Java虚拟机:JVM高级特性与最佳实践.北京:机械工业出版社,2013.
[2] Chapter 5. Loading, Linking, and Initializing
[3] JVM里的符号引用如何存储?
文章首发于陈建源的博客,欢迎访问。
文章做者:陈建源
文章连接: https://www.techstack.tech/post/lei-jia-zai-guo-cheng/