YY's Studio.

【虚拟机简史】万物的DNA—Class文件

2018/08/13

前言

DNA是生命的背后基石,支持着生命的基本构造和性能。储存着生命的种族、血型、孕育、生长、凋亡等过程的全部信息。

就如同在现实生活中每种生物都有自己的独特DNA一样,虚拟机世界中每个对象都拥有自己的DNA——Class文件。储存着对象的类型、字段、方法、可见性,GC等过程的全部信息。

Class文件的由来

在了解之前class文件之前,我们必须要明白为什么会有class文件。

在C/C++的时代,为了解决平台移植,开发者们必须在不同的操作系统编译相对应的文件。因为每个操作系统的CPU指令集机都是不同的,而为了提供C/C++的运行环境,分别实现了C/C++的标准运行库。

upload successful

针对每个平台生成的可执行文件,只能在该平台上运行,因为其内部的指令不同。还记得高中从游民星空下载游戏的时候都要区分是x86还是x64么?就是这个原因。

而Java的口号是:“Write Once,Run Anywhere”。其充分吸取了C/C++的教训,采用了字节码(*.class)与虚拟机的结合方式。将平台层面上差异处理交给JVM,而不需要经过开发者。现在打着同样口号的weex在实现思路上也一脉相承。

upload successful

这样的机制虽然解决了跨平台,可是同样引进了新的问题——性能。在C/C++中最后生成的可执行文件是本地机器码,而Java中,由于为了避免平台差异的问题,统一用字节码作为程序存储媒介。而这样代价就是在运行的过程是需要转译步骤的。即

字节码 -> 本地机器码

不过在后期Java也衍生出了即时编译器,在运行时,直接将热点字节码转换为本地机器码。一定程度上缓解了被人诟病的缺点。

Class文件构成

在自然界中,DNA是一种长链聚合物,上面有成千上万的基因片段,决定着生物的性状。

在虚拟机世界中,Class文件则是一段遵循JVM协议的紧密固定结构,里面包含着Class版本信息,常量池,访问标识,字段集合等类信息。同样决定着实例化之后对象的状态。

upload successful

我们可以先看看java文件编译后的样子,到底是怎么样的。

1
2
3
4
5
6
7
8
9
public class HelloWorld {

String demo = "demo";

public void main(String args[]) {
int i = 0;
}

}

用javac指令编译之后,打开生成的 HelloWorld.class

upload successful

会发现这是一堆按照固定格式生成的字节码,不要觉得头疼,就像需要拿着基因表达谱翻译基因片段一样。我们只需要用其固定的解析模板就可以解读它。

Class文件采用一种伪结构来存储数据,其中只有两种数据类型:无符号数和表。以u1、u2、u4来代表1个字节、2个字节、4个字节的无符号数。以”_info“结尾的为表。

类型 名称 数量
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 attributes_count 1
attribute_info attributes attributes_count

以上的attribut_info 是一个属性表,在类文件、cp_info、field_info、method_info、包括attribut_info内都有可能出现。因为篇幅有限,只会重点介绍一些重要的部分。

1.校验字节

  • 魔数(4字节)

基于安全方面的考虑,JVM对类文件的头部统一要求加入这块魔数区域,在类的验证过程中会对此进行校验。

为什么Java的logo上会有咖啡?

据说是当时的开发小组的成员希望Java和Baristas咖啡一样深受欢迎,因此将这块区域定义为:cafe babe。后来在商标上也采用咖啡这个元素。

  • Class文件版本信息(2字节 + 2字节)

这块区域定义的是编译Class文件的JDK版本。这块区域又分为次版本号与主版本号。当JVM支持的JDK版本低于或等于Class要求的版本时候可以运行,而高于Class要求的版本后则不能运行。

2.常量池

常量池会先告诉JVM自己的长度(constant_pool_size 为u2字节),后面才会排布自己的常量。

这一块区域在Class文件中的地位相当重要。作用类似于Class文件中的114查号台。

这个一个神奇的区域,找常量、找变量,找接口、找方法、找字段、找类,就在常量池!

常量池中主要存放两个类常量

  1. 字面量
  2. 符号引用

这两个最后都是由 CONSTANT_XXX_info 这样的数据结构组成。

