Java 之所以可以“一次编译,到处运行”,一是因为 JVM 针对各种操作系统、平台都进行了定制,二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class 文件)供 JVM 使用。
.class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项严格按照顺序紧凑地排列在 .class 文件中,中间没有添加任何分隔符。整个 .class 文件本质上就是一张表。
一、字节码
什么是字节码
之所以被称之为字节码,是因为字节码文件由十六进制值组成,而 JVM 以两个十六进制值为一组,即以字节为单位进行读取。在 Java 中一般是用 javac
命令编译源代码为字节码文件。
对于开发人员,了解字节码可以更准确、直观地理解 Java 语言中更深层次的东西,比如通过字节码,可以很直观地看到 Volatile 关键字如何在字节码上生效。另外,字节码增强技术在 Spring AOP、各种 ORM 框架、热部署中的应用屡见不鲜,深入理解其原理对于我们来说大有裨益。除此之外,由于 JVM 规范的存在,只要最终可以生成符合规范的字节码就可以在 JVM 上运行,因此这就给了各种运行在 JVM 上的语言(如 Scala、Groovy、Kotlin)一种契机,可以扩展 Java 所没有的特性或者实现各种语法糖。理解字节码后再学习这些语言,可以“逆流而上”,从字节码视角看它的设计思路,学习起来也“易如反掌”。
字节码的结构
.java 文件通过 javac 编译后将得到一个.class 文件,比如编写一个简单的 Demo 类
package com.april.test; public class Demo { private int num = 1; public int add() { num = num + 2; return num; } }
编译后生成 Demo.class 文件,打开后是一堆十六进制数,按字节为单位进行分割后(这里用到的是Notepad++
,需要安装一个HEX-Editor
插件):
在分析class文件之前,我们先来看下将这个Demo.class反编译回Demo.java的结果:
可以看到,回编译的源码比编写的代码多了一个空的构造函数
和this关键字
,为什么呢?先放下这个疑问,看完这篇分析,相信你就知道答案了。
上面的字节码文件中我们可以看到,里面就是一堆的16进制字节。那么该如何解读呢?别急,我们先来看一张表:
类型 | 名称 | 说明 | 长度 |
u4 | magic | 魔数,识别Class文件格式 | 4个字节 |
u2 | minor_version | 副版本号 | 2个字节 |
u2 | major_version | 主版本号 | 2个字节 |
u2 | constant_pool_count | 常量池计算器 | 2个字节 |
cp_info | constant_pool | 常量池 | n个字节 |
u2 | access_flags | 访问标志 | 2个字节 |
u2 | this_class | 类索引 | 2个字节 |
u2 | super_class | 父类索引 | 2个字节 |
u2 | interfaces_count | 接口计数器 | 2个字节 |
u2 | interfaces | 接口索引集合 | 2个字节 |
field_info | fields | 字段集合 | n个字节 |
u2 | methods_count | 方法计数器 | 2个字节 |
method_info | methods | 方法集合 | n个字节 |
u2 | attributes_count | 附加属性计数器 | 2个字节 |
attribute_info | attributes | 附加属性集合 | n个字节 |
这是一张Java字节码总的结构表,我们按照上面的顺序逐一进行解读就可以了。
首先,我们来说明一下:class文件只有两种数据类型:无符号数
和表
。如下表所示:
数据类型 | 定义 | 说明 |
无符号数 | 无符号数可以用来描述数字、索引引用、数量值或按照utf-8编码构成的字符串值。 | 其中无符号数属于基本的数据类型。 以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节 |
表 | 表是由多个无符号数或其他表构成的复合数据结构。 | 所有的表都以“_info”结尾。 由于表没有固定长度,所以通常会在其前面加上个数说明。 |
实际上整个class文件就是一张表,其结构就是上面的表了。
那么我们现在再来看表一中的类型那一列,也就很简单了:
类型 | 说明 | 长度 |
u1 | 1个字节 | 8 |
u2 | 2个字节 | 16 |
u4 | 4个字节 | 42 |
u8 | 8个字节 | 64 |
cp_info | 常量表 | n |
field_info | 字段表 | n |
method_info | 方法表 | n |
attribute_info | 属性表 | n |
魔数
每个 .class
文件的头 4 个字节称为 魔数(magic number)
,它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 .class
文件。魔数的固定值为:0xCAFEBABE
。
那么为什么不是用文件名后缀来进行判断呢?因为文件名后缀容易被修改啊,所以为了保证文件的安全性,将文件类型写在文件内部可以保证不被篡改。
有趣的是,魔数的固定值是 Java 之父 James Gosling 制定的,为 CafeBabe(咖啡宝贝),而 Java 的图标为一杯咖啡。
版本号
版本号为魔数之后的 4 个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。
举例来说,如果版本号为:“00 00 00 34”。那么,次版本号(0x0000)转化为十进制为 0,主版本号(0x0034)转化为十进制为 52,在 Oracle 官网中查询序号 52 对应的主版本号为 1.8,所以编译该文件的 Java 版本号为 1.8.0。
常量池
紧接着主版本号之后的字节为常量池入口。
常量池主要存放两类常量:
- 字面量:如文本字符串,声明为final的常量值
- 符号引用
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值。
常量池整体上分为两部分:常量池计数器以及常量池数据区。
- 常量池计数器:由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。如0x0013转化为十进制是19,排除掉下标“0”,也就是说,这个类文件中共有 18 个常量。
通常我们写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了。这是为了在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示。
- 常量池数据区: 数据区是由(
constant_pool_count-1
)个 cp_info 结构组成,一个 cp_info 结构对应一个常量。在字节码中共有 14 种类型的 cp_info,每种类型的结构都是固定的。
常量池中的每一项都是一个表,其项目类型共有14种,如下表格所示:
类型 | 标志 | 描述 |
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整形字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MothodType_info | 16 | 标志方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
这14种类型的结构各不相同,如下表格所示:
注:此表格的类型的单位不对,不是bit,应该是byte(字节)。后面的同理。
从上面的表格可以看到,虽然每一项的结构都各不相同,但是他们有个共同点,就是每一项的第一个字节都是一个标志位,标识这一项是哪种类型的常量。
示例Demo.class的标志位值为0x0a,即10,查上面的表格可知,其对应的项目类型为CONSTANT_Methodref_info
,即类中方法的符号引用。其结构为:
即后面4个字节都是它的内容,分别为两个索引项:
其中前两位的值为0x0004
,即4,指向常量池第4项的索引;
后两位的值为0x000f
,即15,指向常量池第15项的索引。
第二个常量标志位的值为0x09
,即9,查上面的表格可知,其对应的项目类型为CONSTANT_Fieldref_info
,即字段的符号引用。其结构为:
同样也是4个字节,前后都是两个索引。分别指向第4项的索引和第10项的索引。后面还有16项常量就不一一去解读了。
实际上,我们只要敲一行简单的命令:javap -verbose Demo.class
其中部分的输出结果为:
Constant pool: #1 = Methodref #4.#15 // java/lang/Object."<init>":()V #2 = Fieldref #3.#16 // com/april/test/Demo.num:I #3 = Class #17 // com/april/test/Demo #4 = Class #18 // java/lang/Object #5 = Utf8 num #6 = Utf8 I #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 add #12 = Utf8 ()I #13 = Utf8 SourceFile #14 = Utf8 Demo.java #15 = NameAndType #7:#8 // "<init>":()V #16 = NameAndType #5:#6 // num:I #17 = Utf8 com/april/test/Demo #18 = Utf8 java/lang/Object
访问标志
常量池后面就是访问标志,用两个字节来表示,其标识了类或者接口的访问信息,比如:该Class文件是类还是接口,是否被定义成public
,是否是abstract
,如果是类,是否被声明成final
等等。各种访问标志如下所示:
标志名称 | 标志值 | 含义 |
ACC_PUBLIC | 0x0001 | 是否为Public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真 |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | x4000 | 标志这是一个枚举 |
再来看下我们Demo字节码中的值:其值为:0x0021
,是0x0020
和0x0001
的并集,即这是一个Public
的类。
类索引、父类索引、接口索引
访问标志后的两个字节就是类索引;
类索引后的两个字节就是父类索引;
父类索引后的两个字节则是接口索引计数器。
通过这三项,就可以确定了这个类的继承关系了。
类索引
类索引的值为0x0003
,即为指向常量池中第三项的索引。通过类索引我们可以确定到类的全限定名。
父类索引
父类索引的值为0x0004
,即常量池中的第四项,这样我们就可以确定到父类的全限定名。可以看到,如果我们没有继承任何类,其默认继承的是java/lang/Object
类。同时,由于Java不支持多继承
,所以其父类只有一个。
接口计数器
接口索引个数的值为0x0000
,即没有任何接口索引,我们demo的源码也确实没有去实现任何接口。
接口索引集合
由于我们demo的源码没有去实现任何接口,所以接口索引集合就为空了。可以看到,由于Java支持多接口
,因此这里设计成了接口计数器和接口索引集合来实现。
字段表
接口计数器或接口索引集合后面就是字段表了。
字段表用来描述类或者接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。
字段表计数器
前面两个字节用来表示字段表的容量,其值为0x0001
,表示只有一个字段。
字段表访问标志
我们知道,一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected
)、static
修饰符、final
修饰符、volatile
修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段。字段的访问标志有如下这些:
标志名称 | 标志值 | 含义 |
ACC_PUBLIC | 0x0001 | 字段是否为public |
ACC_PRIVATE | 0x0002 | 字段是否为private |
ACC_PROTECTED | 0x0004 | 字段是否为protected |
ACC_STATIC | 0x0008 | 字段是否为static |
ACC_FINAL | 0x0010 | 字段是否为final |
ACC_VOLATILE | 0x0040 | 字段是否为volatile |
ACC_TRANSTENT | 0x0080 | 字段是否为transient |
ACC_SYNCHETIC | 0x1000 | 字段是否为由编译器自动产生 |
ACC_ENUM | 0x4000 | 字段是否为enum |
字段表结构
字段表作为一个表,同样有他自己的结构:
类型 | 名称 | 含义 | 数量 |
u2 | access_flags | 访问标志 | 1 |
u2 | name_index | 字段名索引 | 1 |
u2 | descriptor_index | 描述符索引 | 1 |
u2 | attributes_count | 属性计数器 | 1 |
attribute_info | attributes | 属性集合 | attributes_count |
字段表解读
private int num = 1;
访问标志的值为0x0002
,查询上面字段访问标志的表格,可得字段为private
;
字段名索引的值为0x0005
,查询常量池中的第5项,可得:
#5 = Utf8 num
描述符索引的值为0x0006
,查询常量池中的第6项,可得:
#6 = Utf8 I
属性计数器的值为0x0000
,即没有任何的属性。
注意事项
- 字段表集合中不会列出从父类或者父接口中继承而来的字段。
- 内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
- 在Java语言中字段是无法重载的,两个字段的数据类型,修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的.
方法表
字段表后就是方法表了。
方法表计数器
前面两个字节依然用来表示方法表的容量,其值为0x0002,
即有2个方法。
方法表访问标志
跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:
参考