Java 之所以可以“一次编译,到处运行”,一是因为 JVM 针对各种操作系统、平台都进行了定制(JRE),二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。
因此,也可以看出字节码对于Java生态的重要性。那到底什么是字节码?之所以被称之为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取(1字节 = 8个二进制位,1个十六进制位 = 4个二进制位)。
PS:除此之外,由于 JVM 规范的存在,只要最终可以生成符合规范的字节码就可以在 JVM 上运行,因此这就给了各种运行在 JVM 上的语言(如Scala、Groovy、Kotlin)一种契机,可以扩展Java所没有的特性或者实现各种语法糖。
Java源文件(.java)经过命令javac
编译后可以生成相应的字节码文件(.class)。比如
public class Demo {
public int math() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Demo demo = new Demo();
int math = demo.math();
}
}
经过编译后生成的字节码文件 Demo.class,打开后看到是一堆十六进制数,以字节为单位进行分割后如下图:
上文提及过,JVM 对字节码规范是有要求的,那么看似杂乱的十六进制符合什么结构呢?JVM规范要求每一个字节码文件都要由十部分按照固定的顺序组成,整体结构如下图所示。接下来我们将一一介绍这十部分:
每个 Class 文件的头 4 个字节称为魔数(Magic Number)
ca fe ba be
它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。魔数的固定值为:0xCAFEBABE。魔数放在文件开头,JVM 可以根据文件的开头来判断这个文件是否可能是一个.class文件,如果是,才会继续进行之后的操作。
很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如 gif 或者 jpg 等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意改动。
版本号为魔数之后的 4 个字节
00 00 00 34
前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。
Java 的版本号是从 45r(JDK 1.1)开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1(JDK 1.0~1.1 使用了 45.0~45.3 的版本号),高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。
从上面代码的class文件中,可以看到版本号为 “00 00 00 34”,次版本号转换为十进制为0,主版本号转换为十进制为52。在Oracle官网中查询序号52对应的主版本号为1.8,所以编译该文件的Java版本号为1.8.0。
紧接着主版本号之后的字节为常量池入口。常量池中存储两种类型常量: 字面量和符号运用。
可以将类信息理解成框架,常量池保存具体数据。因为无论是后面类名称或者是字段名、方法名都是保存在常量池,它们相应位置只保存在常量池中的偏移量。
PS:Java 代码是在虚拟机加载 Class 文件时进行动态链接的,虚拟机在运行期从常量池中获得字段和符号引用后,再在类创建或者运行时解析、翻译到具体的内存地址。
常量池组成
常量池整体上分为两部分:常量池计数器以及常量池数据区,如下图:
1)常量池计数器(constant_pool_count): 由于常量池的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。上面示例代码的常量池计数器为“0014”,转换为十进制后可以得到20,排除下标 0,也就是说这个类文件有 19 个常量。
2)常量池数据区:数据区是由(constant_pool_count - 1)个 cp_info 结构组成,一个 cp_info 的结构对应一个常量。在字节码中共有 14 种类型的 cp_info ,每种类型的结构都是固定的,如下表所示:
序号 | 常量池中数据项类型 | 类型标志 | 类型描述 |
---|---|---|---|
1 | CONSTANT_Utf8 | 1 | UTF-8 编码的Unicode字符串 |
2 | CONSTANT_Integer | 3 | int 类型字面值 |
3 | CONSTANT_Float | 4 | float 类型字面值 |
4 | CONSTANT_Long | 5 | long 类型字面值 |
5 | CONSTANT_Double | 6 | double 类型字面值 |
6 | CONSTANT_Class | 7 | 对一个类或接口的符号引用 |
7 | CONSTANT_String | 8 | String 类型字面值 |
8 | CONSTANT_Fieldref | 9 | 对一个字段的符号引用 |
9 | CONSTANT_Methodref | 10 | 对一个类中声明的方法的符号引用 |
10 | CONSTANT_InterfaceMethodref | 11 | 对一个接口中声明的方法的符号引用 |
11 | CONSTANT_NameAndType | 12 | 对一个字段 或 方法的部分符号引用 |
常量池各类型的具体结构
来看个 CONSTANT_Utf8_info 的例子,01 00 03 61 62 63 表示什么?(上面并没有)
首先,第一个字节从上表可以得到为01(十六进制),表示常量类型为 CONSTANT_Utf8_info;接下来 length 这两个字节标识该字符串的长度 ,最后 bytes 标识这个字符串具体的值(长length 个字节)。
其它类型的 cp_info 结构在本文不在细说,和 CONSTANT_Utf8_info 的结构大同小异,都是先通过 tag 来标识类型,然后后续的 n 个字节来描述长度和数据。
反编译直接查看常量池内容
等我们对这些结构了解了之后,我们可以通过: javap -verbose Demo > Demo.txt
命令查看 JVM 反编译后的完整常量池,可以看到反编译结果可以将每一个 cp_info 结构的类型和值都很明确的呈现出来,如下图所示:
方法是示例构造器,在创建归一个示例对象时就会调用该方法javap -c -l Demo > Demo.txt
常量池结束之后的两个字节,描述该 Class 是类还是接口,以及是否被 Public、Abstract、Final 等修饰符修饰。JVM 规范规定了如下表所示的 8 种访问标志:
标志名称 | 标志值 | 含义 |
---|---|---|
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_TRANSIENT | 0x0080 | 字段是否为 transient |
ACC_SYNCHETIC | 0x1000 | 字段是否为编译器自动产生 |
ACC_ENUM | 0x4000 | 字段是否为 enum |
需要注意的是,JVM 并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为 public final,则对应的访问修饰符的值为 ACC_PUBLIC | ACC_FINAL,即 0x0001 | 0x0010 = 0x0011
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
当前类名的后两个字节,描述父类的全限定名。这两个字节保存的值也是在常量池中的索引值,根据索引值就能在常量池中找到这个类的父类的全限定名。
父类名称后的两个字节,描述这个类的接口计数器,即: 当前类或父类实现的接口数量。紧接着的 n 个字节是所有的接口名称的字符串常量在常量池的索引值。
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的 局部变量。字段表也分为两部分
这里象征性的举一个例子(上面的示例代码并无成员变量):private int x
注:字节码的所有数据都是存在常量池中,所以这里的字段名称和子字段描述符都只是保存的在常量池的偏移量。
字段表结束后为方法表,方法表也是由两部分组成
方法的权限修饰符依然可以在第四部分访问标志查询到,方法名和方法的描述符都是常量池的索引值,可以通过索引值在常量池中查询得到。而方法属性这个部分比较复杂,我们可以借助javap -c -l > Demo.txt
将其反编译:
虽然上面只有两部分Code和LineNumberTable,但实际上属性表集合有很多种:
Code 属性:方法体。Java 程序方法体中的代码经过 Javac 编译器处理后,最终变为字节码指令存储在 Code 属性内。Code 属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在 Code 属性。
LineNumberTable 属性(可选):行号表。将 Code 区的操作码和源代码的行号对应,Debug 时会起到作用(即: 当源代码向下走一行,相应的需要走几个 JVM 指令操作码)
注:Java 代码中的一条指令,可以会分成多条字节码指令,所以,LineNumberTable 是有意义的。
LocalVariableTable 属性(可选):本地变量表。包含 this 和局部变量,之所以可以在每一个非 static 的方法内部都可以调用到 this,是因为 JVM 将 this 作为每个方法的第一个参数隐式进行传入。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE 将会使用诸如 arg0,arg1 之类的占位符代替原有的参数名。
PS:在 JDK 1.5 引入泛型之后,LocalVariableTable 属性增加了一个「姐妹属性」:LocalVariableTypeTable。由于泛型的类型擦除,需要用这个属性描述泛型的类型。
ConstantValue 属性:作用是通知虚拟机自动为静态变量赋值。只有被 static 关键字修饰的变量(类变量)才可以使用这项属性。
InnerClasses 属性:记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成 InnerClasses 属性。
SourceFile 属性(可选):记录生成这个 Class 文件的源码文件名称
Java 的两种类内变量赋值方法:
int x = 1
: 在实例构造器方法中进行
static int x = 1
: 在类构造器方法中或者使用
ConstantValue
属性
对于 static 变量的初始化,目前 Sun Javac 编译器的选择是:如果同时使用final
和static
来修饰一个变量,并且这个变量的数据类型是基本类型或者 java.lang.String 的话,就生成 ConstantValue 属性来进行初始化,如果这个变量没有被final
修饰,或者并非基本类型及字符串,则将会选择在方法中进行初始化。
字节码的最后一部分,存放了在文件中类或接口所定义的属性的基本信息。
类型 | 名称 | 数量 |
---|---|---|
u4 | magic | 1 |
u2 | minor_version | 1 |
u2 | major_version | 1 |
u2 | constant_pool_count | 1 |
cp_info | constant_pool | constant_pool_count - 1 |
u2 | access_flags | 1 |
u2 | this_class | 1 |
u2 | super_class | 1 |
u2 | interfaces_count | 1 |
u2 | interfaces | interfaces_count |
u2 | fields_count | 1 |
field_info | fields | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_count |
u2 | attribute_count | 1 |
attribute_info | attributes | attributes_count |