文章目录
Java语言的无关性
从事Java语言开发工作的人都知道,Java语言有一个非常著名的宣传口号就是——一次编写,到处运行。那到底是怎么做到“一次编写,到处运行”的呢?
其实,我们知道计算机在底层只认识0和1这种二进制格式,如C,C++这些平台相关的高级语言都是由编译器将源代码翻译成0和1构成的二进制代码才能由计算机执行,它们的具体实现需要依赖底层的操作系统和不同的CPU指令集。但是近年来,大量建立在虚拟机上的语言蓬勃发展,将源程序编译成二进制本地机器码已经不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的,平台中立的格式作为程序编译后的存储格式——字节码存储格式。Java语言就是运行于Java虚拟机之上,使用字节码存储格式的语言,Java语言之所以宣称其平台无关性就是由于字节码存储格式。
字节码存储格式是实现平台无关性和语言无关性的基础:
-
平台无关性:不管底层是什么操作系统(windows,linux),虚拟机都可以载入与平台无关的字节码。在虚拟机这一层做了屏蔽,不同操作系统对应着不同的虚拟机实现。
-
语言无关性:Java虚拟机并非面向Java语言或者其他建立在其上的其他语言——Clojure,Groovy,JRuby,Jython,Scala等,而是面向字节码,即我们所熟知的“Class文件”。因此,Java虚拟机并不关心什么语言运行其上,它只关心字节码文件。字节码文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。
Class类文件结构
任何一个Class文件都对应着唯一一个类或接口的定义信息,但是类或接口并不一定都得定义在文件里,也可以通过类加载器直接生成。
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在Class文件之中,中间没有添加任何分隔符。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前(最高位字节在地址最低位,最低位字节在地址最高位的顺序存储)的方式分割成若干个8位字节进行存储。
Class文件采用一种类似于c语言结构体的伪结构体来存储数据,这种伪结构体有两种数据类型:无符号数和表。
-
无符号数属于基本数据类型,u1,u2,u4,u8分别代表1,2,4,8个字节的无符号数。无符号数可以用来描述数字,索引引用,数量值或者按照UTF-8编码构成的字符串值
-
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表习惯性以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。
下表表示Class文件的格式
类型 | 名称 | 解释 | 数量 |
---|---|---|---|
u4 | magic | 魔数:0xCAFEBABE | 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 | attributes_count | 属性表容量计数值 | 1 |
attribute_info | attributes | 属性表 | attributes_count |
为了便于分析Class文件中的各个属性,现在提供一段示例代码,并使用十六进制编译器WinHex打开这个Class文件的结果进行分析
示例代码如下:
public class TestClass {
private int m;
public int inc() {
return m + 1;
}
}
WinHex打开这个Class文件后结果如下:
魔数与Class文件版本
每个Class文件的头4个字节称为魔数:0xCAFEBABE(从上面的16进制结果中可以看到),它的唯一作用是确定这个文件是否是一个能被虚拟机接受的Class文件。
紧接着魔数的4个字节存储的是Class文件的版本号:第5,6字节存储的是次版本号,第7,8字节存储的主版本号。Java的版本号是45开始的,高版本的JDK能向下兼容低版本的Class文件,但不能运行更高版本的Class文件。通过上图可知:次版本号为 0x0000,十进制数为 0;主版本号为 0x0034,十进制数为 52,版本号为:52.0,说明该文件是 JDK1.8 及以上的版本虚拟机执行的Class文件。
常量池
为了便于分析,使用 javap -verbose 工具得到类的文件字节码内容,给出常量池部分的字节码内容。
常量池字节码内容:
Compiled from "TestClass.java"
public class TestClass
minor version: 0 //次版本号
major version: 52 //主版本号
flags: ACC_PUBLIC, ACC_SUPER //标志位
Constant pool: //常量池
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#16 // TestClass.m:I
#3 = Class #17 // TestClass 类索引
#4 = Class #18 // java/lang/Object 父类索引
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 inc
#12 = Utf8 ()I
#13 = Utf8 SourceFile
#14 = Utf8 TestClass.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = NameAndType #5:#6 // m:I
#17 = Utf8 TestClass
#18 = Utf8 java/lang/Object
主次版本号之后是常量池入口(constant_pool),常量池可以理解为Class文件之中的资源仓库:
-
常量池是Class文件结构中与其他项目关联最多的数据类型
-
常量池是占用Class文件空间最大的数据项目之一
-
常量池是Class文件中出现的第一个表类型数据项目
常量池常量的数量是不固定的,所以设置了一个u2类型的数据项用来表示常量的数量——常量池容量计数值(constant_pool_count)。这里需要注意的是:容量计数是从1开始而不是从0开始的。通过WinHex的结果可知,主版本号之后的两位代表常量的个数:0x0013,十进制数为 19,代表有 18 个常量,索引范围为:1 —— 18,通过观察常量池的字节码内容,我们也可以得到有 18 个常量。
常量池中主要存放两大类常量:字面量和符号引用。字面量主要包括文本字符串,声明为final的常量值等。符号引用有如下三类常量:
-
类和接口的全限定名
-
字段的名称和描述符
-
方法的名称和描述符
常量池中存储的项目如下:
类型 | 描述 | 标志 |
---|---|---|
CONSTANT_UTF-8_info | UTF-8编码的字符串 | 1 |
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_MethodType_info | 标识方法类型 | 16 |
CONSTANT_InvokeDynamic_info | 表示一个动态方法调用点 | 18 |
通过上表可知,常量池中存储着14个项目表,代表着不同类型数据的符号引用和字面量值。它们有一个共同点就是表开始的第一位都是一个u1的标志位,代表当前这个常量属于哪种常量类型。
访问标志
常量池之后的两个字节代表访问标志,这个标志用于识别类或接口层次的访问信息:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,类可以设置,接口不可以 |
ACC_SUPER | 0x0020 | 是否使用invokespecial的新语义 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,此标志值为真,其他类值为假 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生 |
ACC_ANNOTATION | ox2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
从上面示例代码的常量池信息中可以看到,其标志位为:ACC_PUBLIC, ACC_SUPER。ACC_SUPER 为真,因为在Java中,除 java.lang.Object类没有父类之外,其他每个没有显示继承的类都是 java.lang.Obejct 类的子类。
三大索引——类索引,父类索引,接口索引集合
接着访问标志之后是类索引(this_class),父类索引(super_class)和接口索引集合(interfaces),类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据集合,Class文件中由这三项数据来确定这个类型的继承关系。
-
类索引用于确定这个类的全限定名
-
父类索引用于确定这个类的父类的全限定名,由于Java语言是单继承的,所以父类索引只有一个。除了java.lang.Object类之外,所有的Java类都有父类,因此除了java.lang.Object之外,所有Java类的父类索引都不为0
-
接口索引集合用来描述一个类实现了哪些接口,这些被实现的接口将按照implements(接口和接口是继承extends)后的接口顺序从左到右排列在接口索引集合中
类索引,父类索引的两个u2类型的索引值表示各自指向一个类型为CONSTANT_Class_info的类型描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型常量中的全限定名字符串。接口索引集合入口的第一项是一个u2类型的接口计数器,表示索引表的容量。如果该类没有实现任何接口,则该计数值为0,后面接口的索引表(u2)不占用任何字节。
字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。字段包括类变量(static)和实例变量,但不包括在方法内部声明的局部变量。Java中可以使用如下限定符描述字段:
-
字段作用域:public,private,protected,默认
-
实例变量(new)还是类变量(static修饰)
-
可变性(final)
-
并发可见性(volatile)
-
可否被序列化(transient)
-
字段数据类型(基本类型,对象,数组)
-
字段名称
在上面这些描述信息中,各个修饰符都是布尔值,要么有某个或几个修饰符,要么没有,适合使用标志位来表示。而字段的名字,字段的类型是无法固定的,只能用常量池中的常量来描述。
如下是字段的访问标志表:
标志名称 | 标志值 | 含义 |
---|---|---|
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_SYNTHETIC | 0x1000 | 字段是否由编译器自动产生 |
ACC_ENUM | 0x4000 | 字段是否为enum |
三个概念解析:全限定名,简单名称,描述符
-
全限定名:将类名的“.”替换成“/”。例如:类名 com.yuangh.myclass.myjava,它的全限定名为 com/yuangh/myclass/myjava
-
简单名称:没有类型或者参数修饰的方法或者字段名称,例如上面代码示例中inc()方法和m字段的简单名称分别是“inc”和“m”
-
方法和字段的描述符要相对复杂一些,描述符的作用是用来描述字段的数据类型,方法的参数列表(包括数量,类型以及顺序)和返回值。下表示描述符的标识字符含义:
标识字符 含义 标识字符 含义 B 基本类型byte J 基本类型long C 基本类型char S 基本类型short D 基本类型double Z 基本类型boolean F 基本类型float V 特殊类型void I 基本类型int L 对象类型L,如Ljava/lang/Object 对象使用字符L加对象的全限定名来表示,如果是数组类型,每一个维度使用一个前置的“[”字符来描述
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码中不存在的字段(例如:内部类为了保持对外部类的访问性,会自动添加指向外部类实例的字段)。在Java中字段时不可以重载的,但是在字节码层面,只要两个字段的描述符不一致,那么字段重名就是合法的。
方法表集合
接着字段表之后是方法表,其实方法表和字段表有很多相似的地方。这里就不多赘述,直接给出方法的访问标志表如下:
方法的访问标志表:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 方法是否是public |
ACC_PRIVATE | 0x0002 | 方法是否为private |
ACC_PROTECTED | 0x0004 | 方法是否为protected |
ACC_STATIC | 0x0008 | 方法是否为static |
ACC_FINAL | 0x0010 | 方法是否为final |
ACC_SYNCHRONIZED | 0x0020 | 方法是否为synchronized |
ACC_BRIDEG | 0x0040 | 方法是否为编译器产生的桥接方法 |
ACC_VARARGES | 0x0080 | 方法是否接收不定长参数 |
ACC_NATIVE | 0x0100 | 方法是否为native |
ACC_ABSTRACT | 0x0400 | 方法是否为public |
ACC_STRICTFP | 0x0800 | 方法是否为strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否由编译器自动产生 |
上表中列出的是可以修饰方法的所有修饰符。方法的定义可以通过访问标志,名称索引,描述符表达清楚。方法里面的Java代码经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面,关于属性表下文详细说明。与字段表类似,如果父类方法在子类中没有重写,子类方法表集合中就不会出现来自父类的方法信息。 但是会出现编译器自己添加的方法,例如:类构造器方法 () 和实例构造器方法 ()。
属性表集合
Class文件,字段表,方法表都可以携带自己的属性表集合。属性表集合中有很多非常重要的属性,下面挑几个进行说明。
1. Code属性
Java程序方法体中的代码经过javac编译器处理后,最终变成字节码指令存储在Code属性内。Code属性出现在方法表的属性集合中,但并非所有的方法都必须存在这个属性,例如接口或者抽象类中的方法就不存在Code属性。
如下是示例代码的字节码文件(非常量池内容)
非常量池字节码内容:
{
public TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code: //Code属性
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 6: 0
}
SourceFile: "TestClass.java"
注意:Java虚拟机执行字节码时基于栈的体系结构。
通过上面的字节码来解释Code属性表中的几个重要参数:max_stack,max_locals,code_length和code
-
max_stack:代表操作数栈深度的最大值,就是字节码文件中的 stack。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。
-
max_locals:代表了局部变量表所需要的存储空间,就是字节码文件中 locals。max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。对于byte,char,float,short,boolean,returnAddress等长度不超过32位的数据类型,每个局部变量占用一个Slot,而double和long这两种64位的数据类型则需要两个Slot来存放。并不是在方法中用到了几个局部变量,max_locals的值就是这些局部变量之和,因为局部变量表中的Slot是可以重用的,javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小。
-
ode_length和code:存储Java程序编译后生成的字节码指令,code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。注意:code_length虽然是一个u4类型的长度值,理论上最大值可以是 2^32 - 1,但是虚拟机规范明确规定了一个方法不允许超过65535条字节码指令,实际只是用了u2的长度,如果超过这个限制,javac编译器会拒绝编译。
继续讨论上面的字节码,解释了stack和locals,还有一个参数是 args_size,代表方法中传递的参数个数。但是看了这两个方法会有点疑问:该类的两个方法——实例构造器()和一般方法inc(),我们并没有看到方法中的参数,为什么 args_size=1 呢?而且还有一点,在方法体中也没有定义任何局部变量,为什么 locals=1 呢?其实这就是我们需要重点注意的:在任何实例方法里都可以通过“this”关键字访问到此方法所属的对象。this 这个参数是编译器自动添加的,javac编译器编译的时候会把this关键字的访问转变为对一个普通参数的访问,然后在虚拟机调用实例方法时自动传入此参数。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。这只对实例方法有效,如果是用static修饰的方法,则不会有 this 这个引用。
2. ConstantValue
ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有被static关键字修饰的变量(类变量)才可以使用该属性。
-
对于非static类型的变量(实例变量)赋值是在实例构造器方法中进行
-
对于类变量,则由两种方式可以选择:
- 在类构造器方法中使用进行初始化
- 使用ConstantValue
目前Sun Javac的选择是:如果同时使用final和static来修饰一个变量(常量),并且这个变量的数据类型是基本数据类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化;如果这个变量没有被final修饰,或者并非基本数据类型和字符串,则会选择在方法中进行初始化。
参考
《深入理解Java虚拟机》