类的生命周期和加载过程
类的生命周期可以划分为 7 个阶段
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
其中,第 1~5 阶段,即加载、验证、准备、解析、初始化,统称为「类加载」,如下图所示。
1.加载
加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class
对象,这个 Class
对象就是这个类各种数据的访问入口。
该过程可以总结为「JVM 加载 Class
字节码文件到内存中,并在方法区创建对应的 Class
对象」。
2.验证
当 JVM 加载完 Class
字节码文件,并在方法区创建对应的 Class
对象之后,JVM 便会启动对该字节码流的校验,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行。
这个校验过程,大致可以分为下面几个类型
- JVM 规范校验
0x cafe babe
- 代码逻辑校验
- JVM 会对代码组成的数据流和控制流进行校验,确保 JVM 运行该字节码文件后不会出现致命错误。
- 例如,一个方法要求传入
int
类型的参数,但是使用它的时候却传入了一个String
类型的参数。
3.准备
准备阶段中,JVM 将为类变量分配内存并初始化。
准备阶段,有两个关键点需要注意
- 内存分配的对象
- 初始化的类型
内存分配的对象
Java 中的变量有「类变量」和「类成员变量」两种类型。「类变量」指的是被 static
修饰的变量,而其他所有类型的变量都属于「类成员变量」。 在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始。
public static int factor = 3; public String website = "www.google.com"; 复制代码
如上代码,在准备阶段,只会为 factor
变量分配内存,而不会为 website
变量分配内存。
初始化的类型
在准备阶段,JVM 会为「类变量」分配内存并为其初始化。这里的「初始化」指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。
public static int sector = 3; 复制代码
如上代码,在准备阶段后, sector
的值将是 0,而不是 3。
如果一个变量是常量(被 static final
修饰)的话,那么在准备阶段,变量便会被赋予用户希望的值。 final
关键字用在变量上,表示该变量不可变,一旦赋值就无法改变。所以,在准备阶段中,对类变量初始化赋值时,会直接赋予用户希望的值。
public static final int number = 3; 复制代码
如上代码,在准备阶段后, number
的值将是 3,而不是 0。
4.解析
解析过程中,JVM 针对「类或接口」、「字段」、「类方法」、「接口方法」、「方法类型」、「方法句柄」、「调用点限定符」这 7 类引用进行解析。解析过程的主要任务是将其在常量池中的符号引用,替换成其在内存中的直接引用。
5.初始化
到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。在这个阶段,JVM 会根据语句执行顺序对类对象进行初始化。
一般来说,当 JVM 遇到下面 5 种情况的时候会触发初始化
- 遇到
new
、getstatic
、putstatic
、invokestatic
这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。- 生成这 4 条指令的最常见的 Java 代码场景是使用
new
关键字实例化对象的时候、读取或设置一个类的静态字段(被final
修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 生成这 4 条指令的最常见的 Java 代码场景是使用
- 使用
java.lang.reflect
包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。 - 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含
main()
方法的那个类),虚拟机会先初始化这个主类。 - 当使用 JDK 1.7 动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果是REF_getstatic
、REF_putstatic
、REF_invokeStatic
的方法句柄,并且这个方法句柄所对应的类没有进行初始化时,则需要先出触发其初始化。
6.使用
当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。
7.卸载
当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class
对象,最后负责运行的 JVM 也退出内存。
对类加载的理解
下面,将通过几个案例,对类加载的 5 个阶段加深理解。
类初始化方法和对象初始化方法
public class Book { public static void main(String[] args) { System.out.println("Hello Liu Baoshuai"); } Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } { System.out.println("书的普通代码块"); } int price = 110; static{ System.out.println("书的静态代码块"); } static int amount = 112; } 复制代码
运行上述代码,输出信息如下。
书的静态代码块 Hello Liu Baoshuai 复制代码
下面对输出结果进行分析。
根据「类的生命周期和加载过程 / 5.初始化」章节中提到的「当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()
方法的那个类),虚拟机会先初始化这个主类」可知,我们将会进行类的初始化。
Java 源代码中有构造方法这个概念。但编译为字节码后,是没有构造方法这个概念的,只有「类初始化方法」和「对象初始化方法」。
- 「类初始化方法」
- 编译器会按照代码出现的顺序,收集类变量的赋值语句、静态代码块,最终组成类初始化方法。
- 类初始化方法一般在类初始化的时候执行。
上面的例子中,其类初始化方法如下。
static { System.out.println("书的静态代码块"); } static int amount = 112; 复制代码
- 「对象初始化方法」
- 编译器会按照代码出现的顺序,收集成员变量的赋值语句、普通代码块, 最后 收集构造函数的代码,最终组成对象初始化方法。 注意,构造函数的代码一定是被放在最后的。
- 对象初始化方法一般在实例化类对象的时候执行。
上面的例子中,其对象初始化方法如下。
{ System.out.println("书的普通代码块"); } int price = 110; //注意,构造函数的代码一定是被放在最后的 Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } 复制代码
结合「类初始化方法」和「对象初始化方法」的分析,再回过头看上述例子,就不难得出结论了。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。所以开始执行「初始化」过程。
main
方法中,并没有实例化对象,所以只执行「类初始化方法」,如下所示。因此,会输出书的静态代码块
。
static { System.out.println("书的静态代码块"); } static int amount = 112; 复制代码
- 初始化过程执行完毕后,继续执行
main()
方法。因此,会输出Hello Liu Baoshuai
。
案例引申
下面,对上述测试案例进一步引申,修改 main()
方法,代码如下所示。
public class Book { public static void main(String[] args) { System.out.println("Hello Liu Baoshuai" + new Book().price); } Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } { System.out.println("书的普通代码块"); } int price = 110; static{ System.out.println("书的静态代码块"); } static int amount = 112; } 复制代码
运行上述代码,输出信息如下。
书的静态代码块 书的普通代码块 书的构造方法 price=110,amount=112 Hello Liu Baoshuai110 复制代码
下面对输出结果进行分析。
书的静态代码块
static { System.out.println("书的静态代码块"); } static int amount = 112; 复制代码
- 「类初始化方法」执行完毕后,继续执行
main()
方法。遇到了new Book()
语句,所以触发执行「对象初始化方法」,如下所示。
// part 1 { System.out.println("书的普通代码块"); } // part 2 int price = 110; // part 3 Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } 复制代码
- 需要注意的是,
part 1
和part 2
的先后顺序,是根据它们在代码中出现的顺序决定的。part 3
部分是构造函数部分,这部分永远是出现最后的,和它在代码中的顺序无关。在代码中,part 3
部分虽然出现在part 1
和part 2
的前面,但在「对象初始化方法」中,它永远是出现在最后的。 - 此外,由于
part 2
出现在part 3
前面,所以输出price
的值是 110,而不是 0。
继承关系下类的加载情况
class Grandpa { static { System.out.println("爷爷在静态代码块"); } } class Father extends Grandpa { static { System.out.println("爸爸在静态代码块"); } public static int factor = 25; public Father() { System.out.println("我是爸爸~"); } } class Son extends Father { static { System.out.println("儿子在静态代码块"); } public Son() { System.out.println("我是儿子~"); } } public class InitializationDemo { public static void main(String[] args) { System.out.println("爸爸的岁数:" + Son.factor); //入口 } } 复制代码
运行上述代码,输出信息如下。
爷爷在静态代码块 爸爸在静态代码块 爸爸的岁数:25 复制代码
下面对输出结果进行分析。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。所以开始执行「初始化」过程。
main
方法中,并没有实例化对象,所以只执行「类初始化方法」,不会执行「对象初始化方法」。- 根据「类的生命周期和加载过程 / 5.初始化」章节中提到的「当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化」可知,进行
Son
初始化时,会先进行父类Father
的初始化。同理,进行Father
初始化时,会先进行父类Grandpa
的初始化。所以,程序会输出如下信息。
爷爷在静态代码块 爸爸在静态代码块 复制代码
- 继续,执行
main()
方法中的System.out.println
语句,程序会输出爸爸的岁数:25
。
也许会有人问为什么没有输出「儿子在静态代码块」这个字符串?这是因为对于静态字段,只有直接定义这个字段的类才会被初始化,才会执行该类的「类初始化方法」。因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
继承关系下实例化对象
class Grandpa { static { System.out.println("爷爷在静态代码块"); } public Grandpa() { System.out.println("我是爷爷~"); } } class Father extends Grandpa { static { System.out.println("爸爸在静态代码块"); } public Father() { System.out.println("我是爸爸~"); } } class Son extends Father { static { System.out.println("儿子在静态代码块"); } public Son() { System.out.println("我是儿子~"); } } public class InitializationDemo { public static void