专栏原创出处:github-源笔记文件 (opens new window) ,github-源码 (opens new window),欢迎 Star,转载请附上原文出处链接和本声明。
Java JVM-虚拟机专栏系列笔记,系统性学习可访问个人复盘笔记-技术博客 Java JVM-虚拟机 (opens new window)
# 一、前言
前面我们分析过 从虚拟机的角度看对象的创建与访问 (opens new window)。
现在我们站在程序员角度,来看一下我们定义的一个个类及类里面的成员变量是怎么初始化的,分别什么时候初始化,以及初始化顺序和内存分配。
公网上的文章写一堆代码打印一些信息进行分析,有些把语句块的加载顺序都下结论了,这种理解对于初学者来说可行,但是随着深入的学习我们应该试着从虚拟机角度去分析整个过程。
本文从字节码及类加载过程结合虚拟机内存模型进行分析,无需进行大量源码打印,愿意验证的朋友可以采用该方法进行更复杂的初始化过程分析。
首先我们统一一下概念:
- 类变量,表示 static 修饰的成员变量
- 类常量,表示 static final 修饰的基本数据类型与字符串成员变量
- 静态语句块,使用 static{} 包起来的语句块
- 实例变量,表示随着类初始化而初始化的成员变量
- 实例构造器语句块,使用 {} 包起来的语句块
class Father {
// 类常量
public static final String STATIC_FINAL = "static-final-Father";
// 类变量
public static String STATIC = "static-Father";
// 类变量
public static final String STATIC_METHOD = staticMethod();
// 静态语句块
static {
System.out.println("static{}-Father");
System.out.println(STATIC_FINAL);
System.out.println(STATIC);
System.out.println(STATIC_METHOD);
}
// 类方法(静态方法)
public static String staticMethod() {
return "staticMethod";
}
// 实例变量
private String name = "name-Father";
private Object o = new Object();
// 实例构造器
public Father(String name) {
this.name = name;
System.out.println("Constructor-Father");
}
// 实例构造器语句块
{
System.out.println(name);
}
}
# 二、先下结论
如果在类里定义了类变量(static 修饰的变量),则为类生成一个类构造器 <clinit>
方法用于初始化类变量。同时为对象生成实例构造器 <init>
方法用于初始化对象实例。
在此确认理解了「类构造器」「对象实例构造器(构造函数)」这两个概念。
当我们第一次创建一个类的实例对象时,大体流程如下:
类加载中的准备阶段
- 会为类常量(static final 修饰的基本数据类型与字符串成员变量)在常量池分配内存并设值。
- 同时对类变量初始化(int 设为 0,对象设为 null 等)。
类加载中的初始化阶段,执行类构造器
<clinit>
方法为类变量赋值。此时,类变量初始化完成,类变量在类加载阶段仅初始化一次。(后续创建对象实例时不再初始化了)
使用 new 关键字创建对象实例时,虚拟机会将声明的实例变量、实例构造器语句块合并为一个
<init>
方法一起执行。
以上过程中如果加载类有父类,则先加载父类。如果 new 创建对象实例时,对象有父类,则先调用父类的构造方法。
# 三、逐步验证结论
# 1. 类变量的初始化及赋值
class Father {
public static final String STATIC_FINAL = "static-final-Father";
public static String STATIC = "static-Father";
public static final String STATIC_METHOD = staticMethod();
public static String staticMethod() {
return "staticMethod";
}
}
最终生成的 `<clinit>` 方法字节码:
0 ldc #10 将常量 <static-Father> 字符串压入栈顶
2 putstatic #11 将栈顶常量 <static-Father> 字符串赋值给 <Father.STATIC>
5 invokestatic #12 执行静态方法 <Father.staticMethod> 返回字符串
8 putstatic #13 将静态方法返回的字符串赋值给 <Father.STATIC_METHOD>
11 return
我们可以发现 STATIC_FINAL 常量并没有在 clinit 方法中,因为在准备阶段已经在常量池分配好了。
但是呢,STATIC_METHOD 我们也声明为 final 了,为什么还在 clinit 方法中赋值了呢?因为赋值的是一个方法调用,需要在类加载的初始化阶段调用一次方法进行赋值(字节码 8 putstatic
)。
# 2. 实例变量的初始化及赋值
class RefObjInit {
public final String stringFinal = "ref-final-ConstantInit";
public String string = "ref-ConstantInit";
public Object o = new Object();
}
最终生成的 `<init>` 方法字节码:
0 aload_0 // 将 this 压入栈顶
1 invokespecial #1 // 调用父类(Object.<init>)构造方法
4 aload_0 // 将 this 压入栈顶
5 ldc #2 // 将常量 <ref-final-ConstantInit> 压入栈顶
7 putfield #3 // 将常量赋值给 <RefObjInit.stringFinal>
10 aload_0 // 将 this 压入栈顶
11 ldc #4 // 将常量 <ref-ConstantInit> 压入栈顶
13 putfield #5 // 将常量赋值给 <RefObjInit.string>
16 aload_0 // 将 this 压入栈顶
17 new #6 // 创建一个 Object 准备赋值给 o
20 dup
21 invokespecial #1
24 putfield #7 // 创建的 Object 赋值给 o
27 return
我们可以发现该类中未声明任何实例构造器,但是编译器为我们生成的 <init>
方法对上面声明的实例变量进行了赋值操作。
虽然 stringFinal 实例变量声明为 final,但是还是进行一次赋值。
# 四、关于类成员内存分配
class 文件编译后会生成一个常量池。
常量池中主要存放两大类常量: 字面量和符号引用。字面量比较接近于 Java 语言层面的常量概念,如文本字符串、被声明为 final 的常量值等。符号引用可以理解为类、接口、字段、方法、方法句柄等描述符。
比如说 a.method() ,a 会编译为一个符号引用,在执行方法的时候 a 被替换为真正的实例对象引用。
字符串,分配在字符串常量池(JDK 8 后在字符串常量池分配在堆里)
类常量,如果基础数据类型分配在常量池。如果引用类型引用对象的实例数据分配在堆。
类变量,分配在常量池,如果是引用类型,引用对象的实例数据分配在堆。
实例变量,无论基础数据类型还是引用类型,都分配在堆。
局部变量(方法内部创建的变量)。基础数据类型分配在栈桢。引用类型引用分配在栈桢,引用的实例数据分配在堆上。
实例变量中的基础类型特例:如果在方法内创建了对象,但是经过「方法逃逸」分析后该对象并没有逃逸出方法,则可以在栈桢上直接分配基础类型,用完即毁。
此处声明一个误区
大部分人一般说内存分配时,直接会说基础数据类型分配在栈,对象数据在堆上是有问题的。这种理解是错误的。
class RefInit {
private int i = 10;
public RefInit o = new RefInit(); // o 里面的 i 字段在堆里
}
我们可以总结为在未被优化(比如逃逸分析)的概念中,对象的实例数据都是在堆上。对象的引用(指针)数据根据作用域及修饰词的不同,可能会被分配在常量池、栈桢的局部变量表、堆里。