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。
紧接着主版本号之后的字节为常量池入口。常量池中存储两种类型常量: 字面量和符号运用。
字面量(Literal):文本字符串(“ABC”)、基本数据类型的值(1,1.0)符号引用(Symbolic References) 类和接口的全限定名(Fully Qualified Name)字段的名称和描述符(Descriptor)方法的名称和描述符可以将类信息理解成框架,常量池保存具体数据。因为无论是后面类名称或者是字段名、方法名都是保存在常量池,它们相应位置只保存在常量池中的偏移量。
PS:Java 代码是在虚拟机加载 Class 文件时进行动态链接的,虚拟机在运行期从常量池中获得字段和符号引用后,再在类创建或者运行时解析、翻译到具体的内存地址。
常量池组成
常量池整体上分为两部分:常量池计数器以及常量池数据区,如下图:
1)常量池计数器(constant_pool_count): 由于常量池的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。上面示例代码的常量池计数器为“0014”,转换为十进制后可以得到20,排除下标 0,也就是说这个类文件有 19 个常量。
2)常量池数据区:数据区是由(constant_pool_count - 1)个 cp_info 结构组成,一个 cp_info 的结构对应一个常量。在字节码中共有 14 种类型的 cp_info ,每种类型的结构都是固定的,如下表所示:
序号常量池中数据项类型类型标志类型描述1CONSTANT_Utf81UTF-8 编码的Unicode字符串2CONSTANT_Integer3int 类型字面值3CONSTANT_Float4float 类型字面值4CONSTANT_Long5long 类型字面值5CONSTANT_Double6double 类型字面值6CONSTANT_Class7对一个类或接口的符号引用7CONSTANT_String8String 类型字面值8CONSTANT_Fieldref9对一个字段的符号引用9CONSTANT_Methodref10对一个类中声明的方法的符号引用10CONSTANT_InterfaceMethodref11对一个接口中声明的方法的符号引用11CONSTANT_NameAndType12对一个字段 或 方法的部分符号引用常量池各类型的具体结构
来看个 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 结构的类型和值都很明确的呈现出来,如下图所示:
几点注意:
符号引用和字符串字面量的区别是,符号引用不是直接存储字符串,而是存储字符串在常量池里的索引(比如#17)其中的 <init>方法是示例构造器,在创建归一个示例对象时就会调用该方法若不想打印常量池,可以通过命令 javap -c -l Demo > Demo.txt常量池结束之后的两个字节,描述该 Class 是类还是接口,以及是否被 Public、Abstract、Final 等修饰符修饰。JVM 规范规定了如下表所示的 8 种访问标志:
标志名称标志值含义ACC_PUBLIC0x0001字段是否为 publicACC_PRIVATE0x0002字段是否为 privateACC_PROTECTED0x0004字段是否为 protectedACC_STATIC0x0008字段是否为 staticACC_FINAL0x0010字段是否为 finalACC_VOLATILE0x0040字段是否为 volatileACC_TRANSIENT0x0080字段是否为 transientACC_SYNCHETIC0x1000字段是否为编译器自动产生ACC_ENUM0x4000字段是否为 enum需要注意的是,JVM 并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为 public final,则对应的访问修饰符的值为 ACC_PUBLIC | ACC_FINAL,即 0x0001 | 0x0010 = 0x0011
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
当前类名的后两个字节,描述父类的全限定名。这两个字节保存的值也是在常量池中的索引值,根据索引值就能在常量池中找到这个类的父类的全限定名。
父类名称后的两个字节,描述这个类的接口计数器,即: 当前类或父类实现的接口数量。紧接着的 n 个字节是所有的接口名称的字符串常量在常量池的索引值。
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的 局部变量。字段表也分为两部分
第一部分是两个字节,描述字段个数第二部分是每个字段的详细信息 field_info。字段表结构如下图所示:这里象征性的举一个例子(上面的示例代码并无成员变量):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: 在实例构造器<int>方法中进行static int x = 1: 在类构造器 <cinit> 方法中或者使用 ConstantValue 属性 对于 static 变量的初始化,目前 Sun Javac 编译器的选择是:如果同时使用 final 和 static 来修饰一个变量,并且这个变量的数据类型是基本类型或者 java.lang.String 的话,就生成 ConstantValue 属性来进行初始化,如果这个变量没有被 final 修饰,或者并非基本类型及字符串,则将会选择在 <clinit> 方法中进行初始化。字节码的最后一部分,存放了在文件中类或接口所定义的属性的基本信息。
类型名称数量u4magic1u2minor_version1u2major_version1u2constant_pool_count1cp_infoconstant_poolconstant_pool_count - 1u2access_flags1u2this_class1u2super_class1u2interfaces_count1u2interfacesinterfaces_countu2fields_count1field_infofieldsfields_countu2methods_count1method_infomethodsmethods_countu2attribute_count1attribute_infoattributesattributes_count