JVM相关流程理解
记录和思考,不断完善中…
类加载的流程
java的类加载机制是按需加载,当需要某个类时,通过类的全限定名或其他方式获取对应的字节码文件,然后类加载器会采用双亲委派模型对字节码文件进行加载,即首先尝试使用父类加载器进行加载,如果加载不了,再交由子类加载器进行加载,这种加载方式可以有效保护java的核心类库。加载的过程如下:
加载阶段
就是将类的字节码加载到内存中并生成代表该类的Class对象,这个阶段中类的静态存储结构转化为方法区的运行时数据结构,而创建的Class对象就是方法区中这个类的数据访问入口
个人理解:
可以认为类加载的过程中,在加载阶段就完成了外部类到JVM内的加载过程,因为此时确实已经在堆区创建了代表类的class对象了。加载过程的后续环节可以认为是对class对象的不断丰富,比如校验,初始化等。
链接
包含验证、准备、解析三个小阶段
验证
验证字节码合法性
思考:为什么在加载之前不进行验证呢?如果字节码不合法,岂不是进行一次无用的加载。
Class对象是类在JVM中的表现形式,需要在加载进JVM并生成相应的代表此类的Class对象后,才能通过Class对象对该类的元数据进行各种判断验证,以确保该class不会对JVM运行产生危害。
准备
对类对象的静态变量设置默认初始值。基本类型,如int设置为0,对象则设置为null。
解析
这个过程则是将类对象中的符号引用解析成直接引用。这一阶段的主要任务是解析类的常量池中的符号引用
扩展:
- 符号引用: 在 Java 源代码中,我们使用的类名、方法名、字段名等都是符号引用。这些符号引用在编译时是无法直接定位到内存地址的,而是需要在运行时动态解析。
- 直接引用: 直接引用是可以直接定位到内存地址的引用,是符号引用经过解析后的结果。直接引用使得虚拟机能够快速访问到对应的类、方法或字段的内存地址,而不需要再进行一次动态的解析过程。
初始化
这个阶段是为了让类对象完成初始化并处于可用状态。
当类中包含静态变量或静态代码块时,JVM会生成一个clinit方法用来包含这些静态代码并执行,如果没有静态代码,则不会生成clinit方法。
注意:
与链接阶段中对静态变量分配内存并设置默认初始值不同,此阶段是显示进行赋值,区别如下:
public class Example { // 静态变量在链接阶段的准备阶段分配内存并设置零初始值 // 静态变量 num 被初始化为 0 // 静态变量 str 被初始化为 null // 注意:这只是准备阶段的设置,默认初始值,并没有执行具体的赋值操作 public static int num; public static String str; static { // 静态代码块中的赋值操作,将 num 和 str 初始化为实际的初始值 num = 42; str = "Hello, World!"; } public static void main(String[] args) { // 在初始化阶段,静态变量 num 和 str 被赋予实际的初始值 System.out.println(num); // 输出 42 System.out.println(str); // 输出 Hello, World! } }
对象的创建流程
- 检查类是否已加载
- 在堆中为对象的创建分配内存
- 初始化内存(基本数据类初始化)
- 设置对象头(mark word和类指针)
- 执行构造方法,返回新创建对象的引用 (Class对象的创建略有不同,是通过JVM直接设置相关属性完成最终初始化的)
思考:
- 调用构造函数之前,对象还没有创建实例,没有对象实例该如何设置对象头?
在内存分配和对象头设置完成后,JVM会持有这个内存地址的引用。这个引用相当于“对象实例”,但还没有完全初始化,JVM会将这个引用传递给构造函数。在构造函数中,this引用指向的就是这个已经分配了内存和设置了对象头的内存区域。 - 对象创建的过程是看不见的,是否可以通过代码或工具证明创建过程
// 字节码参考java虚拟机规范
public class Test { public static void main(String[] args) { Test obj = new Test(); } } //javap -c Test 反编译结果 0: new #2 // 创建对象 ,未完整创建 3: dup // 复制对象引用 4: invokespecial #1 // 调用构造函数 7: astore_1 // 保存引用 8: return // 返回 // 说明: //new 指令不会完全创建新实例;直到对未初始化的实例调用 //实例初始化方法invokespecial指令
从字节码可以看到,JVM先创建对象(堆区分配了内存且设置了对象头的内存区域)并复制引用,然后调用构造函数,构造函数中this将引用内存区域的地址。
思考:
对象实例只是JVM中存储了数据的一片内存空间,实例化、初始化都是通过在内存中存储数据所呈现的不同的状态或阶段。
如何定位对象
JVM中有两种定位对象的方式,不同的JVM实现,定位方式可能不同。
- 通过句柄定位对象
- 通过直接指针定位对象
句柄
+------------+ +------------+ +------------------+ | 引用 | --> | 句柄表 | --> | 对象实例 | +------------+ +------------+ +------------------+ | ref: handle | | handle: obj_ptr | | data | +------------+ +------------+ +------------------+
在句柄方式中,引用变量包含一个指向句柄表的指针。句柄表中的每个条目包含指向实际对象实例数据的指针。
优点:当对象在内存中移动时,只需要更新句柄表中的指针即可,而不需要修改引用本身。(个人感觉有点像代码中的防腐层)
缺点:复杂,需要额外维护句柄表
直接指针
+------------+ +------------------+ | 引用 | --> | 对象实例 | +------------+ +------------------+ | ref: obj_ptr | | data | +------------+ +------------------+
在直接指针方式中,引用变量直接指向对象在堆中的实际内存地址。这样,当引用访问对象时,只需一次内存定位操作即可。
优点:直接指针访问更快,因为少了一次间接访问。
缺点:对象在堆中的移动和内存压缩(Compact),需要修改所有引用。