1 Class 类文件结构
1 | ClassFile { |
def Class 文件是一组以 8 个字节为基础单位的二进制流,存储内容几乎全部是程序运行的必要数据,各数据项严格按照顺序紧凑排列,中间无任何分隔符。根据《Java 虚拟机规范》,Class 文件由一个 ClassFile 结构组成,采用的是一种类似于 C 语言结构体的伪结构来存储数据,仅包含下述两种数据类型:
-
无符号数:基本数据类型,用 u1、u2、u4、u8 表示 1~8 个字节的无符号数,可表示数字、索引引用、数量值或是以 UTF-8 格式编码的字符串
-
表:包含多个无符号数,用于描述有层次关系的复合结构的数据
- 为便于区分,所有表命名均以
“_info”
结尾 - 整个 Class 文件本质上也是一张表
- 为便于区分,所有表命名均以
点击折叠
Q:对占用 8 个字节以上空间的数据项,Class 文件是如何存储的?
A:按照大端序拆分存储。即将数据项分割成若干个 8 个字节,按照高位字节在地址最低位,最低字节在地址最高位进行存储
1.1 全限定名、简单名称和描述符
在 Class 文件中,这三类特殊字符串的出现频率很高:
-
全限定名:类的全限定名是将类全名中的 “.” 用 “/” 替换,并在最后加入一个 “;” 标志结束的字符串
e.g. Object 类的全限定名为
Ljava/lang/Object;
-
简单名称:没有类型和参数修饰的方法或字段名称
e.g. 假设当前的 Class 文件中包含了一个 hello () 方法,则 hello () 方法的简单名称为
hello
标识字符 | 含义 |
---|---|
B | 基本类型 byte |
C | 基本类型 char |
D | 基本类型 double |
F | 基本类型 float |
I | 基本类型 int |
J | 基本类型 long |
S | 基本类型 short |
Z | 基本类型 boolean |
V | 特殊类型 void |
L | 对象类型,如 Ljava/lang/Object; |
-
方法和字段的描述符
- 描述字段的数据类型:基本数据类型以及 void 类型均用一个大写字符表示,对象类型用字符 L 加对象的全限定名表示
- 描述方法的参数列表(包括数量、类型以及顺序)及其返回值:按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序置于 “()” 内
e.g. 方法 void hello () 的描述符为 “() V”
点击折叠
Q:一个 java.lang.String[][]
类型二维数组字段的字节码应如何描述?
A:每一维对应一个前置的 [
字符,即 [[Ljava/lang/String;
1.2 魔数和版本号
-
魔数(Magic Number):为每个 Class 文件的头 4 个字节,唯一作用是确定文件是否为一个能被虚拟机接受的 Class 文件
-
次版本号(Minor Version):第 5、第 6 个字节,JDK1.2 后置 0
-
主版本号(Major Version):第 7、第 8 个字节
e.g. 52(0x0034)代表 JDK8 版本下编译产生的字节码文件
点击折叠
* 注 1:据说早期 Class 文件使用 “0xCAFEBABE”
作为魔数象征着著名咖啡品牌 Peet’s Coffee 深受欢迎的 Baristas 咖啡,这也和日后 Java 的商标不谋而合
* 注 2:根据《Java 虚拟机规范》4.1 节可知,次版本号(minor version)曾在早期版本使用。但在实际的 Java 演化中,JVM 的兼容性几乎完全通过主版本号(major version)来管理:
“For a class file whose major_version is 56 or above, the minor_version must be 0 or 65535”
对于 major_version 为 56 或以上的类文件,minor_version 必须为 0 或 65535
“For a class file whose major_version is between 45 and 55 inclusive, the minor_version may be any value”
对于 major_version 在 45 至 55 之间(含 55)的类文件,minor_version 可以是任何值
“A class file is said to depend on the preview features of Java SE N (N ≥ 12) if it has a major_version that corresponds to Java SE N and a minor_version of 65535”
如果一个类文件的 major_version 与 Java SE N 对应,且 minor_version 为 65535,则该类文件被称为依赖于 Java SE N 的预览功能(N ≥ 12)
1.3 常量池
def 常量池(constant pool)可理解为 Class 文件的资源仓库,入口处由常量池容量计数值(constant_pool_count)从 1 开始记录了常量池中的常量数目。主要存放下述两类常量:
-
字面量:文本字符串、被声明为 final 的常量值等
-
符号引用
- 被模块导出或者开放的包
- 类和接口的全限定名
- 字段、方法的名称和描述符
- 方法句柄和方法类型
- 动态调用点和动态常量
以 JDK13 为例,常量表包含了 17 种不同类型的常量,彼此数据结构完全独立(建议在有需要时查表)
点击折叠
Q:为什么 Class 文件中,只有常量池容量计数值不是从 0 开始的?
A:人为设计,将 0 保留并用于表示 “不引用任何一个常量池项目” 的索引值数据
1.4 访问标志
def 访问标志(access_flags)是一个标志掩码,用于表示类或接口的访问权限和属性。一共有 16 个标志位可以使用,截止 JDK9 定义了 9 个。Class 文件中访问标志的 2 个字节紧跟在常量池之后,值按照按位取或的方式(压缩状态)计算
e.g. 假设当前有一个 Main 类,是一个访问权限为 public 的普通 Class,且未被声明为 final 或 abstract,则有 ACC_PUBLIC | ACC_SUPER = 0x0001 | 0x0020 = 0x0021
1.5 类索引、父类索引和接口索引集合
Class 文件中由这三项数据确定继承关系:
-
类索引(this_class):2 个字节,用于确定类的全限定名,指向一个类型为
CONSTANT_Class_info
的类描述符常量,通过CONSTANT_Class_info
类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info
类型的常量中的全限定名字符串 -
父类索引(super_class):2 个字节,用于确定类的父类的全限定名
-
接口索引集合(interfaces []):一组 u2 类型数据的集合,描述类实现的接口
- 由接口计数器(interfaces_count)表示索引表的容量
- 按照
implements
/extends
关键字后的接口顺序排列
点击折叠
Q:Object 类的父类索引应如何表示?
A: Object 作为唯一没有父类的类,其父类索引规定置 0
“If the value of the super_class item is zero, then this class file must represent the class Object, the only class or interface without a direct superclass”
如果
super_class
项的值为零,那么此类文件必须表示类Object
,这是唯一没有直接超类的类或接口
1.6 字段表集合
1 | field_info { |
def 字段表(field_info)用于描述接口或者类中声明的变量
-
字段访问标志(access_flags):类比类的访问标志
-
字段的简单名称对常量池项的引用(name_index)
-
字段的描述符对常量池项的引用(descriptor_index)
-
属性计数器(attributes_count)
-
属性表集合(attribute_info):存储额外信息
e.g. 对字段声明
final static int m=520;
存储的额外信息为常量520
1.7 方法表集合
1 | method_info { |
def 字段表(method_info)用于描述接口或者类中声明的方法
-
方法访问标志(access_flags):类比类的访问标志
-
方法的简单名称对常量池项的引用(name_index)
-
方法的描述符对常量池项的引用(descriptor_index)
-
属性计数器(attributes_count)
-
属性表集合(attribute_info)
Javac 编译器会自动添加方法,最常见的是类构造器 <clinit>()
方法和实例构造器 <init>()
方法
1.8 属性表集合
def 属性表(attribute_info)用于描述信息,从 JVM 实现必须包含的属性到任何人实现的编译器写入的自定义属性均可,JVM 运行时将忽略未知属性
-
属性名称为从常量池中引用的
CONSTANT_Utf8_info
类型的常量 -
属性值的结构是完全自定义的,以 u4 长度的属性说明属性值所占用的位数
1.8.1 Code 属性
1 | Code_attribute { |
def Code 属性是 Class 文件中最重要的一个属性,Java 程序方法体中的代码在编译后以字节码指令的形式存储在 Code 属性内
-
抽象类或接口中的方法不存在 Code 属性
1 | public Fundamentals.BinarySearch.Main(); |
使用 Javap 反编译一个字节码文件,可知 Code 属性中包含以下主要信息:
-
最大操作数栈(stack,对应 max_stack):JVM 运行时根据该值分配栈帧(Stack Frame)中的操作栈深度
-
局部变量表所需存储空间(locals,对应 max_locals)
- 单位是变量槽(Slot),占用 4 个字节,是虚拟机为局部变量分配内存所使用的最小单位
-
方法的参数数目(args_size)
-
方法体内容(attribute_info)
- 将第一个引用类型本地变量推送至栈顶
- 执行该类型的实例方法,即常量池存放的第一个变量
java/lang/Object."<init>":()V
- 执行返回语句
-
行号表(LineNumberTable):描述源码行号与字节码行号(字节码偏移量)之间的对应关系
-
局部变量表(LocalVariableTable):描述源码中定义的变量与栈帧中局部变量之间的关系
点击折叠
Q:这里 Main () 方法并没有入参,为什么方法的参数数目(args_size)为 1?
A:因为实例方法的局部变量表中会存在至少一个指向当前对象实例的局部变量,局部变量表预留出第一个变量槽存放对象实例的引用。具体来说, Javac 编译器在编译时将对 this
关键字的访问转变为对一个普通方法参数的访问,并在虚拟机调用实例方法时自动传入,从而使得实例方法可通过 this
关键字访问到此方法所属的对象。而对于 static
关键字修饰的静态方法,其方法的参数数目为 0
2 字节码指令
def 字节码指令是代表某种特定含义的操作码
-
指令长度只有一个字节
-
JVM 面向操作数栈而非寄存器架构,大多数指令不带参数
-
操作码助记符:l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference
大部分指令都不支持整数类型 byte、char 和 short,且没有任何指令支持 boolean 类型。实际上在大多数情况下,这四种类型均使用 int
作为运算类型。故以下主要以 int
类型列举部分常见指令:
-
加载和存储指令
- 将一个局部变量加载到操作数栈:
iload
、iload_<n>
- 将一个数值从操作数栈存储到局部变量表:
istore
、istore_<n>
- 将一个常量加载到操作数栈:
bipush
、ldc
、iconst_<i>
- 将一个局部变量加载到操作数栈:
-
运算指令
- 四则运算:
iadd
、isub
、imul
、idiv
- 按位运算:
iand
、ior
、ixor
- 局部变量自增指令:
iinc
- 四则运算:
-
对象创建与访问指令
- 创建类实例:
new
- 创建数组:
newarray
- 访问实例字段和类字段:
getfield
、putfield
、getstatic
、putstatic
- 把一个数组元素加载到操作数栈:
iaload
- 将一个操作数栈的值储存到数组元素中:
iastore
- 创建类实例:
-
操作数栈管理指令
- 将操作数栈栈顶一个或两个元素出栈:
pop
、pop2
- 复制栈顶数值并复制值入栈:
dup
、dup_x1
- 将栈最顶端的两个数值互换:
swap
- 将操作数栈栈顶一个或两个元素出栈:
-
控制转移指令
- 条件分支:
ifeq
、ifnonnull
- 复合条件分支:
tableswitch
、lookupswitch
- 无条件分支:
goto
、jsr
、ret
- 条件分支:
-
方法调用和返回指令
- 调用对象的实例方法:
invokevirtual
- 调用接口方法:
invokeinterface
- 调用一些需要特殊处理(实例初始化、私有和父类方法)的实例方法:
invokespecial
- 调用类静态方法:
invokestatic
- 调用对象的实例方法:
3 JVM 类加载机制
3.1 类的生命周期
def 类加载过程包含加载、验证、准备、解析和初始化五个部分,是一个类型从被加载到虚拟机内存中到完成初始化的全过程
-
验证、准备、解析三个部分统称为连接
-
第 1~4 阶段的顺序是确定的:指按顺序开始,而不是按顺序依次执行
-
为支持运行时绑定特性,解析阶段可以在初始化阶段之后开始
3.1.1 加载
在加载阶段,JVM 完成以下步骤:
-
通过一个类的全限定名获取定义此类的二进制字节流
-
将该字节流代表的静态存储结构转化为方法区的运行时数据结构
-
在内存中生成一个代表类的
java.lang.Class
对象,作为方法区该类数据的访问入口
3.1.2 验证
验证阶段旨在确保 Class 文件的字节流中包含的信息符合约束:
-
文件格式验证:从魔数和版本号的格式开始检查直至属性表
-
元数据验证:对字节码描述的信息进行语义分析,保证元数据信息中的数据类型符合 Java 语言规范
-
字节码验证:验证中最复杂的步骤,对类的方法体(Code 属性)进行校验分析
-
符号引用验证:对常量池中的各种符号引用进行匹配性校验,确保能够正常解析
3.1.3 准备
在准备阶段,正式为静态变量分配内存并设置初始值
-
准备阶段分配内存的仅包括类变量,而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中
-
一般地,设置的初始值是数据类型默认的零值;但如果类字段的字段属性表中存在
ConstantValue
属性,则准备阶段变量值将初始化为ConstantValue
属性指定的初始值e.g. 语句
public static int a = 520;
对应准备阶段 a 的初始值为 0
3.1.4 解析
解析阶段将常量池内的符号引用替换为直接引用,解析动作主要针对类
或接口
、字段
、类方法
、接口方法
、方法类型
、方法句柄
和调用点
限定符 7 类符号引用进行
-
符号引用(Symbolic References):以一组符号来描述所引用的目标,可为任意形式的字面量,仅要求能无歧义地定位到目标
-
直接引用(Direct References):直接指向目标的指针、相对偏移量或能间接定位到目标的句柄
3.1.5 初始化
初始化阶段执行类中编写的 Java 程序代码。《Java 虚拟机规范》严格规定有且只有六种情况必须立即对类进行初始化,类的初始化时机如下:
-
遇到
new
、getstatic
、putstatic
或invokestatic
字节码指令时 -
使用
java.lang.reflect
包的方法对类型进行反射调用时 -
加载一个类的子类时
-
虚拟机启动时,需初始化包含 main () 方法的主类
-
(JDK7+)一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,且该方法句柄对应的类未初始化时
-
(JDK8+)一个接口包含有
default
关键字修饰的方法,且该接口的实现类已初始化时
3.1.6 使用
类访问方法区内数据结构的接口, 对象是 Java 堆的数据
3.1.7 卸载
出现以下情况 JVM 将结束生命周期:
-
执行 System.exit () 方法
-
程序正常执行结束
-
程序异常终止
-
操作系统出错导致 JVM 进程终止
3.2 类加载器
def 类加载器用于实现类的加载动作
-
每个类加载器,都拥有一个独立的类名称空间
e.g. 两个类来源于同一个 Class,被同一个 Java 虚拟机加载,如果用于加载的类加载器不同,则两个类必定不相等
3.2.1 双亲委派模型
从开发人员的角度,类加载器可划分为以下类型:
-
启动类加载器(Bootstrap Class Loader):负责加载存放在
<JAVA_HOME>\lib
目录,或在-Xbootclasspath
参数指定路径下的,并且能被虚拟机识别的类库(如 rt.jar、tools.jar,所有的java.*
开头的类均被启动类加载器加载)- 启动类加载器无法被 Java 程序直接引用,如果需要向其委派加载请求,可直接使用 null 代替
-
扩展类加载器(Extension Class Loader):负责加载
<JAVA_HOME>\lib\ext
目录中,或由java.ext.dirs
系统变量指定路径下的所有类库(如 javax.* 开头的类)- 在
sun.misc.Launcher$ExtClassLoader
类实现 - 由于扩展类加载器是由 Java 代码实现的,开发者可直接在程序中使用扩展类加载器加载类
- 在
-
应用程序类加载器(Application Class Loader):负责加载用户类路径(ClassPath)下的所有类库
- 在
sun.misc.Launcher$AppClassLoader
类实现 - 一般情况下作为程序默认的类加载器,开发者可以直接使用
- 在
def 双亲委派模型体现为三层类加载器、双亲委派的类加载架构
-
要求除顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器
-
父类加载器是采用组合而非继承关系实现
点击折叠
Q1:介绍一下双亲委派模型的工作过程?
A:当类加载器收到类加载的请求时,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,每一个层次的类加载器都是如此,直到最终传送到最顶层的启动类加载器;只有当父加载器反馈无法完成加载请求时,即它的搜索范围中没有找到所需的类,此时子加载器才会尝试自己去完成类的加载
Q2:使用双亲委派模型能带来哪些好处?
A:双亲委派模型使得类随类加载器一起具备了带有优先级的层次关系,保证一个类在程序的各种类加载器环境中均加载为同一个类,防止内存中出现多份相同的字节码,保证 Java 程序能够稳定运行
参考
[1] 深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 3 版)
[2] Java JVM 虚拟机 已完结(IDEA 2021 版本)4K 蓝光画质
[4] Java 全栈知识体系