JVM成神之路(1): Class 文件解析

前言

今天开展 JVM 的深度学习之路,来到第一站,CLass 的字节码篇。

为什么从 Class 字节码开始起,原因是我们在学习 JVM 的时候,其实脑海中就回很自然的想到下面这张图

这就是 JVM 虚拟机的整体架构,经过编译后的 Java Class 文件被类加载器子系统加载到运行时数据区中,运行时数据区中有五个区域,堆、栈、本地方法栈、程序计数器、方法区,然后调用本地方法接口,执行引擎里面有垃圾回收器回收运行时数据区内的垃圾。

所以,class 文件是作为 JVM 虚拟机的输入源头,理解了这个,那后续的一系列围绕 Class 字节码的JVM工作流程就的理解就会变的比较顺畅。

一、Class 文件概述

学习 Java 语言的时候,常会听到这样的话,java 是一门跨平台的语言,即一次编译,就可以在不同的平台上运行。

一次编译指的就是编译成 class 字节码的过程。

而如今,jvm 不和任何语言包括 java 在内的语言绑定,它只与 class 文件这种特定的二进制文件格式关联。

所以,例如 Kotiln、Groovy 只需经过编译器编译后,也可以运行在 JVM 上。

经过编译后的 class 字节码文件,我们可以使用 Notepad++ 查看十六进制形式看到以 cafebabe 开头的二进制字节码文件。

也可以使用 jclasslib 工具进行查看

Class 字节码文件是一组以 8个字节为基础单位的二进制流,因为都是 0-1 的数据,所以没有任何分割符号,其中的字节顺序和数量都是按照 class 的规范进行排列的,这就像一本没有标点符号的书,存储着 class 文件的所有信息。

Class 文件格式采用一种类似于 C 语言的伪结构来存储数据,这种伪结构只有两种数据类型:

  • 无符号数

无符号数属于基本数据类型,以 u1、u2、u4、u8 分别代表1个字节、2个字节、4个字节和8个字节。无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性的以 "_info" 结尾。

表用来描述有层次关系的复合结构的数据,整个 class 文件本质上就是一张表

Class 的文件结构的基本结构和框架都是保持不变的 ,如下所示

二、字节码文件解析

2.1 字节码文件准备

typescript 复制代码
public class jvmClass {
    private String name;

    public jvmClass() {
    }

    public String getName() {
        return this.name;
    }
}

经过编译后的二进制如下:

r 复制代码
CA FE BA BE 00 00 00 34  00 16 0A 00 04 00 12 09
00 03 00 13 07 00 14 07  00 15 01 00 04 6E 61 6D
65 01 00 12 4C 6A 61 76  61 2F 6C 61 6E 67 2F 53
74 72 69 6E 67 3B 01 00  06 3C 69 6E 69 74 3E 01
00 03 28 29 56 01 00 04  43 6F 64 65 01 00 0F 4C
69 6E 65 4E 75 6D 62 65  72 54 61 62 6C 65 01 00
12 4C 6F 63 61 6C 56 61  72 69 61 62 6C 65 54 61
62 6C 65 01 00 04 74 68  69 73 01 00 29 4C 63 6F
6D 2F 78 69 61 6F 6C 65  69 2F 6A 76 6D 2F 74 65
73 74 2F 63 6C 61 73 73  66 69 6C 65 2F 6A 76 6D
43 6C 61 73 73 3B 01 00  07 67 65 74 4E 61 6D 65
01 00 14 28 29 4C 6A 61  76 61 2F 6C 61 6E 67 2F
53 74 72 69 6E 67 3B 01  00 0A 53 6F 75 72 63 65
46 69 6C 65 01 00 0D 6A  76 6D 43 6C 61 73 73 2E
6A 61 76 61 0C 00 07 00  08 0C 00 05 00 06 01 00
27 63 6F 6D 2F 78 69 61  6F 6C 65 69 2F 6A 76 6D
2F 74 65 73 74 2F 63 6C  61 73 73 66 69 6C 65 2F
6A 76 6D 43 6C 61 73 73  01 00 10 6A 61 76 61 2F
6C 61 6E 67 2F 4F 62 6A  65 63 74 00 21 00 03 00
04 00 00 00 01 00 02 00  05 00 06 00 00 00 02 00
01 00 07 00 08 00 01 00  09 00 00 00 2F 00 01 00
01 00 00 00 05 2A B7 00  01 B1 00 00 00 02 00 0A
00 00 00 06 00 01 00 00  00 08 00 0B 00 00 00 0C
00 01 00 00 00 05 00 0C  00 0D 00 00 00 01 00 0E
00 0F 00 01 00 09 00 00  00 2F 00 01 00 01 00 00
00 05 2A B4 00 02 B0 00  00 00 02 00 0A 00 00 00
06 00 01 00 00 00 0B 00  0B 00 00 00 0C 00 01 00
00 00 05 00 0C 00 0D 00  00 00 01 00 10 00 00 00
02 00 11 

