Java虚拟机之Class文件

这一篇主要解析Class文件结构,并通过Java编程实现相关细节对解析的结构进行验证。

字节码分析用例

首先准备一段用例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ClassFileTest {
public static final boolean FLAG = true;
public static final byte BYTE = 123;
public static final char X = 'x';
public static final short SHORT = 12345;
public static final int INT = 123456789;
public static final long LONG = 12345678901L;
public static final float PI = 3.14f;
public static final double E = 2.71828;
public static void main(String[] args) throws RuntimeException {
System.out.println("Hello, World!");
}
}

使用javap工具的-verbose参数输出的ClassFileTest.class文件字节码内容如下多图:

图1

图2

图3

图4

以上就是javap“翻译”的Class文件字节码内容。

创建字节读取工具ClassReader

首先编写ClassReader类。翻开《深入理解Java虚拟机》这本书的165页可知,class文件格式基本上是无符号数和表组成,而表自身又是由多个无符号数或者其它表作为数据项构成。一个字节、二个字节、四个字节和八个字节的无符号数用u1、u2、u4、u8表示,ClassReader就是提供对应的方法来读取这样大小的字节。

接下来用ClassReader类一一读取这些数据项,首先是u1:

1
2
3
4
public int readUint8() {
int val = data[cursor++] & 0xff;
return val;
}

接着是u2:

1
2
3
4
5
6
7
8
public int readUint16() {
int val = 0;
for (int i = 1 + cursor; i >= cursor; i--) {
val |= (data[i] & 0xff) << ((1 + cursor - i) * 8);
}
cursor = cursor + 2;
return val;
}

这段代码中,通过位运算,将byte[]高索引的元素放在读取内容的低位,将低索引的元素放在读取内容的高位,然后再通过或运算,整合出新的内容并返回,通过这种方法解决BigEndian问题。

u4、u8同理,但考虑到Java没有无符号基本类型,试想byte[]数组中第一个元素二进制表示为11111111,这样在求u4的过程中,三十二位的首位是1,如果返回为int类型的话,Java会认为是负数。所以u4用long表示,代码如下:

1
2
3
4
5
6
7
8
public long readUint32() {
long val = 0;
for (int i = 3 + cursor; i >= cursor; i--) {
val |= (data[i] & 0xff) << ((3 + cursor - i) * 8);
}
cursor = cursor + 4;
return val;
}

u8就用BigInteger表示:

1
2
3
4
5
6
7
8
public BigInteger readUint64() {
byte[] bytes = new byte[8];
for (int i = 0; i < 8; i++) {
bytes[i] = data[cursor + i];
}
cursor = cursor + 8;
return new BigInteger(bytes);
}

这段代码还包括读取uint16表和用于读取指定数量的字节的功能的函数,比较简单就不罗列了。详细代码(包括本篇后续所述功能的完整源代码)见GitHub

ClassFile

每一个Class文件对应于一个如下所示的ClassFile结构体(图片引用自《Java虚拟机规范》):

ClassFile

图5

接下来需要构建一个ClassFile类的读取方法read():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void read(ClassReader reader) {
MemberInfo mb = new MemberInfo();
readAndCheckMagic(reader);
readAndCheckVersion(reader);
constantPool = new ConstantPool();
constantPool.readConstantPool(reader);
accessFlags = reader.readUint16();
thisClass = reader.readUint16();
superClass = reader.readUint16();
interfaces = reader.readUint16s();
fields = mb.readMembers(reader, constantPool);
methods = mb.readMembers(reader, constantPool);
attributes = GreateAttributeInfo.readAttributes(reader, constantPool);
}

该方法通过对编译后的字节码文件进行读取,每个无符号数或表获取到相应字节数的数据。例如readAndCheckMagic(reader)方法:

1
2
3
4
5
6
public void readAndCheckMagic(ClassReader reader) {
long magic = reader.readUint32();
if (magic != 0xCAFEBABE) {
throw new RuntimeException("java.lang.ClassFormatError: magic!");
}
}

这个方法读取4个字节的数据,然后判断该数据表示的long数是否==十六进制表示的“CAFEBABE”,需要了解的是,此时ClassReader中的游标cursor向前进4,下一个数据的读取从新的cursor开始读取。

constantPool

这里重点需要了解的是constantPool常量池,常量池的项目类型见下图:

常量池

图6

这里有14种常量类型,它们各自均有自己的结构。接下来再放上编译后的字节码文件解析出的字节码数据,后续将根据这个数据分析一二:

字节码数据

图7

