代码变成的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步
最近由于工作的需要,对Class文件做了一定的了解。然而枯燥无味的课程总是让人犯困,在学习的过程中,总有一种虚无缥缈的感受,看起来好像已经会了。但又好像什么也没懂。故想到不如自己写一篇博客,写一下解析Java字节码的代码,来验证一下自己到底明白了么。 (本以为几天就搞定,然而断断续续拖了很久很久,很久很久很久!!!深感学习不易,写代码更难,写文章就是难上加难)
字节码文件说白了就是一个程序指令的一种表现形式,为了实现“一次编写,到处运行”的宣传口号,Java虚拟机可以被搭载在各个平台之上运行字节码文件。顺便一提,Class文件并不与Java绑定的,Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。 学习Class文件有什么用呢?或许没有,又或许有。 本文将尽力结合Java代码把Class文件讲清楚,篇幅可能很长,且啰嗦。如果需要更细致的文章,建议读《深入理解Java虚拟机》。
要学习Java字节码文件,当然要先学习字节码是什么,首先要知道1 Byte = 8 bit,其他细节建议Google学习。这里先介绍一个自己编写的工具类,HexUtil.java。在读取字节码文件的时候,我们拿到的都是二进制位,类似于0001这样的比特位,为了解析它,需要将bit位与整数直接的转换,bit位与字符串直接的转换。 直接看代码吧。
public class HexUtils {
/**
* byte数组转int
*
* @param bytes
* @return
*/
public static int bytes2Int(byte[] bytes) {
// 根据byte的位数
int result = 0x00000000;
for (int i = 0; i < bytes.length; i++) {
result = result ^ ((bytes[i] & 0x000000ff) << (8 * (bytes.length - i - 1)));
}
return result;
}
public static int bytes2Int(byte a, byte b) {
return a << 8 & 0x0000ff00 ^ b & 0x000000ff;
}
/**
* byte数组 转换成 16进制小写字符串
*/
public static String bytes2Hex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
StringBuilder hex = new StringBuilder();
for (byte b : bytes) {
hex.append(Integer.toHexString(b & 0x00000ff)); // 将低八位取出来
}
return hex.toString();
}
/**
* 16进制字符串 转换为对应的 byte数组
*/
public static byte[] hex2Bytes(String hex) {
if (hex == null || hex.length() == 0) {
return null;
}
char[] hexChars = hex.toCharArray();
byte[] bytes = new byte[hexChars.length / 2]; // 如果 hex 中的字符不是偶数个, 则忽略最后一个
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) Integer.parseInt("" + hexChars[i * 2] + hexChars[i * 2 + 1], 16);
}
return bytes;
}
}
可能会有人看了以后毫无感觉,甚至会说“我曹,写的什么垃圾玩意。” 确实挺垃圾,但好在勉强可以用。先解释一下。
public static int bytes2Int(byte[] bytes) {
// 根据byte的位数
int result = 0x00000000;
for (int i = 0; i < bytes.length; i++) {
result = result ^ ((bytes[i] & 0x000000ff) << (8 * (bytes.length - i - 1)));
}
return result;
}
为什么字节转int,要与上0xff呢?这里就要说道遇到的一个坑。在Java中,Java中数值的二进制是采用补码的形式表示的。为了拿到原始的数字就要每一位与上0xff。为什么?举个例子: 举个例子,原来魔数十六进制字符串为 cafebabe
换成二进制就是 cafebabe ------> 11001010 11111110 10111010 10111110
这些数字用int表示应该是
11001010 -> 00000000 00000000 00000000 11001010 -> 202
11111110 -> 254
10111010 -> 186
10111110 -> 190
但java读到的数字确是 -54, -2, -70, -66 这几个数字在计算机中表示为
00000000 00000000 00000000 00110110 -> 54的源码
11111111 11111111 11111111 11001001 -> 54的反码
11111111 11111111 11111111 11001010 -> 54的补码 -54在计算机中的表示
00000000 00000000 00000000 00000010 -> 2的源码
11111111 11111111 11111111 11111101 -> 2的反码
11111111 11111111 11111111 11111110 -> 2的补码 -2在计算机中的表示
可以看出来低八位就是原来数字 理论基础搞定,接下来就好办了,我们只需将每一个字节的低八位取出来,经过移位操作就可以恢复出原始的数字。
回到正题上。
Class文件是一组以8字节为基础单位的二进制字节流,各个数据项目严格按照循序紧凑的排列在Class文件中,中间没有添加任何分隔符。见下图:
在右图中,class文件被读成了一个个十六进制展示,最明显的可以看到前四个字节的十六进制表示为“CAFEBABE”。而我们的任务不仅是要看懂这一坨二进制,而且要编写代码解析他们,把它们用类来表示。
根据Java虚拟机规范的规定,Class文件格式采用一种类似C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。
所谓的无符号数就是把几个字节提取出来表示为一个数字,这个数字可以用来描述索引、数量值、或者按照UTF-8编码构成的字符串值。
那么几个字节呢?比较术语的讲,class文件中包含了四种类型,分别是u1、u2、u4、u8,其各自代表了一个字节、两个字节、四个字节、八个字节。
举个栗子,在下面这段代码中,Count表示“值”的概念。在class文件中,“值”一般是两个字节(也有可能是四个字节)。那么我们就可以在读取class文件的两个或四个字节,并把读取到的字节转换为int类型
public class Count extends ReadBytes {
// 表示数量、需要读两个字节(默认),或四个字节来表示这个数量
private static final int length = UKind.U2; // 一般这个
private int num;
public Count(FileInputStream fileInputStream) {
super(fileInputStream, length);
num = HexUtils.bytes2Int(bytes);
}
public Count(FileInputStream fileInputStream, int length) {
super(fileInputStream,length);
num = HexUtils.bytes2Int(bytes);
}
public int getNum() {
return num;
}
@Override
public String toString() {
return ""+num;
}
}
表抽象的看就是一个类,它里面包含了其他的无符号数和表。这个class文件可以认为就是一个表。
|类 型|名 称|数 量| |:----:|:----:|:----:| |u4|magic(魔数)|1| |u2|minor_version(小版本)|1| |u2|major_version(主版本)|1| |u2|constant_pool_coun(常量个数)|1| |cp_infoconstant_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_infofields(字段表)|fields_countu2methods_count(方法表的个数)|1| |method_infomethods(方法表)|methods_count| |u2|attributes_count(属性表个数)|1| |attribute_info|attributes(属性表)|attributes_count|
无论是表还是无符号数,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列的连续的某一类型的数据为某一类型的集合
public class ReadBytes {
public byte[] bytes;
private FileInputStream fileInputStream;
public ReadBytes(FileInputStream fileInputStream,int length){
bytes = new byte[length];
try {
int readNum = fileInputStream.read(bytes);
if (readNum == -1){
System.out.println("字节读取失败");
} else {
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
使用这段代码对class文件进行字节码的读取。
首先从简单的开始,从Class类文件的结构可以看到,前四种类型的含义和长度都是固定的。
每一个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
代码很简单,只需要把class文件的前四个字节读取出来,就可以了。
public class MagicNumber extends ReadBytes {
// 版本魔数
// 占四个字节 u4类型
private static final int length = UKind.U4;
private String hexString;
public MagicNumber(FileInputStream fileInputStream) throws Exception {
super(fileInputStream, length);
System.out.println(Arrays.toString(bytes));
hexString = HexUtils.bytes2Hex(bytes); // 将四个字节转为十六进制字符串
if (!hexString.equals("cafebabe")){
throw new Exception("该文件不是正确的class文件");
}
}
public String getHexString() {
return hexString;
}
@Override
public String toString() {
return hexString;
}
第五和第六字节是次版本号,第七和第八字节是主版本号。代码同样很简单,只需要读两个字节到byte数组中,然后将字节转为int类型就可以了。
public class Version extends ReadBytes {
// 版本信息
private static final int length = UKind.U2;
private int versionNum;
public Version(FileInputStream fileInputStream) {
super(fileInputStream,length);
versionNum = HexUtils.bytes2Int(bytes);
}
public int getVersionNum() {
return versionNum;
}
@Override
public String toString() {
return ""+versionNum;
}
}
紧接着主次版本号的是常量池的信息,常量池可以理解为Class文件之中的资源仓库,它里面存储着class文件的所有信息,包括变量名、定义的字符串等等,它是class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据。
由于常量池中的常量是不固定的,所以在常量池的入口需要放置一个u2类型的数据,代表常量池容量计数值。需要注意一点的是,这个计数是从1开始的,其中0表示Null。
我们需要一个类去读取class文件中的count,对于数量这个概念而言,通常是用两个或四个字节来表示,代码很简单,只需要读出来并转换为int类型即可。
public class Count extends ReadBytes {
// 表示数量、需要读两个字节(默认),或四个字节来表示这个数量
private static final int length = UKind.U2; // 一般这个
private int num;
public Count(FileInputStream fileInputStream) {
super(fileInputStream, length);
num = HexUtils.bytes2Int(bytes);
}
public Count(FileInputStream fileInputStream, int length) {
super(fileInputStream,length);
num = HexUtils.bytes2Int(bytes);
}
public int getNum() {
return num;
}
@Override
public String toString() {
return ""+num;
}
}
在代码中,我们创建了一个长度为count+1的数组,用于存放常量。
得知了有常量池中常量的数量以后,我们需要根据数量从class文件中将常量池信息解析出来,但常量都是什么呢?
constantPoolCount = new Count(fi);
constantPool = new ConstantInfo[constantPoolCount.getNum() + 1]; // 加一是因为多了一个null
constantPool[0] = null;
IntStream.range(1, constantPoolCount.getNum()).forEach(i -> constantPool[i] = ConstantFactory.createConstantInfo(i,fi));
常量池中主要存放两大类常量:字面量和符号引用。
字面量比较接近Java语言层面的常量概念,如文本字符串、声明为final的常量值等。
而符号引用则属于编译原理方面的概念,包括了下面三类常量:
可以将符号引用理解为java文件中出现的单词。在常量池中存下了有关类、字段和方法的表示。
常量池中的项目类型如下表所示,后面我们可以得知,每一种类型开始的第一位都是一个一字节(u1)类型的标志位,代表当前这个常量属于哪种常量类型。利用这种特性,我们可以使用工厂模式,去解析每一个常量类型。
|类 型|标志|描述| |:----:|:----:|:----:| |CONSTANT_Utf8_info|1|UTF-8编码的字符串| |CONSTANT_Integer_info|3|整形字面量| |CONSTANT_Float_info|4|浮点型字面量| |CONSTANT_Long_info|5|长整型字面量| |CONSTANT_Doubel_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种,每一种的结构都不太相同,但庆幸的是,它们的第一个字节用于表示自己是什么类型。我们可以使用这个字节去动态创建出对于的常量。
public class Tag extends ReadBytes{
private static final int length = UKind.U1;
private int num;
public Tag(FileInputStream fileInputStream) {
super(fileInputStream, length);
num = bytes[0];
}
public int getNum() {
return num;
}
}
public static ConstantInfo createConstantInfo(FileInputStream fi) {
Tag tag = new Tag(fi);
switch (tag.getNum()) {
case Constants.CONSTANT_Utf8:
return new ConstantUtf8Info(fi, new Count(fi).getNum());
case Constants.CONSTANT_Integer:
return new ConstantIntegerInfo(fi);
case Constants.CONSTANT_Float:
return new ConstantFloatInfo(fi);
case Constants.CONSTANT_Long:
return new ConstantLongInfo(fi);
case Constants.CONSTANT_Double:
return new ConstantDoubleInfo(fi);
case Constants.CONSTANT_Class:
return new ConstantClassInfo(fi);
case Constants.CONSTANT_String:
return new ConstantStringInfo(fi);
case Constants.CONSTANT_Fieldref:
return new ConstantFieldrefInfo(fi);
case Constants.CONSTANT_Methodref:
return new ConstantMethodrefInfo(fi);
case Constants.CONSTANT_InterfaceMethodref:
return new ConstantInterfaceMethodRefInfo(fi);
case Constants.CONSTANT_NameAndType:
return new ConstantNameAndTypeInfo(fi);
case Constants.CONSTANT_MethodHandle:
return new ConstantMethodHandleInfo(fi);
case Constants.CONSTANT_MethodType:
return new ConstantMethodTpeInfo(fi);
case Constants.CONSTANT_InvokeDynamic:
return new ConstantInvokeDynamicInfo(fi);
default:
return null;
}
}
所以,我们可以很轻易的写出上述代码,只需要先读出tag,就可以根据响应的tag创建对应的常量种类。
接下来,我们要根据上表来编写代码,如果看懂了一个表示方式,剩下就应该全明白了,可以适当跳过。
需要读出的长度由length决定,而length是一个u2类型的数。所以只需要先读出字符串所占的长度,就可以读出来bytes,然后将byte数组转为字符串即可。
public class ConstantUtf8Info extends ReadBytes implements ConstantInfo {
private String constantString;
public ConstantUtf8Info(FileInputStream fi, int length) {
super(fi, length);
constantString = new String(bytes, Charset.defaultCharset());
}
public String getConstantString() {
return constantString;
}
@Override
public String toString() {
return "#" + " = Utf8" + " " + constantString;
}
}