2.2 魔数与主次版本号

每个 Class 文件开头的4个字节被称为魔数(Magic Number)。

魔数的唯一作用是确定这个文件能否被虚拟机接收,很多文件格式标准中都有使用魔数来进行身份识别的习惯。

譬如图片格式,如 GIT 和 JPEG 等在文件头都存在魔数,使用魔数来区别主要是基于安全考虑,

Java class 的魔数就是 cafebabe。

如果一个 class 不以 oxcafebabe 开头,jvm 在文件校验的时候就会抛出下面的异常。

紧接着魔数的 4个字节存储的是 Class 文件的版本号:

  • 第5 和第6个字节是次版本号
  • 第7 和 第8个字节是主版本号

Java 的版本号从45开始,每发布一个大版本,主版本就加1。因此,看到 52 就是 java8 。

上面的字节码文件经过本小节分析可得

bash 复制代码
CA FE BA BE # 魔数
00 00 # 次版本号
00 34 # 主版本号 34 换成10进制就是 52 
#以下省略

2.3 常量池计数器与常量池

紧接着 次主版本号之后就是常量池入口。

常量池可以比喻为 class 文件里的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 class 文件空间最大的数据项目之一,常量池常用于存放编译时期生成的各种字面量(Literal)和符号引用(Symbolic References)。

由于常量池大小不确定,所以,需要在常量池入口处放置一个 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。

与 java 语言不同,这个容量计数是从1开始,而不是从0开始,切记。

为什么这样设计?

设计者将第 0 项常量空出来是考虑到某些指向常量池的索引值的数据在特定情况下需要表达"不引用任何一个常量池项目"的含义,这种情况就可以用0来表示。

