在 Java 中 new 是一个关键字,在字节码中也有一个指令 new。当我们创建一个对象时,背后发生了哪些事情呢?
ScoreCalculator calculator = new ScoreCalculator();
对应的字节码如下:
0: new #2 // class ScoreCalculator
3: dup
4: invokespecial #3 // Method ScoreCalculator."<init>":()V
7: astore_1
一个对象创建的套路是这样的:new
、dup
、invokespecial
,下次遇到同样的指令要形成条件反射。
为什么创建一个对象需要三条指令呢? 首先,我们需要清楚类的构造器函数是以<init>
函数名出现的,被称为实例的初始化方法。调用 new 指令时,只是创建了一个类的实例,但是还没有调用构造器函数,使用 invokespecial
调用了 <init>
后才真正调用了构造器函数,但是,使用invokespecial命令会从
操作数堆栈中弹出nargs参数值和objectref,
正是因为需要调用这个函数才导致中间必须要有一个 dup 指令,不然调用完<init>
函数以后,操作数栈为空,就再也找不回刚刚创建的对象了。
(注:在Java虚拟机级别,使用Java编程语言编写的每个构造函数都显示为具有特殊名称的实例初始化方法<init>
。该名称由编译器提供。因为名称<init>
不是有效的标识符,所以它不能直接用在用Java编程语言编写的程序中。实例的初始化方法可能仅在由Java虚拟机调用invokespecial指令,它们只能在未初始化的类实例上调用。实例初始化方法采用从中派生的构造函数的访问权限 —— 摘自Java虚拟机规范)
前面我们知道 <init>
其实就是构造器函数,而invokespecial
会调用 <init>
,也就是 调用构造器函数,但其实<clinit>
是类或接口的初始化方法比 <init>
调用得更早一些,不过<clinit>
不会直接被调用,它在下面这个四个指令触发调用:new, getstatic, putstatic or invokestatic
。也就是说,初始化一个类实例、访问一个静态变量或者一个静态方法,类的静态初始化方法就会被触发。
看一个具体的例子
public class Initializer {
static int a;
static int b;
static {
a = 1;
b = 2;
}
}
部分字节码如下
static {};
0: iconst_1
1: putstatic #2 // Field a:I
4: iconst_2
5: putstatic #3 // Field b:I
8: return
上面的 static {}
就对应我们刚说的 <clinit>
下面我们来看个面试题:
public class A {
static {
System.out.println("A init");
}
public A() {
System.out.println("A Instance");
}
}
public class B extends A {
static {
System.out.println("B init");
}
public B() {
System.out.println("B Instance");
}
}
问题 1: A a = new B(); 输出结果及正确的顺序?
要搞懂这个问题,我一步一步来,先看看A a = new B();的字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class FontCode/B
3: dup
4: invokespecial #3 // Method FontCode/B."<init>":()V
7: astore_1
8: return
第一个调用的指令是new指令,我们先来看看《Java虚拟机规范》中new指令的定义:
可以看到几个重要的地方:
1.成功解析该类后,如果尚未初始化,则将其初始化(第5.5节)。
2.该指令不完全创建一个新的实例; 在未初始化的实例上调用实例初始化方法(第2.9节)之前,实例创建尚未完成。
我们来看看第5.5节初始化讲了什么:
5.5。初始化
初始化一个类或接口包括执行其类或接口初始化方法(的 §2.9)。
只有以下结果才能初始化类或接口:
1.Java虚拟机指令的任何一个的执行new,getstatic,putstatic,或 invokestatic引用的类或接口(§ new,§ getstatic, § putstatic, 第invokestatic)。所有这些指令都通过字段引用或方法引用直接或间接引用类。
2.在执行 new指令时,如果尚未初始化引用的类或接口,则对其进行初始化。
3.执行 getstatic,putstatic或invokestatic指令后,如果尚未初始化,则声明已解析的字段或方法的类或接口已初始化。
4.第一次调用java.lang.invoke.MethodHandle实例,该实例是Java虚拟机(第5.4.3.5节)解析方法句柄并且具有2(REF_getStatic),4(REF_putStatic)或6(REF_invokeStatic)类型的结果。
5.在类库(第2.12节)中调用某些反射方法,例如,在类Class或包中java.lang.reflect。
6.其子类之一的初始化。
它被指定为Java虚拟机启动时的初始类(第5.2节)。
在初始化之前,必须链接一个类或接口,即验证,准备和可选地解析。
由于Java虚拟机是多线程的,因此初始化类或接口需要仔细同步,因为某些其他线程可能正在尝试同时初始化相同的类或接口。作为该类或接口的初始化的一部分,还可以递归地请求类或接口的初始化。Java虚拟机的实现负责通过使用以下过程来处理同步和递归初始化。它假定该Class对象已经过验证和准备,并且该对象已经过验证Class object包含指示以下四种情况之一的状态:
此Class对象已经过验证和准备但未初始化。
该Class对象由某个特定线程初始化。
此Class对象已完全初始化并可供使用。
此Class对象处于错误状态,可能是因为尝试初始化并失败。
对于每个类或接口 C,都有一个唯一的初始化锁LC。从C 到C的映射由LCJava虚拟机实现决定。例如,LC可以是C的Class对象,也可以是与该对象关联的监视器。初始化C的过程 如下: Class
1.LC对于C,初始化锁定同步。这涉及等到当前线程可以获取LC。
2.如果C的Class对象指示某个其他线程正在对C进行初始化,则释放并阻止当前线程,直到通知正在进行的初始化已完成,此时重复此过程。
3.如果C的Class对象指示当前线程正在为C进行初始化,那么这必须是初始化的递归请求。正常发布并完成。
4.如果C的Class对象表明C已经初始化,则不需要进一步的操作。正常发布并完成。
5.如果C的 Class对象处于错误状态,则无法进行初始化。释放并抛出一个 。 LCNoClassDefFoundError
6.否则,记录当前线程正在进行CClass对象初始化并释放的事实。然后,按照字段在结构中出现的顺序,使用其属性(第4.7.2节)中的常量值 初始化C的每个字段。 LCfinal staticConstantValueClassFile
7.接下来,如果C是类而不是接口,并且其超类SC尚未初始化,则递归地执行SC的整个过程 。如有必要,请先验证并准备SC。
如果SC的初始化由于抛出的异常而突然完成,则获取LC,将C的Class对象标记为错误,通知所有等待的线程,释放并突然完成,抛出因初始化SC而导致的相同异常。
8.接下来,通过查询其定义的类加载器来确定是否为C启用了断言。
9.接下来,执行C的类或接口初始化方法。
10.如果类或接口初始化方法的执行正常完成,则获取LC,将C的Class对象标记为完全初始化,通知所有等待的线程,释放并正常完成此过程。
11.否则,类或接口初始化方法必须通过抛出一些异常E而突然完成。如果E的类不是Error其子类或其子类之一,则ExceptionInInitializerError使用E作为参数创建类的新实例 ,并在以下步骤中 使用此对象代替E.
如果ExceptionInInitializerError由于OutOfMemoryError发生而无法创建新实例,则在以下步骤中 使用OutOfMemoryError对象代替 E.
12.获取LC,将C的Class对象标记 为错误,通知所有等待的线程,释放,并在原因E 或其替换中突然完成此过程,如上一步骤中所确定的。
Java虚拟机实现可以通过在步骤1中删除锁获取(并在步骤4/5中释放)来优化此过程,此时它可以确定类的初始化已经完成,前提是,就Java内存模型而言,所有发生的 - 在获得锁之前存在的排序(JLS§17.4.5),在执行优化时仍然存在。
可以看到几个重要的地方:
1.
只有以下结果才能初始化类或接口:
-
Java虚拟机指令的任何一个的执行new,getstatic,putstatic,或 invokestatic引用的类或接口(§ new,§ getstatic, § putstatic, 第invokestatic)。所有这些指令都通过字段引用或方法引用直接或间接引用类。
在执行 new指令时,如果尚未初始化引用的类或接口,则对其进行初始化。
执行 getstatic,putstatic或invokestatic指令后,如果尚未初始化,则声明已解析的字段或方法的类或接口已初始化。
-
第一次调用
java.lang.invoke.MethodHandle
实例,该实例是Java虚拟机(第5.4.3.5节)解析方法句柄并且具有2(REF_getStatic
),4(REF_putStatic
)或6(REF_invokeStatic
)类型的结果。 -
在类库(第2.12节)中调用某些反射方法,例如,在类
Class
或包中java.lang.reflect
。 -
它被指定为Java虚拟机启动时的初始类(第5.2节)。
2.初始化类或接口C的过程中的第7条:
如果C是类而不是接口,并且其超类SC尚未初始化,则递归地执行SC的整个过程 。如有必要,请先验证并准备SC。
如果SC的初始化由于抛出的异常而突然完成,则获取LC
,将C的Class
对象标记为错误,通知所有等待的线程,释放并突然完成,抛出因初始化SC而导致的相同异常。
初始化类或接口C的过程中的第7条:
接下来,执行C的类或接口初始化方法。
我们再来看下《深入理解Java虚拟机》中是怎么描述的:
也就是说,如果初始化的是类,而且该类的父类并没有初始化,那么会先执行父类的整个初始化过程,之后再执行类或接口的初始化方法,如果是接口,只有真正使用到父接口的时候才会初始化。
所以:当执行new命令的时候,会先进行加载解析,解析成功后,如果尚未初始化,则会进行初始化,
在初始化过程中,如果发现其父类尚未初始化,则会对其父类进行初始化,然后才会执行类或接口初始化方法,也就是<clinit>,上面说到的static{}方法。
现在我们回到题目中,再来看看A a = new B();的字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class FontCode/B
3: dup
4: invokespecial #3 // Method FontCode/B."<init>":()V
7: astore_1
8: return
这是执行new指令时的过程:
1.先是对B类执行new指令,在执行的new的时候,因为B类并没有初始化过,所以会对B类进行初始化。
2.在初始化过程中,发现父类A没有初始化,则对A进行初始化,过程中会执行A的static方法。
3.A初始化完成后,会执行B的static方法。
我们再来看看B和A的字节码分别是怎样的:
{
public FontCode.B();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method FontCode/A."<init>":()V
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String B Instance
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: return
LineNumberTable:
line 8: 0
line 9: 4
line 10: 12
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this LFontCode/B;
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String B init
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
{
public FontCode.A();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String A Instance
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: return
LineNumberTable:
line 7: 0
line 8: 4
line 9: 12
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this LFontCode/A;
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String A init
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
在new指令中,先调用了A的static,再调用了B的static。
输出的结果就是:
A init
B init
dup指令在开头提到过,这里就不提了。
最后我们再来看看A a = new B();的字节码中 4: invokespecial #3 // Method FontCode/B."<init>":()V 这一条的执行过程。
老样子,我们先看看Java虚拟机规范中的invokespecial指令是怎么描述的:
在描述的中间有一段
这里描述在invokespecial调用某方法时,会递归调用父类的名称和描述符都一致的方法,也就是说,在invokespecial调用构造函数方法时,会先调用父类的父类(如果有)的构造函数方法。
所以,当4: invokespecial #3 // Method FontCode/B."<init>":()V 这句指令执行的时候,会先调用A的构造函数方法,然后再调用B的构造函数方法。
而且我们可以在B的字节码中看到,他也默默的调用了A的构造函数:
所以,4: invokespecial #3 // Method FontCode/B."<init>":()V 这句指令的输出结果是:
A Instance
B Instance
问题 1: A a = new B(); 输出结果及正确的顺序?这个问题的答案就是:
A init
B init
A Instance
B Instance
问题 2:B[] arr = new B[10]
会输出什么?
首先,我们还是看看他的字节码:
就三个指令,bipush,anewarray,astore_1
anewarray 接收栈顶的元素(数组的长度),新建一个数组引用。由此可见新建一个 B 的数组没有触发任何类或者实例的初始化操作。所以问题 2 的答案是什么也不会输出。
问题3:如果把 B 的代码稍微改一下,新增一个静态不可变对象,调用System.out.println(B.HELLOWORD)
会输出什么?
public class B extends A {
public static final String HELLOWORD = "hello word";
static{
System.out.println("B init");
}
public B() {
System.out.println("B Instance");
}
}
public class InitOrderTest {
public static void main(String[] args) {
System.out.println(B.HELLOWORD);
}
}
我们先把字节码拿出来看看:
可以看到,根本没有A类或者B类的任何信息,所以题目 3 的答案除了"hello world"以外什么也不会输出。
到此,本文结束,最后再补充下关于<clinit>初始化方法到底是什么。
这个是引用自<<深入理解Java虚拟机>>中的,除此之外所有图都是引用自<<Java虚拟机规范>>。
最后,本文所有题目以及大部分内容都是来源于掘金小册中挖坑的张师傅的著作JVM 字节码从入门到精通,感兴趣的同学可以入手,价格不贵。
这篇博客我写了近10天,就是为了找一些疑惑的地方,比如,为什么调用<clinit>的时候会先调用父类的<clinit>?为什么调用构造函数会先调用父类的构造函数?为什么<clinit>是static{}?还好这些问题最终都从书中找到了答案。
如果有哪里写的不对的地方,请留言指正!