类的生命周期是指一个类被加载,使用,卸载的一个过程,如下图:
类的加载阶段:
-
加载(Loading)阶段第一步是类加载器根据类的**全限定名(也就是类路径)**通过不同的渠道以二进制流的方式获取字节码信息。程序员可以通过Java代码来扩展不同的渠道(后面会有讲解),可以使用以下几种渠道:
- 本地文件
- 动态代理生成(类加载器是如何加载动态代理生成的class 的)
- 网络传输的类
-
类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区(jdk8之后好像叫元空间)中。
疑问:为什么类的字节码信息是保存到方法区中?方法区不应该只保存和方法有关的内容么
解答:方法区只是一个名字,并不一定全都是保存方法信息的
-
在方法区中生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。该对象中包含:
- 基本信息
- 常量池
- 字段
- 方法
- 虚方法表
-
同时,Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象。作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)。java.lang.Class对象通过一个引用和InstanceKlass对象彼此关联,例如:在使用反射的时候要通过class来获取字段名称,方法等相关信息,这个就是为什么要生成java.lang.Class对象的原因:
InstanceKlass和java.lang.Class对象的区别?
在Java虚拟机中InstanceKlass和java.lang.Class这两个对象都记录了类的一些信息,java.lang.Class对象中的信息要少于InstanceKlass中的信息,java.lang.Class对象只记录字段,方法等相关信息不记录多余的信息,例如: InstanceKlass中的虚方法表,这张表是Java虚拟机底层在实现多态的时候去使用的,而对于开发者信息这块儿的内容是完全不需要去使用的,所以从安全性去考虑,则将InstanceKlass对象中开发人员需要的相关参数拷贝到java.lang.Class对象中,以防止误操作
查看内存中的对象
可以使用下图中的工具(看了下jdk11和jdk17 目录下都没有应该需要单独下载,只有jdk8有,应该是版本的变动导致的)
java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
类加载机制的连接阶段
连接阶段分为三部分:验证,准备,解析
验证阶段
连接(Linking)阶段的第一个环节是验证,验证的主要目的是检测ava字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。主要包含如下四部分,具体详见《)ava虚拟机规范》:
- 文件格式验证,比如文件是否以OxCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
- 验证字节码文件的结构是否符合Class文件格式的规范。
- 检查文件的魔数(0xCAFEBABE)、版本号等基本信息是否正确。
- 元信息验证,验证Java语法是否正确
- 验证类的元数据信息是否符合Java语言规范。例如:把Integer 写成了integer,其实就是校验Java语法
- 包括对类、字段、方法等结构的验证,确保没有重复定义,类型描述符是否合法等。
- 字节码验证:
- 栈的数据类型和操作码是否与操作参数符合:主要验证栈的数据类型所需要的空间是否超出,例如:栈有四个操作字节的空间(),但是实际占用的空间远远大于四个字节,这时在验证阶段就会报错
- 字节码验证能否跳转到合适的位置,例如:整个字节码只有100行,而有个指令要求跳转到101行,在验证阶段就会报错
- 符号引用验证:
- 某些字段或者变量是否存在访问权限,例如是否访问了其他类中private的方法等。
- 验证符号引用是否可以解析为实际存在的类、字段和方法。
- 确保符号引用的类型、方法签名等符合Java语言规范,确保在运行时能够正确解析和链接。
例如版本号的检测:
准备阶段
准备阶段只会给静态变量赋初始值,而每一种基本数据类型和引用数据类型都有其初始值。例如:int 类型的初始值为0
但是如果使用final 进行修饰的变量进行赋值则会在编译期(就是Java文件转换为class 文件后,字节码文件中就已经确定静态变量的值了)就可以确定静态变量的值(如下所示):
这个有个问题,下面的代码时成员变量,如果非成员变量的情况下准备阶段是否也能在编译器就确定某些变量的值(应该是可以的,回头用Java命令确定一下)
public class Student{
public static final int value =1;
}
解析阶段
解析阶段主要是将常量池中的符号引用替换为直接引用。符号引用就是在字节码文件中使用编号来访问常量池中的内容。
- **符号引用:**符号引用这里还不是很清晰,回头找找资料
- **直接引用:**直接引用则是有具体引用地址的指针,被引用的类、方法或者变量已经被加载到内存中。
类的生命周期初始化阶段
准备阶段和初始化阶段的区别
初始化阶段会执行静态代码块中的代码,并为静态变量赋值,这里和之前准备阶段的赋值时有区别的,准备阶段是给变量赋默认值,例如:
public static int value = 1;
这段代码在准备阶段的值是0因为int 类型没有赋值之前的值默认都为0,而在初始化阶段才会把当前的真正将1的值赋值给value字段。
init 指令
该指令是构造方法的指令
clinit指令
初始化阶段会执行字节码文件中clinit部分的字节码指令,这里的clinit 中cl 代表类(class),而init 代表初始化,所以这里代表的就是类的初始化,所以初始化阶段执行的就是字节码文件中的clinit部分的指令
静态代码块执行顺序问题
有这样一段代码:
public static int value = 1;
static {
value = 2;
}
public static void main(String[]args){
}
这段代码的字节码指令:
0 iconst_1
1 putstatic #2 <com/jvm/Test.value : I>
4 iconst_2
5 putstatic #2 <com/jvm/Test.value : I>
8 return
此时我们发现最后value字段的值是2,如果此时我们将静态代码块往上移动:
static {
value = 2;
}
public static int value = 1;
public static void main(String[]args){
}
下面是上面代码的字节码指令,我们会发现最后value 字段的值为1,所以这里可以看出字节码指令clinit的执行顺和Java代码中的代码顺序是一致的:
0 iconst_2
1 putstatic #2 <com/jvm/Test.value : I>
4 iconst_1
5 putstatic #2 <com/jvm/Test.value : I>
8 return
添加-XX:TraceClassLoading参数可以打印出加载并初始化的类,以下几种方式会导致类的初始化:
- 访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量是不会触发初始化,而是在连接阶段直接变量进行赋值
- 调用Class.forName(String className),该方法有一个重载方法可以传入一个false 参数不让该方法进行初始化
- new一个该类的对象时
- 执行main方法的当前类
clinit指令在特定情况下不会出现,比如:如下几种情况是不会进行初始化指令执行的。
- 无静态代码块且无静态变量赋值语句。
- 有静态变量的声明,但是没有赋值语句。例如:
public static int a;
- 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。
public final static int a = 10;