类型 作用
CONSTANT_Utf-8_info Utf-8编码的字符串,承接其他类型的最后实现
CONSTANT_Integer_info 整型字面量
CONSTANT_Float_info 浮点型字面量
CONSTANT_Long_info 长整型字面量
CONSTANT_Double_info 双精度字面量
CONSTANT_String_info 字符串字面
CONSTANT_Class_info 类或接口的符号引用
CONSTANT_Fieldref_info 字段的符号引用
CONSTANT_Methodref_info 类中方法的符号引用(只有被调用过才会生成)
CONSTANT_InterfaceMethodref_info 接口中方法的符号引用
CONSTANT_NameAndType_info 字段或方法的部分符号引用
CONSTANT_MethodHandle_info 方法句柄
CONSTANT_MethodType_info 方法类型
CONSTANT_InvokeDynamic_info 表示动态方法调用点

字面量很好理解,把它近似于认为我们Java中的常量就好了。比如字符串、声明为final的常量。

而符号引用则是近似当做字符串,包括了以下三个常量

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

为什么要有这个符号引用呢?

因为Class文件不会保存各个方法、字段的最终内存布局信息。这些信息必须要在运行期才可以得到。只有在类创建或运行时解析、翻译的时候,才将符号引用转化为真正的内存地址。因此在类未被加载到内存的时候,符号引用被做为字符串指针,实现无差异的定位到目标

1
2
3
4
5
6
7
8
import com.demo.B

public class A{

public void a() {
B b= new B();
}
}

A类引用了B类,在编译时A类并不知道B类的实际内存地址,因此只能使用符号”com.demo.B”(实际中是由CONSTANT_Class_info的常量而非简单的字符串)来表示B类的地址。

至于符号引用转为内存地址,会在后续类的加载中介绍。

我们再回到上面的栗子,我们

1
2
3
4
5
6
7
8
public class HelloWorld {

String demo = "demo";

public void main(String args[]) {
int i = 0;
}
}

我们现在来试试用javap来反编译刚才编译好的HelloWorld.class。(javap是JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。这里我只截取的部分。)

javap -verbose HelloWorld

upload successful

虽然我的这个类很简单,可是在常量池中还是存在着很多数据。我们会发现,几乎这个类中所有的数据都在常量池中有着自己的符号引用或是字面量。

我先简单的解释一下这个输出的信息,以第一条为例:

1
#1 = Methodref		#5.#16	//java.lang.Object."<init>":()V

#1的作用是作为类内部的索引。作为114平台,类内部的成员想要找到自己相关的常量,都是通过#index来实现。 而Methodref指的是其类型,在这里指的是方法的符号引用。后面的 #5.#16 是#1的结果,在这里则是要继续链接到后面的 Utf-8的值。即 java.lang.Object.”“:()V

下文会讲到很多属性,这里需要注意的是,属性类型也是通过常量池的utf-8的索引来确定的。

3.访问标记

这块区域用于标识一些类或者接口层面的访问信息。比如,这个Class是类还是接口;是否为public;是否为抽象类型;是否为final等。

4.索引集合

索引区域的作用是:

持有着指向到常量池中类相关信息的指针

这一块的索引集合包含三个部分

  1. 当前类索引(u2)
  2. 父类索引(u2)
  3. 接口索引(变长)

Java只支持单继承,因此父类的索引只有一个,而接口取可以按照事先的接口数变长,索引定位的是其在常量池中的符号引用。

现在我们以上常量池做个demo。

upload successful
我们看到 #4 与 #5 为CONSTANT_Class_info 信息,因此在字节码中肯定存在 0004 0005 的片段。如下所示

upload successful

5.字段表

字段表用于描述接口或者类中声明的变量(不包括局部变量)。里面包含着字段以下内容

  1. 作用域(public、private、protected);
  2. 是否为静态变量 (static);
  3. 可变性(final);
  4. 并发可见性(volatile:强制从主内存中读写);
  5. 是否可以被序列化;
  6. 字段类型;
  7. 字段名称;

和前面的索引集合一样,字段表也是利用同样的方式去索引到常量池中的字段名称、字段类型、字段值。

我们再回顾一下刚才代码

1
2
3
4
5
6
7
8
public class HelloWorld {

String demo = "demo";

public void main(String args[]) {
int i = 0;
}
}

这里面变量只有一个字符串 demo,我们再看看刚才Javap反编译关于字段表的结果。

