企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
[TOC] 虚拟机把描述类的数据从Class文件**加载**到内存,并对数据进行**校验**、转换**解析**和**初始化**,最终成为被虚拟机直接使用的Java对象,这就是JVM的类加载机制。     Java天生的可动态扩展的语言特性就是依赖运行期的**动态加载**和**动态连接**实现的。 </br> ##     一:类的生命周期     类的生命周期包括7个部分:加载——验证——准备——解析——初始化——使用——卸载     其中,验证——准备——解析  称为连接阶段。除了解析外,其他阶段是顺序发生的,而解析可以与这些阶段交叉进行,因为Java支持动态绑定(晚期绑定),需要运行时才能确定具体类型。 ##     二:类的初始化触发     类的加载机制没有明确的触发条件,但是有5种情况下必须对类进行初始化,那么 加载——验证——准备 就必须在此之前完成了。     1:new、getstatic、putstatic、invokestatic这4个  字节码指令  时对类进行初始化(即:**实例化对象、读写静态对象、调用静态方法时,进行类的初始化**);     2:使用反射机制对类进行调用时,进行类的初始化;     3:初始化一个类,其父类没有初始化时,先初始化其父类;     4:虚拟机启动时,初始化一个执行主类;     5:使用JDK1.7的**动态语言**支持时,如果MethodHandle实例的解析结果为REF\_getstatic、REF\_putstatic、REF\_invokestatic的方法句柄(即:读写静态对象或者调用静态方法),则初始化该句柄对应类;     一般,以上5种情况**最常见的是前三种:实例化对象、读写静态对象、调用静态方法、反射机制调用类、调用子类触发父类初始化**。 ##     三:类的加载过程     从用户角度来说,类(对象)的生命周期只需笼统理解为“加载——使用——卸载”即可,无需太过深入。所以,这里的类加载过程就是我们说的 加载——验证——准备——解析(非必须)——初始化  这五个使用前的阶段。 ###     1:加载        加载阶段,虚拟机需要完成三件事:**通过类名字获取类的二进制字节流——将字节流的内容转存到方法区——在内存中生成一个Class对象作为该类方法区数据的访问入口**。        其中,第一步:通过类名获取类的二进制字节流是通过类加载器来完成的。其加载过程使用“双亲委派模型”:        类加载器的层次结构为: ![](https://box.kancloud.cn/cdae2f27302b10cc7a1312d90089ab95_324x287.png)        启动类加载器:加载系统环境变量下JAVA\_HOME/lib目录下的类库。        扩展类加载器:加载JAVA\_HOME/lib/ext目录下的类库。        应用程序类加载器(系统类加载器):加载用户类路径Class\_Path指定的类库。(我们可以在使用第三方插件时,把jar包添加到ClassPath后就是使用了这个加载器)        自定义加载器:如果需要自定义加载时的规则(比如:指定类的字节流来源、动态加载时性能优化等),可以自己实现类加载器。        双亲委派模型是指:当一个类加载器收到类加载请求时,不会直接加载这个类,而是把这个加载请求委派给自己父加载器去完成。如果父加载器无法加载时,子加载器才会去尝试加载。        采用双亲委派模型的原因:避免同一个类被多个类加载器重复加载。 ###     2:验证        确保class文件的二进制字节流中包含的信息符号虚拟机要求,包括:文件格式验证、元数据验证(数据语义分析)、字节码验证(数据流语义合法性)、符号引用验证(符号引用的匹配性校验,确保解析能正确执行) ###     3:准备        为**类变量(静态变量)**在**方法区**分配内存,并设置**零值**。注意:这里是类变量,不是实例变量,实例变量是对象分配到**堆内存**时根据运行时动态生成的。 ###     4:解析        把常量池中的符号引用解析为直接引用:根据符号引用所作的描述,在内存中找到符合描述的目标并把目标指针指针返回。 --- 关于符号引用与直接引用,我们还是用一个实例来分析吧。看下面的 Java 代码: ``` package test; public class Test { public static void main(String[] args) { Sub sub = new Sub(); int a = 100; int d = sub.inc(a); } } class Sub { public int inc(int a) { return a + 2; } } ``` 编译后使用 javap 分析工具,会得到下面的 Class 文件内容: ``` Constant pool: #1 = Methodref #6.#15 // java/lang/Object."":()V #2 = Class #16 // test/Sub #3 = Methodref #2.#15 // test/Sub."":()V #4 = Methodref #2.#17 // test/Sub.inc:(I)I #5 = Class #18 // test/Test #6 = Class #19 // java/lang/Object #7 = Utf8 #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 main #12 = Utf8 (\[Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 Test.java #15 = NameAndType #7:#8 // "":()V #16 = Utf8 test/Sub #17 = NameAndType #20:#21 // inc:(I)I #18 = Utf8 test/Test #19 = Utf8 java/lang/Object #20 = Utf8 inc #21 = Utf8 (I)I { public test.Test(); descriptor: ()V Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V Code: stack=2, locals=4, args_size=1 0: new #2 // class test/Sub 3: dup 4: invokespecial #3 // Method test/Sub."<init>":()V 7: astore_1 8: bipush 100 10: istore_2 11: aload_1 12: iload_2 13: invokevirtual #4 // Method test/Sub.inc:(I)I 16: istore_3 17: return } ``` 因为篇幅有限,上面的内容只保留了常量池,和 Code 部分。下面我们主要对 inc 方法的调用来进行说明。 **符号引用 ** 在 main 方法的字节码中,调用 inc 方法的指令如下: ``` 13: invokevirtual #4 // Method test/Sub.inc:(I)I ``` invokevirtual 指令就是调用实例方法的指令,后面的操作数 4 是 Class 文件中常量池的下标,表示用来指定要调用的目标方法。我们再来看常量池在这个位置上的内容: ``` #4 = Methodref #2.#17 ``` 这是一个 Methodref 类型的数据,我们再来看看虚拟机规范中对该类型的说明: ``` CONSTANT_Methodref_info { u1 tag; u2 class_index; u2 name_and_type_index; } ``` 这实际上就是一种引用类型,tag 表示了常量池数据类型,这里固定是 10。class_index 表示了类的索引,name_and_type_index 表示了名称与类型的索引,这两个也都是常量池的下标。在 javap 的输出中,已经将对应的关系打印了出来,我们可以直接的观察到它都引用了哪些类型: ``` #4 = Methodref #2.#17 // test/Sub.inc:(I)I |--#2 = Class #16 // test/Sub | |--#16 = Utf8 test/Sub |--#17 = NameAndType #20:#21 // inc:(I)I | |--#20 = Utf8 inc | |--#21 = Utf8 (I)I ``` 这里我们将其表现为树的形式。可以看到,我们可以得到该方法所在的类,以及方法的名称和描述符。于是我们根据 invokevirtual 的操作数,找到了常量池中方法对应的 Methodref,进而找到了方法所在的类以及方法的名称和描述符,当然这些内容最终都是字符串形式。 实际上这就是一个符号引用的例子,符号引用也可以理解为像这样使用文字形式来描述引用关系。 **直接引用 ** 符号引用在上面说完了,我们知道符号引用大概就是文字形式表示的引用关系。但是在方法的执行中,只有这样一串字符串,有什么用呢?方法的本体在哪里?下面这就是直接引用的概念了,这里我用自己目前的理解总结一下,直接引用就是通过对符号引用进行解析,来获得真正的函数入口地址,也就是在运行的内存区域找到该方法字节码的起始位置,从而真正的调用方法。 那么将符号引用解析为直接引用的过程是什么样的呢?我这个小渣渣目前也给不出确定的答案,在 JVM里的符号引用如何存储? 里,RednaxelaFX 大大给出了一个 Sun JDK 1.0.2 的实现;在 自己动手写Java虚拟机 中,作者给出了一种用 Go 的简单实现,下面这里就来看一下这个简单一些的实现。在 HotSpot VM 中的实现肯定要复杂得多,这里还是以大致的学习了解为主,以后如果有时间有精力,再去研究一下 OpenJDK 中 HotSpot VM 的实现。 不过不管是哪种实现,肯定要先读取 Class 文件,然后将其以某种格式保存在内存中,类的数据会记录在某个结构体内,方法的数据也会记录在另外的结构体中,然后将结构体之间相互组合、关联起来。比如,我们用下面的形式来表达 Class 的数据在内存中的保存形式: ``` type Class struct { accessFlags uint16 // 访问控制 name string // 类名 superClassName string // 父类名 interfaceNames []string // 接口名列表 constantPool *ConstantPool // 该类对应的常量池 fields []*Field // 字段列表 methods []*Method // 方法列表 loader *ClassLoader // 加载该类的类加载器 superClass *Class // 父类结构体的引用 interfaces []*Class // 各个接口结构体的引用 instanceSlotCount uint // 类中的实例变量数量 staticSlotCount uint // 类中的静态变量数量 staticVars Slots // 类中的静态变量的引用列表 initStarted bool // 类是否被初始化 } ``` 类似的,常量池中的方法引用,也要有类似的结构来表示: ``` type MethodRef struct { cp *ConstantPool // 常量池 className string // 所在的类名 class *Class // 所在的类的结构体引用 name string // 方法名 descriptor string // 描述符 method *Method // 方法数据的引用 } ``` 回到上面符号解析的例子。当遇到 invokevirtual 指令时,根据后面的操作数,可以去常量池中指定位置取到方法引用的结构体。实际上这个结构体中已经包含了上面看到的各种符号引用,最下面的 method 就是真正的方法数据。类加载到内存中时,method 的值为空,当方法第一次调用时,会根据符号引用,找到方法的直接引用,并将值赋予 method。从而后面再次调用该方法时,只需要返回 method 即可。下面我们看方法的解析过程: ``` func (self *MethodRef) resolveMethodRef() { c := self.ResolvedClass() method := lookupMethod(c, self.name, self.descriptor) if method == nil { panic("java.lang.NoSuchMethodError") } self.method = method } ``` 这里面省略了验证的部分,包括检查解析后的方法是否为空、检查当前类是否可以访问该方法,等等。首先我们看到,第一步是找到方法对应的类: ``` func (self *SymRef) ResolvedClass() *Class { if self.class == nil { d := self.cp.class c := d.loader.LoadClass(self.className) self.class = c } return self.class } ``` 在 MethodRef 结构体中包含对应 class 的引用,如果 class 不为空,则可以直接返回;否则会根据类名,使用当前类的类加载器去尝试加载这个类。最后将加载好的类引用赋给 MethodRef.class。找到了方法所在的类,下一步就是从类中找到这个方法,也就是方法数据在内存中的地址,对应上面的 lookupMethod 方法。查找时,会遍历类中的方法列表,这块在类加载的过程中已经完成,下面是方法数据的结构体: ``` type Method struct { accessFlags uint16 name string descriptor string class *Class maxStack uint maxLocals uint code []byte argSlotCount uint } ``` 这个其实就和 Class 文件中的 Code 属性类似,这里面省略了异常和其他的一些信息。类加载过程中,会将各个方法的 Code 属性按照上面的结构保存在内存中,然后将类中所有方法的地址列表保存在 Class 结构体中。当在 Class 结构体中查找指定方法时,只需要遍历方法列表,然后比较方法名和描述符即可: ``` for c := class; c != nil; c = c.superClass { for _, method := range c.methods { if method.name == name && method.descriptor == descriptor { return method } } } ``` 可以看到,查找方法会从当前方法查找,如果找不到,会继续从父类中查找。除此以外,还会从实现的接口列表中查找,代码中省略了这部分,还有一些判断的条件。 最终,如果成功找到了指定方法,就会将方法数据的地址赋给 MethodRef.method,后面对该方法的调用只需要直接返回 MethodRef.method 即可。 ###     5:初始化        真正开始执行Java程序代码,该步执行方法根据代码赋值语句,对**类变量和其他资源**  进行初始化赋值。        方法:编译器自动收集类中所有  类变量的赋值语句和静态语句合并而成,收集的顺序是在程序代码出现的顺序。所以,静态语句中只能访问到定义在静态语句块之前的变量,在其之后的变量可以赋值(相当于新建并赋值了)但不可以访问(因为还没出现)。        注:由此步我们就可以得知,我们在分析向上转型的例子时的程序代码的运行顺序了:父类静态内容——子类静态内容——父类构造——子类构造——子类方法 。     在经历了上面5步“加载”阶段后,才真正地可以使用class对象或者使用实例对象。使用过后,不再需要用到该类的class对象或者实例对象时,就会把类卸载掉(发生在方法区的垃圾回收:无用类的卸载)。 ##     四:对象的生命周期      对象是由类创建出来的,所以对象的生命周期就是包含在类的生命周期中:      类加载(5步)——创建类的实例对象——使用对象——对象回收——类卸载