类的生命周期概述
类的生命周期描述了一个类加载、使用、卸载的整个过程。整体可以分为:
- 加载
- 连接,其中又分为验证、准备、解析三个子阶段
- 初始化
- 使用
- 卸载
加载阶段
加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息,程序员可以使用Java代码拓展的不同的渠道。
- 从本地磁盘上获取文件
- 运行时通过动态代理生成,比如Spring框架
- Applet技术通过网络获取字节码文件
类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中,方法区中生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。
Java虚拟机同时会在堆上生成与方法区中数据类似的java.lang.Class对象,作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)。
连接阶段
连接阶段分为三个子阶段:
- 验证,验证内容是否满足《Java虚拟机规范》。
- 准备,给静态变量赋初值。
- 解析,将常量池中的符号引用替换成指向内存的直接引用。
验证
验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。主要包含如下四部分,具体详见《Java虚拟机规范》:
1、文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
2、元信息验证,例如类必须有父类(super不能为空),Java默认所有类都继承了Object这个顶级父类。
3、验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置。
4、符号引用验证,例如是否访问了其他类中private的方法等。
对版本号的验证,在JDK8的源码中如下:
编译文件的主版本号不能高于运行环境主版本号,如果主版本号相等,副版本号也不能超过。
准备
准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。
数据类型 | 初始值 |
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用数据类型 | null |
如下代码在准备阶段会为value分配内存并赋初值为0,在初始化阶段才会将值修改为1。
public class Student{
public static int value = 1;
}
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
在上面的例子中,变量加上final进行修饰,在准备阶段value值就直接变成1了,因为final修饰的变量后续不会发生值的变更。
从字节码文件也可以看到,编译器已经确定了该字段指向了常量池中的常量2:
import java.io.IOException;
public class HsdbDemo {
public static final int i = 2;
public HsdbDemo() {
}
public static void main(String[] args) throws IOException, InstantiationException, IllegalAccessException {
new HsdbDemo();
System.out.println(2);
System.in.read();
}
}
解析
解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容,直接引用就是指向具体的内存地址。
直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。
初始化阶段
初始化阶段会执行字节码文件中clinit(class init 类的初始化)方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值。
如下代码编译成字节码文件之后,会生成三个方法:
- init方法,会在对象初始化时执行
- main方法,主方法
- clinit方法,类的初始化阶段执行
public class Demo1 {
public static int value = 2;
public Demo1() {
}
public static void main(String[] args) {
}
static {
value = 1;
}
}
继续来看clinit方法中的字节码指令:
1、iconst_1,将常量1放入操作数栈。此时栈中只有1这个数
2、putstatic指令会将操作数栈上的数弹出来,并放入堆中静态变量的位置,字节码指令中#2指向了常量池中的静态变量value,在解析阶段会被替换成变量的地址。
3、后两步操作类似,执行value=2,将堆上的value赋值为2。
如果将代码的位置互换:就会先执行静态代码块得初始化,再执行显式赋值的值
public class Demo1 {
static {
value = 2;
}
public static int value = 1;
public static void main(String[] args) {
}
}
因为字节码指令的位置也会发生变化,这样初始化结束之后,最终value的值就变成了1而不是2。
触发类的初始化
以下几种方式会导致类的初始化:
1.访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化,因为此时可以直接从常量池中找到这个变量,不需要访问类信息。
2.调用Class.forName(String className)。
3.new一个该类的对象时。
4.执行Main方法的当前类。
面试题1
public class Test1 {
public static void main(String[] args) {
System.out.println("A");
new Test1();
new Test1();
}
public Test1(){
System.out.println("B");
}
{
System.out.println("C");
}
static {
System.out.println("D");
}
}
分析步骤:
1、执行main方法之前,先执行clinit指令。执行静态代码块的初始化,指令会输出D
2、执行main方法的字节码指令。指令会输出A
3、创建两个对象,会执行两次对象初始化的指令。
这里会输出CB,源代码因为代码块的执行在构造器的前面执行,输出C这行,被放到了对象初始化的一开始来执行。所以最后的结果应该是DACBCB
面试题2
public class Demo01 {
public static void main(String[] args) {
new B02();
System.out.println(B02.a);
}
}
class A02{
static int a = 0;
static {
a = 1;
}
}
class B02 extends A02{
static {
a = 2;
}
}
分析步骤:
1、调用new创建对象,需要初始化B02,优先初始化父类。
2、执行A02的初始化代码,将a赋值为1。
3、B02初始化,将a赋值为2。
将new B02();注释掉会怎么样?
分析步骤:
1、访问父类的静态变量,只初始化父类。
2、执行A02的初始化代码,将a赋值为1。
补充练习题
数组的创建不会导致数组中元素的类进行初始化。
public class Test2 {
public static void main(String[] args) {
Test2_A[] arr = new Test2_A[10];
}
}
class Test2_A {
static {
System.out.println("Test2 A的静态代码块运行");
}
}
通过查看字节码文件,我们发现只初始化了Object这个类
final修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化。
public class Test4 {
public static void main(String[] args) {
System.out.println(Test4_A.a);
}
}
class Test4_A {
public static final int a = Integer.valueOf(1);
static {
System.out.println("Test3 A的静态代码块运行");
}
}
clinit不会执行的几种情况
1.无静态代码块且无静态变量赋值语句。
2.有静态变量的声明,但是没有赋值语句。
3.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。