# 类文件结构
计算机是不能直接运行 java 代码的,必须要先运行 java 虚拟机,再由 java 虚拟机运行编译后的 java 代码。这个编译后的 java 代码,就是本文要介绍的 java 字节码。
- Java 代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的 JVM 虚拟机去读取执行,从而实现一次编写,到处运行的目的。
- JVM 也不再只支持 Java,由此衍生出了许多基于 JVM 的编程语言,如 Groovy, Scala, Koltin 等等。
许多开发语言支持将源代码编译为
.class
字节码文件格式,以便交给 JVM 运行
# 类文件信息
先写一个例子:
public class Test { | |
private int m; | |
public int inc() { | |
return m + 1; | |
} | |
} |
编译生成 Test.class
文件。
下载 WinHex 软件,以十六进制格式查看字节码文件(将编译好的
class
文件拖进去),每一位都是 4 个 bit
呈现出来的是酱紫的:
但是 winHex 我这打开总是有些毛病,所以选择另一个工具(IEDA 插件)查看十六进制字节码:
- 安装插件:Binary/hexadecimal editor。
- 右键 class 文件,选择 Open As Binary。
有时直接查看 16 进制的结果不方便,为了能够方便查看字节码信息,需要安装另一个插件:
- 安装插件:jclasslib Bytecode Viewer。
- 选中要查看的 java 文件(不是 class 文件),点击 view(视图),再点击
show Bytecode with jclasslib
。
- 这样查看字节码文件中的常量池什么的就非常方便。
对于类文件信息,需要知道的部分如下图:
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;// 父类 | |
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];// 属性表集合 | |
} |
# 魔数与版本
字节码文件前 4 个字节(32bit)组成了魔数,魔数机制检验该文件是否是 JVM 可以直接运行的字节码文件。字节码文件的魔数为:CAFFBABE。
魔数后面的 4 个字节存储的是字节码文件的版本号,前两个是次要版本号(现在基本不用了),后两个是主要版本号,将 16 进制换算成 10 进制之后,得到的可以参照:52 代表 JDK8 编译的字节码文件(51 是 JDK7,53 是 JDK9)。
# 常量池
Constant pool
意为常量池,可以理解为 Class
文件中的资源仓库,是程序运行一些需要用到的常量数据,主要存放字面量和符号引用。字面量类似于 Java 中的常量概念,如文本字符串, final
常量等。
符号引用属于编译原理的概念:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
常量的数量不是确定的,所以在最开始的位置会存放常量池中常量的数量(是从 1 开始计算的,不是 0,比如 18,翻译为 10 进制就是 24,所以实际上有 23 个常量)。
u2 constant_pool_count;// 常量池的数量 | |
cp_info constant_pool[constant_pool_count-1];// 常量池 |
每一项常量池里面的数据都是一个表,都是以_info 结尾的(通过插件查看即可),有 14 种表。
# 访问标志
两个字节,代表访问标志,用于识别一些类或者接口层次的访问信息,包括:Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。
反编译一下 Test.class
文件查看访问标志:
# -p -private 显示所有类和成员 | |
# -v -verbose 输出附加信息 | |
javap -verbose -p Test.class |
对于结果为:
# 类索引和接口索引集合
u2 this_class;// 当前类 | |
u2 super_class;// 父类 | |
u2 interfaces_count;// 接口 | |
u2 interfaces[interfaces_count];// 一个类可以实现多个接口 |
在访问标志的反编译图中也可以看到当前类索引,父类索引,接口索引。
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object
之外,所有的 java 类都有父类,因此除了 java.lang.Object
外,所有 Java 类的父类索引都不为 0。
接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implents
(如果这个类本身是接口的话则是 extends
) 后的接口顺序从左到右排列在接口索引集合中。
# 字段表集合
u2 fields_count;//Class 文件的字段的个数 | |
field_info fields[fields_count];// 一个类会可以有个字段 |
用于描述接口或类中声明的变量。不包括方法内部的局部变量。
- access_flags: 字段的作用域(
public
,private
,protected
修饰符),是实例变量还是类变量(static
修饰符), 可否被序列化(transient 修饰符), 可变性(final), 可见性(volatile 修饰符,是否强制从主内存读写),各个修饰符都是布尔值,要么有某个修饰符,要么没有,适合使用标志位来表示。 - name_index: 对常量池的引用,表示的字段的名称;
- descriptor_index: 对常量池的引用,表示字段和方法的描述符;//
- attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
- attributes[attributes_count]: 存放具体属性具体内容。
# 方法表集合
u2 methods_count;//Class 文件的方法的数量 | |
method_info methods[methods_count];// 一个类可以有个多个方法 |
Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表( method_info
)的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项(所以参照上图 field_info
)。
得到的结果(这里只看方法那部分):
public Chapter1.Test(); | |
descriptor: ()V | |
flags: (0x0001) ACC_PUBLIC // 这就是 access_flag 取值 | |
Code: | |
stack=1, locals=1, args_size=1 | |
0: aload_0 | |
1: invokespecial #1 // Method java/lang/Object."<init>":()V | |
4: return | |
0: aload_0 | |
1: getfield #2 // Field m:I | |
4: iconst_1 | |
5: iadd | |
6: ireturn | |
LineNumberTable: | |
line 6: 0 |
其中 access_flag
取值为:
因为
volatile
修饰符和transient
修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized
、native
、abstract
等关键字修饰方法,所以也就多了这些关键字对应的标志。
code 内的主要属性为:
- stack: 最大操作数栈,JVM 运行时会根据这个值来分配栈帧 (Frame) 中的操作栈深度,此处为 1**(递归?)**
- locals:局部变量所需的存储空间,单位为 Slot, Slot 是虚拟机为局部变量分配内存时所使用的最小单位,为 4 个字节大小。方法参数 (包括实例方法中的隐藏参数 this),显示异常处理器的参数 (try catch 中的 catch 块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals 的大小并不一定等于所有局部变量所占的 Slot 之和,因为局部变量中的 Slot 是可以重用的。
- args_size:方法参数的个数,这里是 1,因为每个实例方法都会有一个隐藏参数 this
- attribute_info: 方法体内容,0,1,4 为字节码 "行号",该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的
java/lang/Object."":()V
, 然后执行返回语句,结束方法。 - LineNumberTable:该属性的作用是描述源码行号与字节码行号 (字节码偏移量) 之间的对应关系。可以使用 -g:none 或 - g:lines 选项来取消或要求生成这项信息,如果选择不生成 LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
有时还会出现这个:
- LocalVariableTable:该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars 来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是 arg0, arg1 这样的占位符。 start 表示该局部变量在哪一行开始可见,length 表示可见行数,Slot 代表所在帧栈位置,Name 是变量名称,然后是类型签名。
# 属性表集合
u2 attributes_count;// 此类的属性表中的属性数 | |
attribute_info attributes[attributes_count];// 属性表集合 |
用于描述某些场景专有的信息。
# 参考
谈谈 Java 类文件结构
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html
https://coolshell.cn/articles/9229.html
https://blog.csdn.net/luanlouis/article/details/39960815
《实战 Java 虚拟机》
https://pdai.tech/md/java/jvm/java-jvm-class.html
https://www.yuque.com/qingkongxiaguang/javase/keopmg