upload successful
其中 descriptor 在字节码中应该索引到的是 #7的位置,flags为空是因为没有给String设置作用域。

为什么 descriptor 是 Ljava/lang/String 而不是 java/lang/String? 多出来的L是什么意思

这里的descriptor的作用是表述字段的数据类型。根据描述符的规则,基本数据类型(byte、char、double、float、int、long、short、boolean)和对象类型都需要加上相对应的字符。

这里的 L 就是规定对象类型需要加上的字符。

基础类型的字符:long的字符是J,其他基础类型的字符很好记忆,都是首字母。如 int 就是 I

我们再修改一下我们的修改实例代码,将String改为字符串数组

1
2
3
4
5
6
7
8
public class HelloWorld {

String demo[];

public void main(String args[]) {
int i = 0;
}
}

看看其javap之后的结果
upload successful
Ljava/lang/String 前面加了一个 [。 这里的意思是如果是数组类型,每增加一个维度都将在类型前加一个[, 所以如果是

字段表中

1
String demo[][]

就是[[Ljava/lang/String

这种格式其实在很多奔溃中会出现。我们了解起来也并不陌生。

字段表有一个专属的属性是ConstantValue,里面存储的是在类的常量在常量池的索引。

5.方法表

方法表的构成和字段表十分的类似。

  1. 作用域(public、private、protected);
  2. 是否为静态方法 (static);
  3. 是否可以被子类复写(final);
  4. 并发性(sychronize:线程同步锁);
  5. 是否可以是抽象方法;
  6. 返回值;
  7. 方法名称;
  8. 入参信息;

我们可以看看从刚才常量池的截图中看到看到当前这个Class有两个方法:

  1. 这个是在编译器合成的,在实例化的过程中会先被调用。
  2. 这个方法才是我们写的方法。

方法表的属性表中还会存在Code、Exception等,Code里这里面就是我们的方法的执行指令,Exception则是方法可能会抛出异常的索引。Code属性很重要,这里我们展开来说它。

6. Code

Code属性主要有几个部分

  1. code:具体字节码指令(一个指令一个字节);
  2. max_stack: 操作数栈(先入后出栈)最大深度;
  3. max_locals: 局部变量表所需的最大存储空间,单位为slot(可复用且单个长度不超过32位);
  4. exceptinfo_info:记录着代码中可能存在的异常的执行路径(从第几行到第几行);
  5. attribute_info:code属性中还会嵌套其他属性如LineNumbertable等;
  6. code_length:u4,因此一个方法最多能放65535个字节;

我们再回头看看我们javap的结果

upload successful

我们可以看到Code部分,操作数栈深度为1,局部变量表的最大容量为3,参数为2

看到这里,会有人为什么参数为2,明明入参只有一个的?

这里的main方法是实例方法,因此在这里需要默认传入一个当前实例(即this),且放在局部变量表的第一个位置,不过在这里被隐藏了。而如果是静态方法则不需要传入。

这里先给大家简单的介绍一下字节码指令。指令的具体细节很多,如果要记下来比较繁琐,我这里先举出一些常用的指令

类型(T为泛型) 作用
Tload 从局部变量表中读取数值
Tconst 将常量加载到操作数栈
Tstore 将操作数栈的值存到局部变量表
Treturn 返回值
new 创建实例
getXXX 获取字段
invokevirtual 调用对象的实例方法
invokespecial 用于调用init方法、私有方法、父类方法
invokestatic 用于调用静态方法

接下来我们在说明一下指令、操作数栈、局部变量表之间的关系:

加载和存储指令用于将数据在栈帧中的布局变量与操作数栈之间来回传输。我简单用一张图描述3者的关系。

upload successful

总结

一个Class文件的结构就是按照这样的格式来分布,有兴趣的同学,可以对照字节码一点一点的看看着。在Class文件中最重要的就是常量池,当掌握之后,能更助利了解后续类加载,方法调用。

CATALOG
  1. 1. 前言
  2. 2. Class文件的由来
  3. 3. Class文件构成
    1. 3.1. 1.校验字节
    2. 3.2. 2.常量池
    3. 3.3. 3.访问标记
    4. 3.4. 4.索引集合
    5. 3.5. 5.字段表
    6. 3.6. 5.方法表
    7. 3.7. 6. Code
  4. 4. 总结