引:java代码编译的结果从本地机器码转变为字节码,并且生成了类文件,那么这个类文件里面是什么东西呢?
JVM的无关性
一般提到Java的好处,其中必定有一条是平台无关性,但是这个太狭隘了,其实它有两点无关性。
平台无关性
Java在刚刚诞生之时就有一个著名的宣传口号:“一次编写,到处运行(Write Once, Run Anywhere)”。“与平台无关”的理想最终实现在操作系统的应用层上:其实就是Java虚拟机了,他可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。
语言无关性
目前已经一大批能够在JVM运行语言了,就我自己知道并且使用过就有Groovy、Jython、Scala等。实现语言无关性的基础仍然是虚拟机和字节码存储格式。其他语言通过自己的编译把程序代码编程成符合Java虚拟机规范的Class文件即可。
类文件的结构
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑排列在Class文件之中,中间没有任何分隔符。Class文件只有两种数据类型:无符号数和表
- 无符号数:以u1,u2,u4,u8来分别代表1个字节,2个字节,4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数值量或者按照UTF-8编码构成字符串值。
- 表:它是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表习惯地以“_info”结尾。
整个Class文件本质上就是一张表,它由下表所示的数据项构成。
类型 | 名称 | 数量 |
---|---|---|
u4 | 魔数 | 1 |
u2 | 次版本号 | 1 |
u2 | 主版本号 | 1 |
u2 | 常量数量 | 1 |
cp_info | 常量池 | 常量数量-1 |
u2 | 访问标志 | 1 |
u2 | 类索引 | 1 |
u2 | 父类索引 | 1 |
u2 | 接口数量 | 1 |
u2 | 接口索引集合 | 接口数量 |
u2 | 字段数量 | 1 |
field_info | 字段表 | 字段数量 |
u2 | 方法数量 | 1 |
method_info | 方法表 | 方法数量 |
u2 | 属性数量 | 1 |
attribute_info | 属性表 | 属性数量 |
接下里稍微详细看看上面的数据:
不过按例子来吧,下面是一个简单的Java类1
2
3
4
5
6
7
8
9
10
11package com.todorex.demo;
public class TestClass {
private int m;
public int inc() {
return m+1;
}
}
先编译这个类得到TestClass.class文件,打开它可以下面的16进制数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 000e 5465 7374 436c 6173 732e
6a61 7661 0c00 0700 080c 0005 0006 0100
1a63 6f6d 2f74 6f64 6f72 6578 2f64 656d
6f2f 5465 7374 436c 6173 7301 0010 6a61
7661 2f6c 616e 672f 4f62 6a65 6374 0021
0003 0004 0000 0001 0002 0005 0006 0000
0002 0001 0007 0008 0001 0009 0000 001d
0001 0001 0000 0005 2ab7 0001 b100 0000
0100 0a00 0000 0600 0100 0000 0600 0100
0b00 0c00 0100 0900 0000 1f00 0200 0100
0000 072a b400 0204 60ac 0000 0001 000a
0000 0006 0001 0000 0009 0001 000d 0000
0002 000e
然后用javap 解析TestClass.class文件,得到:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53Classfile /Users/rex/IdeaProjects/JVMTest/src/com/todorex/demo/TestClass.class
Last modified 2017-12-1; size 292 bytes
MD5 checksum 337a51d3bebe0e9a82142a352eb0977e
Compiled from "TestClass.java"
public class com.todorex.demo.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 // com/todorex/demo/TestClass.m:I
#3 = Class #17 // com/todorex/demo/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 com/todorex/demo/TestClass
#18 = Utf8 java/lang/Object
{
public com.todorex.demo.TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 6: 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 9: 0
}
SourceFile: "TestClass.java"
魔数
- 每个Class文件的头4个字节成为魔数
- 使用魔数来进行身份识别(文件类别),因为如果使用文件名来识别,安全性太低,由于文件名可以随意改动。
- Class文件的魔数的获得就有“浪漫气息”,值为0xcafebabe,上面16进制文件也可以看到。这让自己想起了高中用的三星手机打开qq就是一杯咖啡的标志。
Class文件的版本
接下来第5,6个字节显示的是次版本号,7,8字节显示的是主版本号。
高版本的JDK可以向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。
常量池
在版本号之后的是常量池入口。
- 常量池是Class文件结构中与其他项目关联最大的数据类型,也是占Class文件空间最大的数据项目之一。
- 常量池常量的数据不是固定的,在最前面的Class文件组成可以看到有一个常量数量项,这个容量计数是从1而不是0开始的,比如上面的十六进制的值为0x0013(19)就代表有18个常量。
常量池主要存放两大类变量
- 字面量:文本字符串、声明为final的常量值等
- 符号引用:(编译原理的概念)
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
常量池的每一项常量都是表,常量的顺序可以参照javap解析出来的常量顺序,在JDK1.7以后共有14种不同类型的表,他们共同点是表的第一位是一个u1类型的标志位(tag),具体的标志对应的类型参照书《深入理解Java虚拟机》。这里提一下CONSTANT_UTF8_info这个表:
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
length值说明了UTF-8编码的字符串长度是多少字节,他后面跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。而u2最大值是65535,所以说如果Java程序如果定义了超过64KB(大约)英文字符的变量或者方法名,将无法编译。
- UTF-8缩略编码和UTF-8编码的区别:UTF-8编码都是使用3个字节编码,而UTF-8缩略编码可以使用1或2或3个字节编码。
访问标志
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等,访问标志一共有16个标志位可以使用,当前只定义了8个。
类索引、父类索引、接口索引集合
- 类索引、父类索引都是u2类型的数据,类索引、父类索引都是指向一个CONSTANT_Class_info的类描述符常量
- 接口索引集合是一组u2类型的数据的集合,它入口的第一项是u2的接口计数器,后面就是具体接口索引
- Class文件有这三项数据来确定这个类的继承关系
字段表集合
接下来是字段表,字段表用于描述接口或者类中声明的变量,包括类级变量以及实例级变量,不包括方法内部声明的局部变量。我们看一下一个字段表的构成:
类型 | 名称 | 数量 |
---|---|---|
u2 | 访问标志 | 1 |
u2 | 简单名称索引 | 1 |
u2 | 描述符索引 | 1 |
u2 | 属性数量 | 1 |
attribute_info | 属性表 | 属性数量 |
根据上表我们解析一下其中的含义:
- 访问标志:它和之前的访问标志很类似。
- 简单名称索引:它指向一个CONSTANT_UTF8_info类型的常量,这里面存储了本字段的名字信息,像javap解析后的第5个常量m。
- 描述符索引:用来描述字段的数据类型,像javap解析后的第6个常量I,代表了基本类型int
- 属性表(可能有ConstantValue表
下面是字符表的注意点:
- 字段表集合不会列出从超类或者父接口继承而来的字段。
- 字段表有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加执行外部类实例的字段。(不懂,大佬请指教)
- Java语言中字段是无法重载的,名称必须不一样,但是对于字节码来说,如果两个字段的描述符不一致,那么字段重名是合法的。
方法表集合
接下来是方法表,方法表的内容和字段表几乎完全一致。其中坑顶也看一下方法表的构成:
类型 | 名称 | 数量 |
---|---|---|
u2 | 访问标志 | 1 |
u2 | 简单名称索引 | 1 |
u2 | 描述符索引 | 1 |
u2 | 属性数量 | 1 |
attribute_info | 属性表 | 属性数量 |
这里解释一下和字段表不一样的地方
- 简单名称索引:它指向一个CONSTANT_UTF8_info类型的常量,这里面存储了本字段的名字信息,像javap解析后的第11个常量inc。
- 描述符索引:它的作用是用来描方法的参数列表(包括数量、类型以及顺序)和返回值,像javap解析后的第12个常量()I,表示的就是一个返回值为int的方法。
- 属性表:这里肯定存放了Code属性表(方法里的Java代码)
下面是方法表的注意点:
- 如果父类方法在子类没有被重写,方法表集合中就不会出现来自父类的方法信息。
- 可能会出现由编译器自动添加的方法,最典型的有类构造器“
”方法和实例构造器“ ”方法,就像javap解析后的第11个常量 。 - 在Java语言中,要重载一个方法,需要相同的简单名称和与原方法不同的Java代码的方法特征签名,这里需要解释一下Java特征签名和JVM特征签名:
- Java特征签名:方法名称、参数顺序
- JVM特征签名:Java特征签名、方法返回值以及受查异常表
属性表集合
最后就是属性表集合了,在Class文件、字段表、方法表都可以携带自己的属性表集合,以及用于描述某些场景专有的信息。属性表集合不要求各个属性具有严格的顺序,并且只要不与已有属性名重复就好。
上面程序的例子出现过几个属性表:Code、LineNumberTable、SourceFile、ConstantValue,接下来我们详细说一下:
Code属性
Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内,Code属性出现在方法表属性集合之中,我们先看看Code属性表的结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | 属性名索引 | 1 |
u4 | 属性长度 | 1 |
u2 | 操作数栈深度的最大值 | 1 |
u2 | 局部变量表所需的存储空间 | 1 |
u4 | Java方法代码字节码指令长度 | 1 |
u1 | Java方法代码字节码 | Java方法代码字节码指令长度 |
u2 | 显式异常表长度 | 1 |
exception_info | 显式异常表 | 显式异常表长度 |
u2 | 属性个数 | 1 |
attribute_info | 属性表 | 属性个数 |
接下来我们说明一下几个关键项:
- 属性名索引:它是一项指向CONSTANT_UTF8_info型的索引,常量值固定为“Code”,就像上面javap解析出来的常量池的第9个。
- 属性长度:固定为整个属性表长度减去6个字节。
- 操作数栈深度的最大值:在方法执行的任意时刻,操作数栈都不会超过这个深度,虚拟机运行的时候需要根据这个值来分配栈栈中的操作数栈深度。
- 局部变量表所需的存储空间:它的单位是Slot,局部变量表存储了方法参数(包括实例方法中的隐藏参数this)、显示异常处理器的参数(try-catch检查的异常)、方法体中定义的局部变量,Javac编译器会根据变量的作用域来分配Slot,然后计算出需要的存储空间大小。
Java方法代码字节码指令长度和Java方法代码字节码:存储了Java源程序编译后生成的字节码指令,目前Java虚拟机规范已经定义了约200条编码值对应的指令含义。
PS:如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义以及其他信息)两部分,那么在整个Class文件,Code属性用来描述代码,所有其他数据项目都用于描述元数据。
- 显式异常表:用于显示try-catch代码块要检查的异常
LineNumberTable
它的使用位置是在Code属性,用于描述Java源码行号与字节码行号之间的对应关系,当抛出异常的时候堆栈会显示出错的行号。
SourceFile
它的使用位置是类文件,用于记录生成这个Class文件的源码文件名称,当抛出异常的时候会显示出错代码所属的文件名。
ConstantValue
它的使用位置是字段表,作用是通知虚拟机自动为静态变量赋值,只有static关键字修饰的变量(类变量)才可以使用这项属性,虚拟机对于类变量和实例变量赋值的方式有所不用。
- 实例变量:在实例构造器
方法中进行 - 类变量:在类构造器
方法中或者使用ConstantValue属性
目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量,并且这个常量的数据类型是基本类型或者String类型,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型或者字符串,则将会选择在
总结
通过上面的分析,我们一定能清楚的知道Class文件是什么以及Class文件包含什么东西,再也不怕了!!
参考
- 《深入理解Java虚拟机》