直接看一个例程
public class JvmClassTest {
public static JvmClassTest OBJ = new JvmClassTest();
public static int A;
public static int B = 0;
//public static JvmClassTest OBJ = new JvmClassTest();
static {
System.out.println("A:" + A);
System.out.println("B:" + B);
}
public JvmClassTest() {
A++;
B++;
}
public static void main(String[] args) throws Exception {
JvmClassTest.B = -1;
}
}
看上去很绕,可以走读一遍自己给出一个打印结果。。。。
正确打印结果如上,这个看上去很诡异。A的值应该是int的默认值0,B显式赋值为0了,然后A++、B++,为何结果不同???稍微修改一下,将line4与line7互换一下
/*【代码2】*/
//public static JvmClassTest OBJ = new JvmClassTest();
public static int A;
public static int B = 0;
public static JvmClassTest OBJ = new JvmClassTest();
执行:
如我所愿了。。。。只是调换了一下代码顺序而已,这是为什么呢?而且为什么只有B会受影响,而A不会受影响呢?解释这个问题只是需要去了解jvm在“真正执行一个java类的main()”之前,做了哪些事情?简单的说,有三件事情
- 加载
- 连接(其中又分解为验证、准备、解析三个步骤)
- 初始化
加载
它直接表现出来的代码应该是ClassLoader.getSystemClassLoader().loadClass("com.my.test.AbcClass")。所以具体完成类的加载工作的,是常被提到的类加载器ClassLoader,它就是专门干这件事的。“类的加载”具体而言就是指将类.class文件中的二进制数据读入到内存中将其放在方法区内,然后在堆区创建一个java.lang.Class对象,所以说“加载”的最终产出是堆中的一个Class对象,它一产生,加载这件事就干完了。这里又引出一个东东——方法区
上图截自毕玄大师的ppt,描绘了jvm内存的布局,书中都会说到方法区中存放的是类信息、类的field信息、方法信息都在其中;另外以前听到过一种说法:“堆(新生代+老生代)是留给java开发人员使用的,非堆(持久带即方法区)是留给jvm自己使用的”。再回过头看上面的描述——“类的加载”就是指将类的.class文件中的二进制数据读入到内存中将其放在方法区内,然后在堆区创建一个java.lang.Class对象,换句换说“类的加载”就是为了给程序员一个可以获得类相关定义信息的窗口,这个窗口就是Class对象,类加载的过程中将方法区的结构化类定义信息映射到堆里的一个实体Class对象中,进而程序员可以通过这道桥梁最终得到该类的一个实例,比如调用Class的newInstance()。
类的加载时机
目前我理解类的加载时机不受程序员控制,由jvm自己控制,或许它需要考虑一些优化策略,比如对于一些jvm认为未来很可能需要用到的类,jvm可以在空闲时提前加载,即提前准备好堆中的Class对象。类加载最迟的时机应该很明确,等同于类的初始化时机,下面说初始化时会说到。
连接
类的连接,就分开来讲它的每个子步骤吧
- 验证:顾名思义,这一步会做java基础语法检查、会做字节码验证、做二进制兼容的验证,这几个验证具体做的是什么,可能需要专门深挖了。总之,验证就是做各种验证涉及类文件、字节码、语法语义等等各方面
- 准备:这一步理解起来很具体,就是为类的static变量开辟堆内存,并赋上默认值(java各类型的默认值不同)
- 解析:在类型的常量池中寻找类、接口、字段和方法的符号引用,把这些符号引用替换成直接引用。比如在A类中实例化了B类的一个对象并调了一个方法,b.test(),在解析之前,这个b.test()只是指向一个描述符(该描述符存在Class对象专属的独立常量池中),解析之后,它就被替换成一个真实的方法指针,指向方法区中B类test()方法的那一段(这一段内存相当于描述了test方法应该如何执行),这样未来才能完成方法的执行动作
类的连接时机
查了很久,没有查到,目前暂时yy认为他是跟在类加载后面的吧。。。
初始化
千辛万苦走到最后一步初始化。类的初始化可以用一句明确的话来描述——“就是按顺序把类中的static代码执行一遍”,若是对static变量赋值,则进行赋值操作(连接时只是开辟内存并给默认值,此时才给static变量赋上我们代码里写的初始值);若是static代码块,则将static块执行一遍。另外值得重点说明的是两点,初始化的时机与初始化的步骤
类的初始化时机
在以下6种场景下,才会触发“类的初始化”这件事
- 创建类的实例
- 访问类或接口的statis变量,或者对statis变量赋值
- 调用类的statis方法
- 调用反射(如Class.forName(“com.my.test.Test”))
- 初始化一个类的子类
- Jvm启动时被标明为启动类的类(就是命令行执行java时指定的那个带有main方法的类,就是启动类)
类的初始化步骤
下图可以看出,若发生上面的6个情况中的任何1个,会触发了类的初始化,若此时该类还没有做加载&连接,会连带触发做加载&连接,但是另一种情况是在此之前jvm已经未雨绸缪地提前完成了加载&连接,这个自然是jvm设计者期望看到的情况。下图可以看出,父类有优先的初始化权利。
-------------------------------------------分割线-------------------------------------------
说完以上这些,开篇的疑惑就可以解除了。。。
【代码1】的执行顺序如下:
- 加载&连接类JvmClassTest,“连接”中的“准备”过程会为分别为三个static变量开辟对内存,并给默认值
- int A 开辟4字节,给默认值0
- int B 开辟4字节,给默认值0
- JvmClassTest OBJ 给默认值null
- 下面进入类的初始化阶段,按顺序做static动作
- static JvmClassTest OBJ = new JvmClassTest()调用了构造方法,故A++B++,此时A由0->1,B由0->1
- static int A这句无赋值动作,什么也不做
- static int B = 0,这句B由1->0
- 最终,static代码块打印出了刚才看到的 A:1,B:0
- 初始化完成之后,开始执行main方法,B由0->-1。完毕,jvm进程终止。
而【代码2】中只是相当于将1.放到了3.之后,所以得到了不同的结果。
最后还有一个问题可以问一下自己,上面这个代码对应到“触发类初始化的6个场景”中,应该对上哪一条呢?“2.访问类或接口的statis变量,或者对statis变量赋值”,看上去是这个,是吗?通过代码还是可以验证的,只要在main()方法第一句加一个打印
/*【代码3】*/
public static void main(String[] args) throws Exception {
System.out.println("test");
JvmClassTest.B = -1;
}
执行结果如下:
发现static块先执行了,而后才执行的System.out.println("test"),当时我看到这个结果之后,才恍然大悟,“6. Jvm启动时被标明为启动类的类”,Eclipse执行时将该类作为启动类拉,java命令一执行,当场就触发了JvmClassTest类的初始化,进而连带触发了加载&连接动作,而后执行JvmClassTest类的初始化,初始化完毕之后才开始步入main()方法体(此时此刻,static代码块已经在控制台打出了A、B的值了。。。),可能例程代码可以更纯粹一点。。。。。
/*【代码4】*/
public class JvmClassTest {
//public static JvmClassTest OBJ = new JvmClassTest();
public static int A;
public static int B = 0;
public static JvmClassTest OBJ = new JvmClassTest();
static {
System.out.println("A:" + A);
System.out.println("B:" + B);
}
public JvmClassTest() {
A++;
B++;
}
public static void main(String[] args) throws Exception {
//无
}
}