从第十一个字节0A(十六进制)开始,就进入到读取常量池第一个表的部分了,虽然“常量池是最繁琐的数据”(《深入》书中语),但是所有常量池的表头项目都是“tag”,大小为u1(也就是一个字节),所以在实现和理解上并不复杂,比如上图中的0A,表示10,在图6中,10表示CONSTANT_Methodref,再去图1中查证,果然#1指向的是CONSTANT_Methodref。CONSTANT_Methodref的结构是确定的,除了一个tag,还包括两个index,index指向的就是常量池中的常量,当然这个表自己本身也是常量池中的一部分。这里对两个index进行解读:

  • 第一个index指向声明方法的类描述符CONSTANT_Class_info的索引:0006(见图7,十六进制,两个字节),在图1中查看表示java/lang/Object(实际上常量#6也需要指向#51才能拿到真正的字符串);
  • 第二个index指向名称及类型描述符CONSTANT_NameAndType的索引:002C(十六进制,表示十进制的44),然后可以在图2中找到#44对应的常量。

按照如此这般的规则,整个常量池就可以解析出来,再回过头来看第9和第10个字节组成的数据:003B,换算成十进制表示59,从图1到3可以看出常量实际只有58个,这是因为常量池的容量计数值是从1而不是0开始的,这里的范围在1~58,0表示不引用任何常量池项目。

ConstantPool构造代码如下:

1
2
3
4
5
6
7
8
9
10
11
public void readConstantPool(ClassReader reader) {
int cpCount = reader.readUint16();
cp = new ConstantInfo[cpCount];
for (int i = 1; i < cpCount; i++) {
cp[i] = CreateConstantInfo.readConstantInfo(reader, this);
if (cp[i] instanceof ConstantLongInfo || cp[i] instanceof ConstantDoubleInfo) {
i++;
}
}
}

通过前面的分析不难理解cpCount是常量池的容量计数(实际范围在1~cpCount-1),观察图2,代表long和double的常量#25和#32分别占用了两个索引,所以在readConstantPool中添加上相应的代码。

readConstantPool是ConstantPool类中最重要的方法,在该类下还包括一个索引方法和若干起到了返回字符串值作用的工具方法,这里就不赘述了,由于常量池中的表具有类似的结构,所以抽象出一个统一接口ConstantInfo,常量池中涉及到的14种表都实现了该接口,内容比较简单,然后再通过构造一个静态方法读取tag,进而获取并返回相应的表,解析常量池这部分的工作就完成了。

MemberInfo

常量池解析完后,就是访问标志、类索引、父类索引与接口索引集合的部分了,但这部分要么是读取十六进制的值,要么是索引常量池中的部分,实现起来没什么复杂度索性就不搬运代码了。本条目主要解析字段表集合和方法表集合,这两部分的结构非常相似,具体结构如下:

field

图8

method

图9

所以在这里统一用memberInfo类来表示,先来看下面这几行代码:

1
2
3
4
5
6
7
8
9
public static MemberInfo readMember(ClassReader reader, ConstantPool cp) {
MemberInfo mb = new MemberInfo();
mb.cp = cp;
mb.accessFlags = reader.readUint16();
mb.nameIndex = reader.readUint16();
mb.descriptorIndex = reader.readUint16();
mb.attributes = GreateAttributeInfo.readAttributes(reader, cp);
return mb;
}

要构造表的集合,先构造表,此段代码通过静态方法readMember()来初始化单个表对象。

接下来的代码通过读取表集合前的fields_count/methods_count构造出这个数值大小的数组,将依次读取到的表“装”进该数组并返回,相关代码如下:

1
2
3
4
5
6
7
8
public MemberInfo[] readMembers(ClassReader reader, ConstantPool cp) {
int memberCount = reader.readUint16();
MemberInfo[] members = new MemberInfo[memberCount];
for (int i = 0; i < memberCount; i++) {
members[i] = readMember(reader, cp);
}
return members;
}

AttributeInfo

最后就是属性表集合,在本篇提供的完整代码中,本人并没有实现全部预定义属性表,在《Java虚拟机规范(Java SE 7)版中预定义属性为21项。其中Code属性是Class文件中最重要的一个属性,在这里就单讲一下这个属性,首先看一下规范中定义的Code结构:

图10

图10

属性表和前述的表不一样,它不使用tag,而是在索引常量池中的常量后,用这个常量来对彼此进行标识。这个索引也就是图中的attibute_name_index,类型为u2;接下来是attribute_length,指示了属性值的长度;max_stack是操作数栈深度的最大值;max_locals代表局部变量所需要的存储空间,单位时slot;code_length表示字节码指令长度;code表示code_length长度的字节码指令;字节码指令后的是显式异常处理表;最后的部分还是一个属性表集合,程序在这里可以递归的进行。

有了前面的基础,异常处理表之前的代码很容易写,这里要注意,code_length是一个u4类型的长度值,理论上最大可以达到二进制的32位,但是虚拟机规范中明确限制了一个方法不允许超过65535条字节码指令,所以实际使用了u2长度。所以本人用下面这条代码编写:

1
int codeLength = (int)(reader.readUint32());

这里主要说说异常处理表:

编写这个表的时候,需要读取数据后初始化四个字段startPc endPc handlerPc catchType;它们都是u2型,这些字段的含义是:当start_pc到end_pc之间(不包括后者)如果出现了catch_type或者其子类的异常,则转到handler_pc行继续处理,当catch_type的值为0时,代表任意异常情况都需要转向handler_pc处理。

表的内容很清晰了,就要构造表的集合,和前面的例子是类似的,代码如下:

1
2
3
4
5
6
7
8
static ExceptionTableEntry[] readExceptionTable(ClassReader reader) {
int exceptionTableLength = reader.readUint16();
ExceptionTableEntry[] exceptionTable = new ExceptionTableEntry[exceptionTableLength];
for (int i = 0; i < exceptionTableLength; i++) {
exceptionTable[i] = new ExceptionTableEntry(reader);
}
return exceptionTable;
}

先读取出表集合的大小,然后依次读取“装”进数组。

验证

在Main方法中添加如下代码:

1
2
3
4
5
6
7
8
9
System.out.println((cf.getFields()).length);
System.out.println(cf.getMajorVersion());
System.out.println(cf.getAccessFlags());
System.out.println(cf.getClassName());
System.out.println(cf.getSuperClassName());
System.out.println(cf.getInterfaceNames().length);
for (MemberInfo m: cf.getFields()) {
System.out.println(m.getName());
}

cf时ClassFile的实例(具体细节见github),运行代码结果如下:

图11

图11

小结

《深入理解Java虚拟机》的相关章节曾写到:“Class类文件结构这部分是了解虚拟机的重要基础之一,要深入了解虚拟机,这部分是不能不接触的。”而该书后面章节中介绍的“自己动手实现远程执行功能”的实战,正是利用Java类加载机制的技术并修改替换Class文件字节码来实现的。

参考文献