面向JVM的字节码
什么是字节码
Write Once, Run Anywhere
Java程序的一大特性便是支持跨平台,它不需要被重新编译就能够在安装了不同操作系统的计算机上运行,这个特性主要是基于JVM与字节码实现的
JVM可以理解的代码就称作字节码,通常是扩展名为.class
的文件,JVM加载字节码后再进行解释执行
并不是只有
*.java
对应的java程序可以被javac
编译器翻译成字节码,像Kotlin、Scala、Groovy等其他语言的源代码也可以通过各自对应的编译器编译为字节码文件,最终都可以在JVM中运行
字节码结构
根据JVM规范,.class
文件通过一个结构体ClassFile
定义:
ClassFile {
u4 magic; // Class文件的标志
u2 minor_version;// Class的次版本号
u2 major_version;// Class的主版本号
u2 constant_pool_count;// 常量池表的数量
cp_info constant_pool[constant_pool_count-1];// 各个常量池表
u2 access_flags;// Class的访问标志
u2 this_class;// 当前类的索引
u2 super_class;// 父类的索引(Java只支持单继承,因此这里也只有一个父类)
u2 interfaces_count;// 接口数量
u2 interfaces[interfaces_count];// 一个类可以实现多个接口
u2 fields_count;// Class文件的字段个数
field_info fields[fields_count];// 字段表数据区
u2 methods_count;// Class文件的方法个数
method_info methods[methods_count];// 方法表数据区
u2 attributes_count;// 此类的属性表个数
attribute_info attributes[attributes_count];// 属性表集合
}
其中u4
、u2
代表字段是unsigned
无符号的,数字对应该字段所占的字节数,其他的诸如cp_info
、field_info
、method_info
、attribute_info
都是复合的数据结构
根据定义,即可大致知晓字节码的组成:
Class文件的结构不存在任何分隔符号,因此它包含的数据项,在顺序、数量、字节序(字节码采用大端字节序,高位在前)等细节上都是严格定义的
魔数 Magic Number
u4 magic; // Class 文件的标志
每个Class文件的首部4个字节被称为魔数 (Magic Number),它的唯一作用是确定该字节码文件能否被虚拟机所接收,目前JVM所能识别的所有字节码文件其魔数都为固定的0xCAFEBABE
这个32bit数
据说
0xCAFEBABE
是因为Java之父 James Gosling经常去一家名为CAFEBABE
的咖啡店,所以他敲定了魔数,并沿用至今(Objective-C的Mach-o文件头部的魔数也是0xCAFEBABE
) Java的符号也是一杯咖啡(笑)
字节码版本号
u2 minor_version;// Class 的次版本号
u2 major_version;// Class 的主版本号
紧随魔数之后的便是该字节码被编译时的编译器次版本号和主版本号,每当Java更新大版本,主版本号都会+1(版本号从JDK1的45开始),一般来说JVM都是向下兼容的,高版本JVM可以运行低版本编译生成的字节码文件
比如,使用javap -v *.class
查看JDK17编译的.class
文件,其主版本号就是61:
javap -v SegmentTree.class
...
public class org.penistrong.template.tree.SegmentTree
minor version: 0
major version: 61
...
常量池 Constant Pool
u2 constant_pool_count;// 常量池的数量
cp_info constant_pool[constant_pool_count-1];// 常量池
Class文件中的常量池不等同于JVM结构里的常量池,个人觉得可以将字节码中的常量池称为常量池表,JVM在加载类文件(即Class文件时),会将常量池表中的字面量和符号引用等加载到JVM方法区中的运行时常量池中
字节码的常量池实际大小为constant_pool_count - 1
,空出第0项是因为Jaba规定了索引为0代表”不引用任何一个常量池项”,每一个常量池项其本身就是一个表,记录每种常量类型对应的各个常量
一共有17种类型,每个表的第一个字节(u1
类型标志位)标识了常量表对应的类型:
标志位(u1 tag) |
类型 | 描述 |
---|---|---|
0x01 | CONSTANT_utf8_info | UTF-8编码的字符串 |
0x03 | CONSTANT_Integer_info | 整型字面量 |
0x04 | CONSTANT_Float_info | 单精度浮点型字面量 |
0x05 | CONSTANT_Long_info | 长整型字面量 |
0x06 | CONSTANT_Double_info | 双精度浮点型字面量 |
0x07 | CONSTANT_Class_info | 类或接口的符号引用 |
0x08 | CONSTANT_String_info | 字符串类型字面量 |
0x09 | CONSTANT_Fieldref_info | 字段的符号引用 |
0x10 | CONSTANT_Methodref_info | 类中方法的符号引用 |
0x11 | CONSTANT_InterfaceMethodref_info | 接口中方法的符号引用 |
0x12 | CONSTANT_NameAndType_info | 字段或方法的名称:类型 符号引用组合 |
0x15 | CONSTANT_MethodHandle_info | 方法句柄 |
0x16 | CONSTANT_MethodType_info | 方法类型 |
0x17 | CONSTANT_Dynamic_info | 需要通过动态计算得到的常量 |
0x18 | CONSTANT_InvokeDynamic_info | 动态方法的调用点 |
0x19 | CONSTANT_Module_info | 模块 |
0x20 | CONSTANT_Package_info | 模块中开放或者导出的包 |
第一个CONSTANT_utf8_info,应该是每个Class文件常量池中最多的常量,因为它保存了字段名称、方法名称等所有源码中出现的非关键字名称:
CONSTANT_Utf8_info {
u1 tag; // 该常量表对应类型的标志位
u2 length; // 该UTF-8编码字符串的长度
u1[] bytes[length]; // 字节数组,存储字符串的每个UTF-8字节
}
由于CONSTANT_Utf8_info常量也用于描述字段、方法的名称,而描述该常量的长度由无符号2字节数表示,因此最大长度只能是65535,当名称超过此长度就会无法编译
访问标志 Access Flags
u2 access_flags;// Class的访问标志
访问标志用于标识当前Class是类还是接口,是否为public
或者abstract
类型,如果是类的话再判断是否为不可继承的final
类型
Flag Name | Value | Interpretation |
---|---|---|
ACC_PUBLIC | 0x0001 | 声明为public ,可被包外类访问 |
ACC_FINAL | 0x0010 | 声明为final ,不允许存在继承它的子类 |
ACC_SUPER | 0x0020 | 使用invokespecial 指令执行实例方法时,按照新语义执行(JDK1.0.2之后所有的类被编译后ACC_SUPER都为真) |
ACC_INTERFACE | 0x0200 | 说明当前Class为接口而不是类 |
ACC_ABSTRACT | 0x0400 | 声明为abstract ,不能被实例化 |
ACC_SYNTHETIC | 0x1000 | 声明为synthetic ,不是源码中的而是编译器生成的 |
ACC_ANNOTATION | 0x2000 | 声明为注解类型 |
ACC_ENUM | 0x4000 | 声明为枚举类型 |
ACC_MODULE | 0x8000 | 声明为模块 |
注意到各个访问标记对应的十六进制值都是错开的,多个访问标志其实重叠在u2
这个2字节16bit类型上,通过分离每一个十六进制位就可以直到当前Class的修饰符
比如,存在一个public abstract
的抽象类,该类对应的Access Flag就是0x0421 = 0x0400(ACC_ABSTRACT) + 0x0020(ACC_SUPER) + 0x0001(ACC_PUBLIC)
当前类、父类、接口索引集合
u2 this_class;// 当前类的索引
u2 super_class;// 父类的索引(Java只支持单继承,因此这里也只有一个父类)
u2 interfaces_count;// 接口数量
u2 interfaces[interfaces_count];// 一个类可以实现多个接口
JVM根据字节码中的当前类索引、父类索引、接口索引表这三项确定当前Class的继承关系:
-
当前类索引指向该类的全限定名
全限定名是用’/’替换类名中的’.’,比如
org.penistrong.template.tree.SegmentTree
的全限定名就是org/penistrong/template/tree/SegmentTree
-
父类索引指向该类的直接父类的全限定名(这里可以发现只有一个父类,说明Java是单继承的),而所有的类的最左边界(顶级父类)一定是
java.lang.Object
,因此除了Object
类外其他所有Java类的父类索引都不为0 -
接口索引由接口数量和接口索引表组成,Java规定了类可以实现多个接口,当前类实现的接口会按照源码中
implements
的顺序排列在接口索引表里(如果当前Class本身就是接口,则它extends
的接口也是按出现顺序排列)
字段表集合 Fields
u2 fields_count;// Class文件的字段个数
field_info fields[fields_count];// 字段表数据区
当前Class的字段表由字段数量和字段表数据区组成,用于描述当前Class里声明的变量,字段包括类变量(静态变量)、实例变量(实例化后才有具体的值),但是不包括方法内部声明的局部变量
field info
结构体如下所示:
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
-
access_flags
: 字段的作用域修饰符(public
,protected
,private
),实例变量or类变量(static
修饰),该字段能否被序列化(transient
),可变性(final
),并发可见性(volatile
) -
name_index
: 对常量池中该字段名称对应的utf8字符串的引用(以常量池索引表示) -
descriptor_index
: 对常量池中该字段描述符对应的utf8字符串的引用(以常量池索引表示)对于数组类型,每有一个维度就会在其类型前添加一个前置的
[
字符 -
attributes_count
: 某些字段还会拥有额外的属性,该变量记录额外属性的个数 -
attributes[attributes_count]
: 存放该字段拥有的具体额外属性
字段的access_flags
类似于类的Access Flags
,修饰符标志几乎一样,但是相较类的修饰符增加了字段特有的标志:
Flag Name | Value | Interpretation |
---|---|---|
ACC_PRIVATE | 0x0002 | 声明为private ,只能在声明该字段的类内部使用 |
ACC_PROTECTED | 0x0004 | 声明为protected ,可以被继承该类的子类访问 |
ACC_STATIC | 0x0008 | 声明为static ,指示该字段是否是类变量 |
ACC_VOLATILE | 0x0040 | 声明为volatile ,指示该字段不能被线程缓存,只能到主存中读取 |
ACC_TRANSIENT | 0x0080 | 声明为transient ,指示该字段不能被序列化 |
其他标志都已复用,只是在字段上表示时的意义不同,比如ACC_FINAL
修饰字段时指示该字段不能在实例构造后被赋值
具有语法冲突的修饰符,其对应的十六进制位是互斥的
方法表集合
u2 methods_count;// Class文件的方法个数
method_info methods[methods_count];// 方法表数据区
Class文件中,对于方法的描述与对字段的描述一致,首先方法名和方法描述符都需要存储为常量池的CONSTANT_utf8_info
的字符串,同时方法内部的局部变量嵌套存储在方法表结构体method_info
method_info
结构体如下所示:
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
其中access_flag
的取值比其类、字段的访问标志取值多出好几项,同时因为volatile
、transient
修饰符不可以修饰方法,所以去掉了这两个标志:
Flag Name | Value | Interpretation |
---|---|---|
ACC_SYNCHRONIZED | 0x0020 | 声明为synchronized ,调用该方法时会使用monitor包裹它,防止其他线程并发调用 |
ACC_BRIDGE | 0x0040 | 由编译器生成的桥接方法(所以ACC_SYNTHETIC 这个标志也会一并启用),编译器在进行泛型擦除或者处理该方法的协变返回类型时就会生成桥接方法 |
ACC_VARARGS | 0x0080 | 指示该方法拥有可变数量的参数 |
ACC_NATIVE | 0x0100 | 声明为native ,说明该方法实际由非java实现,调用该方法要走本地方法栈 |
ACC_STRICT | 0x0800 | 声明为strictfp ,采用精确浮点数模式 |
方法的具体代码存储在方法表内的名为Code
的属性表里
编译器通常会自动添加类构造器<clinit>()
方法和实例构造器<init>()
方法
Java中要重载(Overload)一个方法,除了具有相同的简单名称外还必须拥有一个与原方法不同的特征签名
Java代码中,特征签名只包括 方法名称、参数顺序、参数类型 JVM字节码中,特征签名还包括类方法返回值、受检异常表
属性表集合
u2 attributes_count;// 此类/方法/字段的属性表个数
attribute_info attributes[attributes_count];// 属性表集合
类、字段表、方法表中都会携带自己的属性表集合,其它数据项的约束相对严格,但是属性表的限制就较为宽松,编译器可以向其中写入自己定义的属性信息
类的属性表集合通常包含类的签名Signature
、源文件名索引SourceFile
、invokedynamic
使用的引导方法限定符BootstrapMethods
、内部类InnerClasses
Signature
主要用于编译器进行泛型擦除后记录泛型类型
所有的属性名、字段名、方法名等都是以常量池索引的形式保存的,索引到常量池中这些名称对应的UTF-8字符串
每一个属性表attribute_info
结构如下所示:
u2 attribute_name_index;
u4 attribute_length;
u1[] info[attribute_length];
类的生命周期
类的生命周期是指,其对应的字节码从被加载到虚拟机内存中开始,到该类被卸载出内存为止。大体可以概括为7个阶段:
- 加载 Loading
- 验证 Verification
- 准备 Preparation
- 解析 Resolution
- 初始化 Initialization
- 使用 Using
- 卸载 Unloading
其中验证、准备、解析这三个阶段可以一并称为连接(Linking)阶段
类加载过程
JVM需要将类对应的字节码文件加载后才能够使用,类加载过程即类生命周期的前5步
加载
JVM准备使用某个类时,如果发现其并不在内存中,就要执行加载过程,通过类加载器ClassLoader
加载对应的类
-
通过全类名限定获取定义此类的二进制字节流(字节码不仅来源于本地,还可来源于
ZIP
、JAR
、WAR
、动态代理生成、JSP
解析等等) -
将字节码中的静态存储结构(常量池表、方法表、字段表等)转换为方法区的运行时数据结构
-
在内存中生成代表该类的
Class
对象,作为方法区中该类对应数据的访问入口
ClassLoader
负责加载字节码,而具体的类加载器由双亲委派模型决定
注意,数组类不是通过ClassLoader
创建的,而是JVM自动创建,且数组类通过getClassLoader()
方法获取的类加载器与该数组元素类型对应的ClassLoader
一致
加载阶段与连接阶段不是严格串行的,比如执行类加载时,JVM同时会对字节码执行文件格式验证等动作
验证
连接阶段的第一步便是验证,确保字节码中包含的信息满足JVM规范的约束要求,保证运行代码的安全性
JVM启动时可以添加参数
-Xverify:none
,关闭大部分的类验证措施,缩短类加载时间
验证阶段主要分4步
-
文件格式验证: 验证字节码格式是否符合规范,比如开头的
0xCAFEBABE
魔数、主次版本号是否能被当前版本的JVM处理、常量池中是否有某些JVM不支持的类型等 -
元数据验证: 对字节码描述的信息进行语义分析,比如该类是否有父类、是否继承了不允许被继承的类等
-
字节码验证: 通过数据流与控制流分析,保证程序语义合法,比如函数的参数类型是否正确、对象的类型转换是否正确等
-
符号引用验证: 对该类引用的其他类、方法、字段等进行验证,确保该类持有正确的访问权限(比如访问其他类的私有字段就是非法的)
文件格式验证是在上一步的加载过程中同步进行的,保证字节码能够被JVM正确解析并存储在方法区里
符号引用验证其实是在下下步的解析阶段执行,JVM将相关的符号引用转换为直接引用的过程就会执行符号引用验证,如果无法通过符号引用验证,JVM就会抛出对应的异常,如:
-
java.lang.IllegalAccessError
: 当类试图访问或修改没有权限访问的字段,或者调用没有权限访问的方法,就会抛出非法访问错误 -
java.lang.NoSuchFieldError
: 当类试图访问或修改指定的其他对象字段时,如果该对象不再包含该字段,就会抛出字段不存在错误 -
java.lang.NoSuchMethodError
: 当类试图访问一个指定的方法,但是该方法不存在,就会抛出方法不存在错误 -
……
准备
JVM正式为类的类变量分配内存并设置初始值,注意类变量只会被分配到方法区的静态变量池和常量池中
-
没有
static
修饰的实例变量不会在准备阶段被分配内存,当对象实例化时才会随着对象一起在堆上分配 -
JDK8之后的虚拟机,方法区由堆中的静态变量池、常量池和本地内存里的元空间共同组成,因此该类对应的Class对象及其类变量实际还是存放在堆中(但不是在新生代或者老年代里),详见JVM内存模型
-
设置的初始值是指数据类型的默认零值,没有被
final
修饰的类变量,其初始值是该变量对应数据类型的零值,不是源码中设定的初值。只有被final
修饰的常量才会直接赋值为设定的初值,并存储到方法区的常量池中
解析
JVM在解析阶段会将常量池中的符号引用替换为直接引用,在字节码常量池一节中,常量池表的全部17种类型里有9种是符号引用:
标志位(u1 tag) |
类型 | 描述 |
---|---|---|
0x07 | CONSTANT_Class_info | 类或接口的符号引用 |
0x08 | CONSTANT_String_info | 字符串类型字面量 |
0x09 | CONSTANT_Fieldref_info | 字段的符号引用 |
0x10 | CONSTANT_Methodref_info | 类中方法的符号引用 |
0x11 | CONSTANT_InterfaceMethodref_info | 接口中方法的符号引用 |
0x15 | CONSTANT_MethodHandle_info | 方法句柄 |
0x16 | CONSTANT_MethodType_info | 方法类型 |
0x17 | CONSTANT_Dynamic_info | 需要通过动态计算得到的常量 |
0x18 | CONSTANT_InvokeDynamic_info | 动态方法的调用点 |
符号引用(Symbolic Reference)以一组符号来描述引用的目标,与虚拟机实现的内存布局不同,引用的目标并不一定是已经加载到虚拟机内存中的内容
直接引用(Direct Reference)的目标必定已存在于JVM中,可以是直接指向目标的指针、相对偏移量、间接定位目标的句柄这三种
解析阶段并不一定在类加载时就会进行,当JVM执行字节码指令集中的17种与引用类型相关的字节码指令时(比如instanceof
、invokespecial
、invokestatic
),需要将这些指令使用的符号引用解析为直接引用,JVM自行决定是在类加载时就进行符号引用解析,还是等到执行字节码指令时再解析
对同一符号引用多次进行解析是很常见的情况,所以JVM通常会缓存该符号引用第一次被解析的结果(比如将常量标识为已解析状态,直接引用常量池中的记录),但是对
invokedynamic
指令需要另行解析
初始化
JVM执行类的初始化方法<clinit>()
,该方法由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句,按照语句在源码中出现的顺序排列各语句,因此静态语句块中只能对定义在它之后的静态变量执行赋值,但是不能够访问(编译器会抛出非法前向引用错误)
<clinit>()
方法不需要显式调用父类的类初始化方法(不同于类的构造函数<init>()
方法),JVM会保证子类初始化时其父类就已经初始化完毕,所以JVM中第一个执行<clinit>
方法的类型一定是java.lang.Object
由于类只能被初始化一次,为了防止不同线程并发执行<clinit>()
,JVM会对类初始化方法加锁,保证并发安全性
JVM遵循的原则:当需要主动使用某个类时,才会去执行该类的初始化。存在6种主动使用场景:
-
当遇到字节码指令
new
、getstatic
、putstatic
、invokestatic
时:new
: JVM需要实例化该类的对象,所以要进行类初始化getstatic
: 程序需要访问类的类变量(不是final
的静态常量),需要初始化该类并得到该类变量初始化后的值putstatic
: 程序需要对类的类变量赋值invokestatic
: 程序需要调用类的静态方法
-
使用反射时,需要类被初始化才能够被正确解析,比如
Class.forname("...")
、Method.invoke("...")
-
如果该类的父类还没有初始化,先执行父类的初始化
-
JVM启动时需要一个入口方法,优先初始化包含该入口方法(
main
方法)的类 -
使用
MethodHandle
与VarHandle
时,需要先初始化被调用的类 -
JDK8之后,由于接口可以添加
default
默认方法,由于其包含了方法的语句,当该接口的实现类要被初始化时,先去初始化该接口(注意,如果没有default
方法,当父接口中定义的变量被使用时也会初始化该接口)
类加载器与双亲委派模型
类加载过程的第一步加载阶段,需要ClassLoader去获取给定了全限定名的类的字节码,其实ClassLoader还可以加载其他资源文件比如图片、文本、视频等
在JVM中,类加载器还有其他的作用:对于任意一个类,由加载这个类的ClassLoader和这个类本身共同确定该类在JVM中的唯一性。即只有被同一个类加载器加载,JVM才认为它们是相同的类
ClassLoader会用一个Vector
容器保存每一个被加载过的类,加载类时会先判断是否已有该类,没有的话再去尝试加载
类加载器种类
JVM内置了3层核心的ClassLoader:
-
BootstrapClassLoader: 启动类加载器,HotSpot VM中由C++实现,最顶级的类加载器,无法获取其对应实例(实际使用时直接给定null参数,默认使用启动类加载器加载),主要用来加载JDK运行需要的核心类库比如
%JAVA_HOME%/lib
下的rt.jar
、resources.jar
、charsets.jar
等 -
ExtensionClassLoader: 扩展类加载器,对应类
sun.misc.Launcher$ExtClassLoader
,加载%JRE_HOME%/lib/ext
和系统变量java.ext.dirs
路径下的所有类库,这些类库具有扩展Java SE的功能 -
ApplicationClassLoader: 应用程序类加载器,对应类
sun.misc.Launcher$AppClassLoader
,用户使用的类加载器,负责加载用户添加到用户类路径ClassPath
下的所有类库,在程序中可以直接使用该类加载器加载其他位置的类
这三种类加载器具有前后顺序,它们和用户自定义的类加载器共同构成被称为双亲委派模型的层次关系如下图所示,注意它们的层次不是以继承(Inheritance)实现而是以组合(Composition)实现
双亲委派模型
双亲并不是真的指父母双亲而是指直接父类parent,类加载器按照双亲委派模型工作时,每个ClassLoader都持有其父类加载器的实例
ClassLoader抽象类源码中可以看到它们之间的组合关系:
public abstract class ClassLoader {
...
private final ClassLoader parent;
protected ClassLoader(ClassLoader parent) {
this(checkCreateClassLoader(), parent);
}
...
}
如果一个类加载器需要加载某个类或资源时,会将该任务委托给其父类加载器,层层传递到最顶层的BootstrapClassLoader后,如果它无法加载目标类,就会将该任务一级级地返回,由各个层次的类加载去自己去加载
这个过程即:
-
自底向上查找目标类是否已经被父类加载器加载,如果已被加载直接返回该类
-
如果没被加载,则继续传递给父类加载器,直到传递到BootstrapClassLoader后仍发现目标类没有被加载
-
对于没被加载过的类,自顶向下尝试加载目标类,由BootstrapClassLoader首先尝试加载,无法加载的话则返回给下一级类加载器
以上逻辑过程的代码实现位于ClassLoader抽象类的方法loadClass()
中:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先检查该类是否已经被当前类加载器加载过
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 存在父类加载器,调用父类加载器的loadClass()方法
// 让父类加载器加载时,resolve参数为false,父类不负责连接
c = parent.loadClass(name, false);
} else {
// 父类加载器为空,调用缺省的启动类加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 非启动类加载器的父类加载器向上抛出了ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
// 父类加载器无法加载目标类,自己使用findClass()方法加载目标类
if (c == null) {
c = findClass(name);
}
}
// 注意,父类加载器只负责加载目标类,字节码后续的连接过程由自己负责
if (resolve) {
resolveClass(c);
}
return c;
}
}
按照双亲委派模型的执行逻辑,可以避免类的重复加载(不同类加载器加载同一个字节码),同时保证基础类等核心API不被篡改:比如用户编写了java.lang.Object
类,JVM在加载该类时会按照双亲委派模式,层层向上查找,但是BootstrapClassLoader早就从rt.jar
中加载了核心类java.lang.Object
,于是直接返回安全的核心类Object
,保证其没有被篡改
打破双亲委派模型
有3种方法能够打破双亲委派模型机制,每种都有其历史成因:
-
自定义类加载器继承
ClassLoader
抽象类并重写loadClass()
方法JDK1.2引入了双亲委派模型,在这之前用户就已经可以继承
ClassLoader
抽象类重写loadClass()
方法,如今Java官方是建议继承抽象类后只重写findClass()
方法,就可以在不违背双亲委派机制的条件下自行加载目标类 -
涉及SPI的加载时使用线程上下文类加载器
Thread Context ClassLoader
以JNDI为例,它作为Java标准服务的一部分(位于
rt.jar
中),由BootstrapClassLoader完成加载,但是JNDI提供的只是SPI(Service Provider Interface,服务提供者接口),它需要调用其他厂商对该接口的实现,这些代码实际上位于应用程序的ClassPath
中,不在启动类加载器的加载范围里于是就提出了
Thread Context ClassLoader
(实际从父线程继承或者缺省使用AppClassLoader
),加载的过程类似于父类加载器请求子类加载器去加载SPI服务代码JDK6之后,可以使用
java.util.ServiceLoader
类,并配合包中的META-INF/services
中的配置信息,以责任链模式加载SPI服务代码(Spring Boot Starter也是类似这样) -
JDK9之后的模块化热部署,由OSGi负责加载程序模块Bundle
对象创建过程
JVM执行到创建对象的字节码指令new
时,就会开启对象实例化过程:
-
类加载检查:检查该对象对应的类是否已被加载,没有的话则先执行类加载过程
-
对象内存分配:为对象在堆中分配内存,根据GC收集器的垃圾收集算法策略,有不同的内存分配方式:
-
指针碰撞: 基于标记-整理、复制算法的GC收集器(Serial、ParNew),GC时总是将存活对象整齐地堆放在内存中,空闲内存是连续大块的,为该对象开辟空间时只需要将分界指针向空闲内存方向移动对应大小的位置即可
-
空闲列表: 基于标记-清除算法的GC收集器(CMS、G1),GC后空闲内存不是连续的,需要JVM维护一个空闲内存块列表来了解内存分配情况(比如G1的Region),为该对象开辟空间需要查表找到一块足够大的空闲内存块进行分配
为对象分配内存时还要注意线程安全问题,JVM采用两种方式保证创建对象时的线程安全性:
-
TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区): 为每个工作线程在新生代Eden区中预留一块专属内存称为TLAB,对应的线程要分配对象时优先在TLAB中分配,当对象所需内存大于TLAB剩余内存时再使用下一种方式分配内存(用预留空间避免多线程环境中频繁的加锁操作)
-
CAS+自旋(失败重试): 采用基于乐观锁的CAS机制配合类似自旋锁的失败重试,保证操作的原子性
-
-
初始化默认值: 跟类加载中的准备阶段类似,这里是为实例变量分配其对应数据类型的默认零值,保证实例字段可以在不赋初值的情况下使用
-
设置对象头: JVM将相关的标记字段等写入到对象实际内存结构的对象头中,比如锁状态标志、hashcode、GC分代年龄等标记字段及对象对应的类元数据指针
-
执行初始化方法: 执行
<init>()
方法,即类源码中的构造函数,为实例变量分配真正的初值