例子1 :Object类没有父类,他的父类索引指向哪里呢?指向 00 00 (指向常量池里的第 0 个常量,第0 个常量什么都没有,这个第 0 个,就是为了给所有无法指向的情况提供的一个空常量指向

例子2匿名内部类。 (类名称指向哪里?指向 00 00)

bash 复制代码
CA FE BA BE # 魔数
00 00 # 次版本号
00 34 # 主版本号 34 换成10进制就是 52 
00 16 # 常量池个数,换成10进制就是22个
#以下省略

同时用工具查看,确实是从1开始的。

常量池包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项常量的结构都具备相同的特征,那就是每一项常量入口都是一个u1类型的标识,该标识用于确定该项的类型,这个字节称为tag byte(标识字节)

一旦JVM获取并解析这个标识,JVM就会知道在标识后的常量类型是什么。常量池中的每一项都是一个表,其项目类型共有14种,表17-4列出了所有常量项的类型和对应标识的值,比如当标识值为1时,表示该常量的类型为CONSTANT_utf8_info。

常量池中主要存放两大类常量:字面量 (Literal)和 符号引用 (Symbolic References)(类加载会提到一个解析过程,符号引用->直接引用

字面量比较接近于Java语言层面的常量概念 ,如文本字符串、被声明为final的常量值等。

符号引用则属于编译原理方面的概念,主要包括下面几类常量:

  • 被模块导出或者开放的包(Package

  • 类和接口的全限定名(Fully Qualified Name

  • 字段的名称和描述符(Descriptor

  • 方法的名称和描述符

  • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)

  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

arduino 复制代码
CA FE BA BE 
00 00 
00 34  
00 16 

0A // 第一个常量 0A,对应十进制 10 => 类中方法的符号引用
00 04 // 指向第4个常量
00 12 // 指向第18个常量

// 第2个常量
09 字段的符号引用
00 03 指向第3个常量
00 13 指向第19个常量

// 第3个常量
07    // 类或接口的符号引用
00 14 // 指向第20个常量

// 第4个常量
07 
00 15 // 指向第21常量

// 第5个常量
01 
00 04 长度
6E 61 6D 65 name

// 以下省略

可以看到分析完5个常量后,与 jclasslib 解析的一模一样。第五个常量 6E 61 6D 65 转换成字符串就是 name 。

2.4 访问标志

常量池后紧跟着的2个字节代表访问标志(access_flas)。

描述当前类或接口的访问修饰符,如 public、private ,该类是否抽象,是否 final 等。

在访问标识后,会指定该类的类别,父类列表以及实现的接口,这三项数据来确定这个类的继承关系,格式如表所示

类索引用于确定这个类的全限定名,

父类索引用于确定这个类的父类的全限定名。

  • 由于 Java 语言不允许多重继承,所以父类索引只有一个,注意 java.lang.Object 类除外。

接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口按 implement 语句后面接口的顺序从左到右排列在接口索引集合中。如果这个类本身是接口类型,则应当是按 extends 语句后面接口的顺序从左到右边排列在接口索引集合中。

对应字节码

bash 复制代码
00 21 # 访问标志 public
00 03 # 本类索引 <com/xiaolei/jvm/test/classfile/jvmClass>
00 04 # 父类索引 <java/lang/Object>

没有接口的话就是为空了。

2.5 字段表集合

类里面有很多字段,字段用集合来表示。

字段表(field_info)用于描述接口或者类中声明的变量。

Java 语言中的字段包括 "类级变量以及实例级变量",但不包括在方法内部声明的局部变量。

字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。

而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

字段表作为一个表,同样有自己的结构

2.6 方法表集合

字段表之后就是方法表信息了,它指向常量池索引集合,完整描述了每个方法的信息,在class 文件中,一个方法表与类或者接口中方法一一对应。

方法信息包含方法的访问修饰符(public、private、protected)、方法的返回值类型以及方法的参数信息等。

方法表结构如下

2.7 属性表集合

方法表集合之后的属性表集合,指的是class文件所携带的辅助信息,比如该class文件的源文件的名称。

属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但JVM运行时会忽略掉它不认识的属性。前面我们看到的属性表都是Code属性。Code属性就是存放在方法体里面的代码,像接口或者抽象方法,它们没有具体的方法体,因此也就不会有Code属性了。和常量池计数器以及常量池的设计一样,属性表同样设计了属性计数器和属性表。

本节对字节码class文件进行了比较深入的了解,class 字节码文件是一串二进制的字节流,内部包含很多属性,可以使用 javap命令或 jclasslib插件进行查看。

拓展--JVM指令码

在 Java 虚拟机的指令中,大多数指令都包含其操作所对应的数据类型信息。

举个例子,iload 指令用于从局部变量表中加载 int 类型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据,这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class 文件中它们必须拥有各自独立的操作码。

对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference

也有一些指令的助记符中没有明确指明操作类型的字母,例如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。

还有另外一些指令,例如无条件跳转指令goto则是与数据类型无关的指令。

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括:

  • 将一个局部变量加载到操作栈:iload 、iload_、lload、lload_、fload、fload_、dload、dload_、aload、aload_

  • 将一个数值从操作数栈存储到局部变量表:istore、istore_、lstore、lstore_、fstore、fstore_、dstore、dstore_、astore、astore_

  • 将一个常量加载到操作数栈:bipush 、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_

** 扩充局部变量表的访问索引的指令:wide

  • 加法指令:iadd、ladd、fadd、dadd
  • 减法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求余指令:irem、lrem、frem、drem
  • 取反指令:ineg、lneg、fneg、dneg
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
  • 按位或指令:ior、lor
  • 按位与指令:iand、land
  • 按位异或指令:ixor、lxor
  • 局部变量自增指令:iinc
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp*

*Java虚拟机直接支持(即转换时无须显示的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversion,即小范围类型向大范围类型的安全转换):
·int类型到long、float或者double类型
·long类型到float、double类型
·float类型到double类型
与之相对的,处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指令来完成,这些转换指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
对象创建指令

java 复制代码
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令包括:
·创建类实例的指令:new
·创建数组的指令:newarray、anewarray、multianewarray

·访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic

·把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
·将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
·取数组长度的指令:arraylength
·检查类实例类型的指令:instanceof、checkcast

方法调用返回指令

arduino 复制代码
方法调用,这里仅列举以下五条指令用于方法调用:

·invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。

·invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。

·invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法(init)、私有方法(private)和父类方法(父类方调用)。

·invokestatic指令:用于调用类静态方法(static方法)。

·invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。

```*
相关推荐
希忘auto14 分钟前
详解MySQL安装
java·mysql
冰淇淋烤布蕾25 分钟前
EasyExcel使用
java·开发语言·excel
拾荒的小海螺31 分钟前
JAVA:探索 EasyExcel 的技术指南
java·开发语言
Jakarta EE1 小时前
正确使用primefaces的process和update
java·primefaces·jakarta ee
马剑威(威哥爱编程)1 小时前
哇喔!20种单例模式的实现与变异总结
java·开发语言·单例模式
java—大象1 小时前
基于java+springboot+layui的流浪动物交流信息平台设计实现
java·开发语言·spring boot·layui·课程设计
杨哥带你写代码2 小时前
网上商城系统:Spring Boot框架的实现
java·spring boot·后端
camellias_2 小时前
SpringBoot(二十一)SpringBoot自定义CURL请求类
java·spring boot·后端
布川ku子2 小时前
[2024最新] java八股文实用版(附带原理)---Mysql篇
java·mysql·面试
白总Server2 小时前
JVM解说
网络·jvm·物联网·安全·web安全·架构·数据库架构