首先引入一道面试题
class Single { private static Single single = new Single(); public static int count1; public static int count2 = 0; private Single() { count1++; count2++; } public static Single getInstance() { return single; } } public class Test { public static void main(String[] args) { Single single = Single.getInstance(); System.out.println("count1=" + single.count1); System.out.println("count2=" + single.count2); } } 复制代码
错误答案: count1=1;count2=1
正确答案: count1=1;count2=0
为神马?为神马?这要从java的类加载时机说起。
本来是准备把分析结果写在最下面的但是怕大家没有耐心看到最后我这边先大概分析下,如果看不懂下面的分析。建议大家能看到最后,文章不算长。
Single single = Single.getInstance();
调用了类的 Single
调用了类的静态方法,触发类的初始化 single=null count1=0,count2=0
single
赋值为 new Single()
调用类的构造方法 count=1;count2=1
count1
与 count2
赋值,此时 count1
没有赋值操作,所有 count1
为1,但是 count2
执行赋值操作就变为0 类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。
其中加载、验证、准备、初始化和卸载五个步骤的顺序都是确定的,解析阶段在某些情况下有可能发生在初始化之后,这是为了支持 Java 语言的运行期绑定的特性。
什么情况下需要开始类加载过程的第一个阶段:"加载"。虚拟机规范中并没强行约束,这点可以交给虚拟机的的具体实现自由把握,但是对于初始化阶段虚拟机规范是严格规定了如下几种情况,如果类未初始化会对类进行初始化。
constant variable
),编译器并不会生成字节码来从对象中载入域的值,而是直接把这个值插入到字节码中。这是一种很有用的优化,但是如果你需要改变final域的值那么每一块用到那个域的代码都需要重新编译。 Class.forName("my.xyz.Test")
) 接口的加载过程与类的加载过程稍有不同。接口中不能使用 static{}
块。当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化。
对于静态字段,只有直接定义这个字段的类会被初始化,如果是通过子类引用父类的字段,父类会被初始化,子类不一定会被初始化,子类会不会被初始化 JVM 虚拟机规范并没有明确规定,取决于虚拟机的具体实现
public class SuperClass { static { System.out.println("SuperClass init!"); } public static int value = 1; } public class SubClass extends SuperClass { static { System.out.println("SubClass init!"); } } public class Demo { public static void main(String[] args){ System.out.println("The value is " + Subclass.value); } } 复制代码
上面代码运行之后输出结果如下所示
SuperClass init! The value is 24 复制代码
public class SubClass { static { System.out.println("SubClass init!"); } } public class Demo { public static void main(String[] args){ SubClass[] subClassArray = new SubClass[10]; } } 复制代码
上面代码运行之后,并不会输出 " SubClass init!
",因为在上面 Demo#main()
方法中,并没有初始化 SubClass
类,而是初始化了一个 SubClass[]
数组类, SubClass[]
数组类代表了一个元素类型为 SubClass
的一维数组,继承自 Object
类,由 newarray
字节码创建。
public class Constant { static { System.out.println("Constant init!"); } public static final String VALUE = "Hello World!"; } public class Demo { public static void main(String[] args){ System.out.println(Constant.VALUE); } } 复制代码
上面代码运行之后也并不会输出" Constant init!
",因为这涉及到一个概念 ---- “常量传播优化”。虽然在代码中 Demo
类引用了 Constant
类中的常量 VALUE
,但是在编译阶段,会将 VALUE
的实际值" Hello World!
"放到 Demo
类中的常量池中, Demo
类每次使用" Hello World!
"常量的时候都会从自己的常量池中去找。 Demo
类不会持有 Constant
类的符号引用,所以 Constant
类也并不会被初始化。
在加载阶段有三个步骤:
java.lang.Class
的对象,作为方法区这些数据的访问入口 在这个阶段,有两点需要注意: .class
静态存储文件中获取,也可以从 zip、jar
等包中读取,可以从数据库中读取,也可以从网络中获取,甚至我们自己可以在运行时自动生成。 java.lang.Class
对象之后,并没有规定此 Class
对象是方法 Java
堆中的,有些虚拟机就会将 Class
对象放到方法区中,比如 HotSpot
。 验证是连接阶段的第一个步骤,验证的目的是为了确保 .class
文件中的字节流所包含的信息是符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全的。
Java
语言本身是相对安全的语言,使用Java编码是无法做到如访问数组边界以外的数据、将一个对象转型为它并未实现的类型等,如果这样做了,编译器将拒绝编译。但是, Class
文件并不一定是由 Java
源码编译而来,可以使用任何途径,包括用十六进制编辑器(如 UltraEdit
)直接编写。如果直接编写了有害的“代码”(字节流),而虚拟机在加载该Class时不进行检查的话,就有可能危害到虚拟机或程序的安全。
不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:文件格式验证、元数据验证、字节码验证和符号引用验证。
private、protected、public、default
验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用 -Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。
准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
有三点需要注意:
static
修饰的变量),而不包括实例变量,实例变量将会跟随着对象在 Java 堆中为其分配内存 0
值,比如有如下类变量,在准备阶段完成之后 val
的值是 0
而不是 123
,为 val
复制为 123
,是在后面要讲的初始化阶段之后 public static int val=123;//在准备阶段value初始值为0 。在初始化阶段才会变为123 。 复制代码
ConstantValue
属性当中,所以在准备阶段结束之后,常量的值就是 ConstantValue
所指定的值了,比如如下,在准备阶段结束之后, val
的值就是 123
了。 public static final int val = 123; 复制代码
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 符号引用(Symbolic Reference): 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。 直接引用(Direct Reference): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。
类的初始化阶段才是真正开始执行类中定义的 Java 程序代码。初始化说白了就是调用类构造器 <clinit>()
的过程,在类的构造器中会为类变量初始化定义的值,会执行静态代码块中的内容。下面将介绍几点和开发者关系较为紧密的注意点
<clinit>()
是由编译器自动收集类中出现的类变量、静态代码块中的语句合并产生的,收集的顺序是在源文件中出现的顺序决定的,静态代码块可以访问出现在静态代码块之前的类变量,出现的静态代码块之后的类变量,只可以赋值,但是不能访问,比如如下代码 public class Demo { private static String before = "before"; static { after = "after"; // 赋值合法 System.out.println(before); // 访问合法,因为出现在 static{} 之前 System.out.println(after); // 访问不合法,因为出现在 static{} 之后 } private static String after; } 复制代码
<clinit>()
类构造器和 <init>()
实例构造器不同,类构造器不需要显示的父类的类构造,在子类的类构造器调用之前,会自动的调用父类的类构造器。因此虚拟机中第一个被调用的 <clinit>()
方法是 java.lang.Object
的类构造器 static{}
代码块也优先于子类的 static{}
执行 <clinit>()
对于类来说并不是必需的,如果一个类中没有类变量,也没有 static{}
,那这个类不会有类构造器 <clinit>()
static{}
,但是接口中也可以有类变量,所以接口中也可以有类构造器 <clinit>{}
,但是接口的类构造器和类的类构造器有所不同,接口在调用类构造器的时候,如果不需要,不用调用父接口的类构造器,除非用到了父接口中的类变量,接口的实现类在初始化的时候也不会调用接口的类构造器 <clinit>()
方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的类构造器 <clinit>()
,其他线程会被阻塞,直到活动线程执行完类构造器 <clinit>()
方法