深入理解JVM虚拟机(四):Class类文件结构(一)
我们都知道Java中的class文件是经过Java编译器对Java类文件进行编译后的产物。我想有不在少数的C程序员在学习Java之后在认知上会粗略的认为C程序在经过编译后产生的.out文件与.class文件在各方面大概相同,我刚开始也这样迷惑自己,但是随着学习的深入,我们必须搞清楚.class文件到底是个什么东西。本博客就带领大家理解Java中Class类文件结构。
1. .class文件和.out文件的不同之处
要想明白两个文件的不同,我们首先要了解两个文件的定义。
1.1 .class文件
java的编译器在编译java类文件时,会将原有的文本文件(.java)翻译成二进制的字节码,并将这些字节码存储在.class文件。也就是说java类文件中的属性、方法,以及类中的常量信息,都会被分别存储在.class文件中。
从这段话中我们提取出重点:.class文件是二进制的字节码。由JVM识别、分析、执行。
1.2 .out文件
C语言源程序(.c文件),经编译器编译,由源代码生成机器指令,并加上描述信息,保存在.out文件(可执行文件)中。可执行文件能被操作系统加载运行,计算机执行该文件中的机器指令。
从这段话中我们提取出重点:.out文件是二进制的机器指令。由操作系统加载运行。
此时两个文件的区别已经非常明显:首先两个文件虽然都是二进制,但存储方式是完全不同的,一个是字节码,一个是机器指令。然后运行平台不同,一个是操作系统,一个是虚拟机。
2. 概述
理解了上面那段话,虽然我们从本质上已经知道了两个文件有什么区别,但在使用的时候却依然感觉不到任何差别,两个都是可执行文件啊,字节码和机器指令到底有什么区别呢?
从上有关计算机的第一堂课开始,老师就一直告诉我们,“计算机只认识二进制的数据,在计算机的内部,它运行的本质,就是一串010101010101… …”,这串010101… … 就是机器指令,所以操作系统可以对.out文件进行加载,运行。
那字节码是什么呢?思考这个问题的时候,你可以想一下Java的优势在哪里。有没有记得在Java界流传这样一句话,“一次编写,到处运行”。没错,字节码就是提供平台无关性的基石。
Java程序在各种不同的平台上进行编译却都生成相同的字节码,这些字节码由JVM进行加载,运行。这种统一的程序存储格式,从而实现了Java的跨平台性。使用Java编译器可以将Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器一样可以将程序代码编译成Class文件,虚拟机并不关心Class的来源是何种语言。
3. Class类文件结构
要想了解.class文件里面存储的具体内容,我们首先要对.class文件的整体存储结构有一个全面的认识。当然在这之前,我们先来对.class文件做个详细的定义。
3.1 Class文件定义
任何一个类或接口都对应唯一的.class文件,具体来说如下图:
Class文件是一组以字节为基础单位的二进制流,各个数据项目紧凑的排列在Class文件中。并且数据项的存储方式类似于大端模式,不了解大端模式的自行百度。Class文件结构中只含有两种数据类型:无符号数与表。
- 无符号数就是基本的数据类型,我们以u1,u2,u4,u8分别来表示1个字节,2个字节,4个字节,8个字节。无符号数可以用来描述数字,索引引用,数量值或是UTF-8编码构成的字符串。如果你对上面这句话感到抽象,别急,看到最后再回过头,就会发现疑惑已经自行解决。
- 表是由多个无符号数或其他表作为数据项构成的复合数据结构,习惯以“_info”结尾。Class文件本质上就是一张表。
Class文件格式:
首先编写一个Java文件
package com.basic.java.classStructure;
public class TestClass {
private int m;
private int c(){
return m+1;
}
}
编译成class文件后,用winhex软件打开class字节码
3.2 魔数
魔数是每个Class文件的头四个字节,作用为确定这个文件是否是一个能被虚拟机接受的Class文件。它的值也非常的好记,充满了浪漫气息:CAFEBABE(咖啡宝贝?),和Java的logo似乎有某种联系~~
3.3 Class文件的版本
后面4个字节存储的就是Class文件的版本号:5、6字节是次版本号,7、8字节是主版本号,这四个字节的作用一般是用来让我们分辨当前的JDK版本,高版本的JDK可以向下兼容低版本,反过来则不可以。
一般来说我们不需要太在意次版本号,版本号转换对应JDK一般步骤如下:
- 将主版本号转换为10进制
- 用版本号减去45(JDK版本号从45开始)再加上1
例如上图中我的次版本号为0x00,主版本号为0x34,转换十进制为52,则JDK版本为52-45+1,也就是8,所以我目前的JDK版本为JDK1.8,没有错误。
3.4 常量池
要说Class文件中的重要组成部分是什么,我觉得肯定属于常量池(其它项目关联最多数据类型,Class文件空间最大的数据项目之一,表类型数据项目)和属性表,关于属性表的部分我们下次再说。
Java虚拟机运行时方法区中的常量池就是将类加载进内存之后.class文件中的常量池。
3.4.1 常量池容量计数
常量池的入口处首先是一项u2类型(无符号,2个字节)的数据,代表常量池容量计数值,设置这个值的原因乃是由于常量池中常量的数量并不固定。从上图中可以看到此Class文件的常量池容量为0x0016。
值得一提的是,常量池中的容量计数是从1开始而不是从0开始,Class文件中也就只有常量池的容量计数是从1开始。这样设计的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义。
所以我的Class文件常量池容量转换为10进制为22,也就是只有21项常量,索引值范围1~21。
3.4.2 常量池存储项目类型
上面说到常量池容量,然后我们需要分析常量池中的内容,在分析常量池中存储的内容之前,我们需要对常量池的存储类型做一个介绍。
- 常量池中主要存放两大常量:字面量和符号引用。
- 字面量接近于Java语言层面的常量概念,如文本字符串,final常量值等。
符号引用包含下面三类常量(关于全限定名与描述符稍后再说):
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
常量池中每一个常量都是一个表。下面我们列出了相应的标志对应的常量池项目类型:
3.4.3 利用javap命令查看常量池
我们可以使用Javap命令借助计算机进行常量表的输出。
我们来看一下用法:
//TestClass 就是我们上面TestClass.java文件编译后产生的Class文件
javap -verbose TestClass
输出结果:
警告: 二进制文件TestClass包含com.basic.java.classStructure.TestClass
Classfile /C:/Users/MasterTj/Desktop/TestClass.class
Last modified 2018-12-11; size 411 bytes
MD5 checksum 3deeee1911ec575f2b826c440a51e7a1
Compiled from "TestClass.java"
public class com.basic.java.classStructure.TestClass
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#19 // com/basic/java/classStructure/TestClass.m:I
#3 = Class #20 // com/basic/java/classStructure/TestClass
#4 = Class #21 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/basic/java/classStructure/TestClass;
#14 = Utf8 c
#15 = Utf8 ()I
#16 = Utf8 SourceFile
#17 = Utf8 TestClass.java
#18 = NameAndType #7:#8 // "<init>":()V
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/basic/java/classStructure/TestClass
#21 = Utf8 java/lang/Object
{
public com.basic.java.classStructure.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 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/basic/java/classStructure/TestClass;
}
SourceFile: "TestClass.java"
上面的输出结果中有很多信息都与我们刚才分析Class文件所得的结果相符:
次版本号与主版本号:
minor version: 0
major version: 52
2.索引值范围:
#1——#21
3.常量池项目类型分析
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
上述代码表示第一项常量指向第4和第15项常量。
至于java/lang/Object.""????)V这个东西,有关我们之前说的类的全限定名与描述符,等一下再说。
3.5 访问标志
常量池结束之后,紧接的两个字节代表访问标志,用于标识一些类或者接口层次的信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类,是否被声明为final等。
具体标志位及含义如下:
从上面使用Javap命令输出常量表结构之后有这样一行代码:
flags: ACC_PUBLIC, ACC_SUPER
我们查看访问标志可知,这个flags对类的描述是正确的,因此它的access_flags的值应为:0x0001|0x0020=0x0021。如果你对应最上面原始的字节码,会发现这个和在常量池后面所占两个字节所显示的数值相一致。
3.6 类索引、父类索引、接口索引集合
访问标志之后,就是类索引和父类索引、接口索引集合了。类索引、父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据集合,Class文件用这三项数据来确定这个类的继承关系。
- 类索引用于确定这个类的全限定名。
- 父类索引用于确定这个类的父类权限定名除外,由于Java语言不允许多重继承,所以父类索引就只有一个,除了java.lang.Object之外,所有的Java类都有父类索引。因此,除了java.lang.Object之外,所有Java类的父类索引都不为0.
- 接口索引集合就是用来描述这个类实现了哪些接口,这些被实现的接口按照implements语句。被实现的接口将按implements语句后的接口顺序从左到右排列在接口索引集合中。
上图中我标记出来的字节码分别为0x0003、0x0004、0x0000,也就是说类索引为常量池中的第3常量、父类索引为常量池中的第4常量。而接口索引有点不同,接口索引的第一项—u2类型的数据为接口计数器,表示索引表的容量。如我刚才所说,可知接口计数器值为0,后面接口的索引表不再占用任何字节。
结合我们刚才Javap命令的输出结果:
#3 = Class #20 // com/basic/java/classStructure/TestClass
#4 = Class #21 // java/lang/Object
#20 = Utf8 com/basic/java/classStructure/TestClass
#21 = Utf8 java/lang/Object
可以看到第3、4常量又分别指向20、21常量,而它们的值分别为UTF-8格式的TestClass和java/lang/Object。
3.7 字段表集合
此表用于描述接口或类中声明的变量。字段包括类级变量(static)和实例级变量,但不包括局部变量。字段包含哪些信息?字段作用域(public、private、protected)、是实例变量还是类变量static、可变性final、并发可见性volatile、可否序列化transient(序列化)、字段数据类型(基本数据类型、字段、数组)、字段名称。
除了字段数据类型、字段名称的字节长度无法固定而需要引用常量池中的内容外,其它修饰符都很适合用标志位来表示。因此字段表主要存储了以下信息:
名称索引、描述符索引、访问标志、属性表
- 名称索引就是字段的名称的全限定名
- 描述符索引就是描述字段的数据类型。
- 访问标志就是下面这幅图
- attributes_info用来描述一个字段的额外信息,如:final static int m = 123;,字段表中就会有一个ConstantValue的属性,其值指向常量123。
在实际情况中,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择一个,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中字段必须由ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则定义的。
3.7.1 名称索引、全限定名、描述符
名称索引和描述符索引。它们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。
简单名称和全限定名
- 简单名称就是指没有类型和参数修饰的方法或字段名称,这个类中的inc()方法和m字段的简单名称分别是“inc”和“m”。
- 全限定名就是 “com/basic/java/classStructure/TestClass;”
- 名称索引就是对简单名称和全限定名在运行时常量池中的索引编号。
描叙符索引
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型、顺序)和返回值。根据这些描叙符规则,基本数据类型(byet、char、double、float、int、long、short、boolean)以及代表无返回这的void类型都用一个大写字母来表示,而对象类型则用字符L加对象的全限定名来表示。
对于数组类型,每一个维度将使用一个前置的“[”字符来描述,例如定义一个“java.lang.String[][]”类型的二维数组,会被记录为:“[[Ljava/lang/String;”,说到这就不得不提全限定名的表示方法了。
描述符的使用
先参数列表、后返回值。
void inc()
java.lang.String.toString()
int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)
描述符语言:
()V
()Ljava/lang/String;
([CII[CIII)I
3.8 实例分析
类索引,父类索引、接口索引集合之后便是字段表,它的第一个u2类型数据为容量计数器field_flags。由上面的图可知值为0x0001,也就是说只有一个字段,接下来的u2便是access_flags标志… …依次类推,字段表的固定数据项目分析完成。
注意:最后,字段表集合不会列出从父类中继承而来的字段,但有可能列出原本Java代码里面不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
还有对于字节码来说,如果两个字段的描述符不一致,那字段重名就是合法的。这在Java里面显然是不可能的。
4. 方法表
方法表结构:
方法表与字段表十分相似,在这里我给出方法表的访问标志:
关于方法表的分析我不在赘述,与前面的分析结果都基本相同。我们来看一下在方法表中需要的注意的几个方面:
- 方法里面的代码存储在“Code”属性中,我们在下一节中进行讲述。
- 方法表集合的入口也有一个u2类型的计数器容量的数据。
- 方法表中也有可能出现编译器自动添加的方法,如 方法。
- 在Class文件中,描述符不完全一致的方法也可以共存,也就是说在Class文件中就算只有返回值不同,也是一种重载。