一、引言
最近社区讨论了一个比较有趣的类初始化代码
public class Test2 {
public Test2() {
s1++;
s2++;
}
private static Test2 test2 = new Test2();
private static int s1;
private static int s2 = 3;
public static void main(String[] args) {
System.out.println(test2.s1);
System.out.println(test2.s2);
}
}
s1和s2的结果是什么,大部分人不知道结果,少部分人将构造方法的执行与静态变量分配的时机进行比较,得到不太确定的结果,只有极少部分对于类初始化研究过的人才能从原理上说出结果。
这里先看一下结果,那么为什么呢,这里涉及的知识其实非常底层,只依靠java知识和开发经验是不行的,必须对jvm有深入的了解才能分析清楚。
二、分析
想要理解这个例子,那就需要了解一下一个类在初始化过程中,该对象属性变量的分配与初始化过程,这里从《深入理解java虚拟机》第七章进行分析
在《深入理解java虚拟机》第七章中介绍类加载实际上分为、记载、验证、准备、解析、初始化等五个阶段:
1、加载:获取类的二进制字节流并且将其转化为方法区的运行时数据结构
2、验证:保证类的信息符合jvm规范,在编译期提前暴露错误
可以分为文件格式、源数据、字节码、符号引用等验证
3、准备:为类静态变量分配内存并且设置初始值
也就是在这一步可以分析出s1、s2都会分配为0,这是在正常情况下,如果静态变量的字段属性表存在ConstantValue属性那就不一样了,比如下面这行代码
private static final int s3 = 666;
s3的分配值是666
4、解析:jvm会把常量池的符号引用替换为直接引用,这个看起来会让许多人不明所以,因为符号引用是Class文件格式使用的,在《深入理解java虚拟机》第六章有详细描述,没有接触过这方面知识的是完全不能理解甚至歪曲理解的。
而直接引用大家就知道了,是指针、句柄、或者相对偏移量。
解析又分为类、接口、字段、方法、接口方法解析,这里就是构造方法被解析的阶段,此时s1 = 1,s2 = 1
public Test2() {
s1++;
s2++;
}
5、初始化:这是类加载的最后一个阶段,也是程序员编写的代码开始执行逻辑的开始,换一种理解,初始化就是执行类构造器<clinit>,<clinit>是javac编译器自动生成的。
<clinit>收集类变量的赋值动作和静态语句合并成,静态语句就是static{}块,这里是赋值语句执行的阶段,因此最终s1 = 1,s2= 3
private static int s2 = 3;
6、额外分析
这段代码没有涉及static块,如果代码是
public class Test2 {
public Test2() {
s1++;
s2++;
}
static {
s2 = 0;
}
private static Test2 test2 = new Test2();
private static int s1;
private static int s2 = 3;
public static void main(String[] args) {
System.out.println(test2.s1);
System.out.println(test2.s2);
}
}
那么s1和s2分别是什么值,答案还是
如果再做一下改动呢
public class Test2 {
public Test2() {
s1++;
s2++;
}
private static Test2 test2 = new Test2();
private static int s1;
private static int s2 = 3;
static {
s2 = 0;
}
public static void main(String[] args) {
System.out.println(test2.s1);
System.out.println(test2.s2);
}
}
最终是
可能有的同学已经猜到了,在初始化阶段对于赋值语句和static块是按照代码顺序进行编译运行的。
三、总结
看似简单的代码,实际上包含了许多知识,所以提倡多阅读源码和权威书籍,对于代码的执行要有原理上的理解,而不仅仅是开发和记住实践结果。