Java-class字节码解析实战

解析class文件

Posted by MakeSail on August 22, 2020

代码变成的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

1. class字节码文件的解析

1.1. 背景

最近由于工作的需要,对Class文件做了一定的了解。然而枯燥无味的课程总是让人犯困,在学习的过程中,总有一种虚无缥缈的感受,看起来好像已经会了。但又好像什么也没懂。故想到不如自己写一篇博客,写一下解析Java字节码的代码,来验证一下自己到底明白了么。 (本以为几天就搞定,然而断断续续拖了很久很久,很久很久很久!!!深感学习不易,写代码更难,写文章就是难上加难)

1.2. 概述

字节码文件说白了就是一个程序指令的一种表现形式,为了实现“一次编写,到处运行”的宣传口号,Java虚拟机可以被搭载在各个平台之上运行字节码文件。顺便一提,Class文件并不与Java绑定的,Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。 学习Class文件有什么用呢?或许没有,又或许有。 本文将尽力结合Java代码把Class文件讲清楚,篇幅可能很长,且啰嗦。如果需要更细致的文章,建议读《深入理解Java虚拟机》。

1.3. 准备前提

要学习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在计算机中的表示

可以看出来低八位就是原来数字 理论基础搞定,接下来就好办了,我们只需将每一个字节的低八位取出来,经过移位操作就可以恢复出原始的数字。

1.4. Class类文件的结构

回到正题上。

Class文件是一组以8字节为基础单位的二进制字节流,各个数据项目严格按照循序紧凑的排列在Class文件中,中间没有添加任何分隔符。见下图:

在右图中,class文件被读成了一个个十六进制展示,最明显的可以看到前四个字节的十六进制表示为“CAFEBABE”。而我们的任务不仅是要看懂这一坨二进制,而且要编写代码解析他们,把它们用类来表示。

根据Java虚拟机规范的规定,Class文件格式采用一种类似C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。

1.4.1. 无符号数

所谓的无符号数就是把几个字节提取出来表示为一个数字,这个数字可以用来描述索引、数量值、或者按照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;
    }
}

1.4.2. 表

表抽象的看就是一个类,它里面包含了其他的无符号数和表。这个class文件可以认为就是一个表。

1.4.3. 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

无论是表还是无符号数,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列的连续的某一类型的数据为某一类型的集合

1.5. 开始解析Class文件

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文件进行字节码的读取。

1.5.1. 魔数与Class文件的版本

首先从简单的开始,从Class类文件的结构可以看到,前四种类型的含义和长度都是固定的。

1.5.1.1. 魔数

每一个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;
    }

1.5.1.2. 版本号

第五和第六字节是次版本号,第七和第八字节是主版本号。代码同样很简单,只需要读两个字节到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;
    }
}

1.5.1.3. 常量池

紧接着主次版本号的是常量池的信息,常量池可以理解为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的常量值等。

而符号引用则属于编译原理方面的概念,包括了下面三类常量:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

可以将符号引用理解为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创建对应的常量种类。

常量 项目 类型 描述
CONSTANT_Utf8_info tag u1 值为1
length u2 UTF-8编码的字符串占用的字节数
bytes u1 长度为length的UTF-8编码的字符串
CONSTANT_Integer_info tag u1 值为3
bytes u4 按照高位在前存储的int值
CONSTANT_Float_info tag u1 值为4
bytes u4 按照高位在前存储的float值
CONSTANT_Long_info tag u1 值为5
bytes u8 按照高位在前存储的Long值
CONSTANT_Double_info tag u1 值为6
bytes u8 按照高位在前存储的double值
CONSTANT_Integer_info tag u1 值为7
index u2 指向全限定名常量项的索引
CONSTANT_String_info tag u1 值为7
index u2 指向字符串字面量的索引
CONSTANT_Fieldref_info tag u1 值为9
index u2 指向声明字段的类或者接口描述符CONSTANT_Class_info的索引项
index u2 指向字段描述服CONSTANT_NameAndType的索引项
CONSTANT_Methodref_info tag u1 值为10
index u2 指向声明方法的类描述符CONSTANT_Class_info的索引项
index u2 指向名称及类型描述符CONSTANT_NameAndType的索引项
CONSTANT_InterfaceMethodref_info tag u1 值为11
index u2 指向声明方法的类描述符CONSTANT_Class_info的索引项
index u2 指向名称及类型描述符CONSTANT_NameAndType的索引项
CONSTANT_NameAndTpe_info tag u1 值为12
index u2 指向该字段或方法名称常量项的索引
index u2 指向该字段或方法描述符常量项的索引
CONSTANT_MethodHandle_info tag u1 值为15
reference_kind u1 值必须在1-9之间,它决定了方法句柄的类型。方法句柄类型的值表示方法句柄的字节码行为
reference_index u2 值必须是对常量池的有效索引
CONSTANT_MethodType_info tag u1 值为16
descriptor_index u2 值必须是对常量池的有效索引,常量池在该索引出的项必须是CONSTANT_Utf8_info结构,表示方法的描述符
CONSTANT_InvokeDynamic_info tag u1 值为8
bootstrap_method_attr_index u2 值必须是对当前Class文件中引导方法表的bootstrap_methods[]数组的有效索引
name_and_type_index u2 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是CONSTANT_NameAndType_info结构,表示方法名和方法描述符

接下来,我们要根据上表来编写代码,如果看懂了一个表示方式,剩下就应该全明白了,可以适当跳过。

CONSTANT_Utf8_info

需要读出的长度由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;
    }
}
CONSTANT_Integer_info

存放被final修饰的常量整形数,四个字节刚好可以容纳一个Java的int型常量,事实上比int更小的boolean、byte、short和char类型的常量也放在CONSTANT_Integer_info中。

该类型在字节码文件中的构成也很简单,由tag和四个字节的byte表示。所以也只需要简单的将四个字节读出,再转化为整数既可。

public class ConstantIntegerInfo extends ReadBytes implements ConstantInfo {
    private static final int length = UKind.U4;
    private int value;
    public ConstantIntegerInfo(FileInputStream fileInputStream) {
        super(fileInputStream, length);
        value = HexUtils.bytes2Int(bytes);
    }

    @Override
    public String toString() {
        return super.toString();
    }
}

CONSTANT_Float_info

存放被final 修饰的单精度浮点数

public class ConstantFloatInfo extends ReadBytes implements ConstantInfo{
    private static final int length = UKind.U4;
    private float value;
    public ConstantFloatInfo(FileInputStream fileInputStream) {
        super(fileInputStream, length);
        value = HexUtils.bytes2Float(bytes);
    }

    @Override
    public String toString() {
        return super.toString();
    }
}

Constant_Long_info

存放被final 修饰的 64位有符号的以补码表示的整数

public class ConstantLongInfo extends ReadBytes implements ConstantInfo {
    private static final int length = UKind.U8;
    private long value;
    public ConstantLongInfo(FileInputStream fileInputStream) {
        super(fileInputStream, length);
        value = HexUtils.bytes2Long(bytes);
    }

    @Override
    public String toString() {
        return super.toString();
    }
}
CONSTANT_Double_info

存放被final 修饰的双精度浮点型

public class ConstantDoubleInfo extends ReadBytes implements ConstantInfo  {
    private static final int length = UKind.U8;
    private double value;
    public ConstantDoubleInfo(FileInputStream fileInputStream) {
        super(fileInputStream, length);
        value = HexUtils.bytes2Long(bytes);
    }

    @Override
    public String toString() {
        return super.toString();
    }
}
CONSTANT_Class_info

存放了类的全限定名常量项的索引,该索引指向了全限定名常量项

public class ConstantClassInfo implements ConstantInfo {
    private Index indexRef;

    public ConstantClassInfo(FileInputStream fi) {

        indexRef = new Index(fi);
    }

    public int getIndexRef() {
        return indexRef.getIndex();
    }

    @Override
    public String toString() {
        return "#"  + " = Class" + "           " + "#" + getIndexRef();
    }
}

CONSTANT_String_info

存放了字符串字面量的索引

public class ConstantStringInfo implements ConstantInfo {
    private Index stringIndex;
    public ConstantStringInfo(FileInputStream fi) {
        stringIndex = new Index(fi);
    }
}

可以看到,几乎所有的类型都是一个套路,这里就不在一个一个说了。

访问标志

在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或接口层次的访问信息。对于类、字段、方法和嵌套类有着不同的访问标志类型

对于类(class),有以下访问标志

  • public
  • final
  • super
  • interface
  • abstract
  • synthetic
  • annotation
  • enum

对于字段(field)

  • public
  • private
  • protected
  • static
  • final
  • volatile
  • transient
  • synthetic
  • enum

对于方法(method)

  • public
  • private
  • protected
  • static
  • final
  • synchronized
  • bridge
  • varargs
  • native
  • abstract
  • static
  • synthetic

对于嵌套类

  • public
  • private
  • protected
  • static
  • final
  • interface
  • abstract
  • synthetic
  • annotation
  • enum

我们在判断标志之前,需要先判断一下类型,因为不同的标志可能会有一样的标志值,比如VOLATILE=0x0040 ,而BRIDGE=0x0040.

类的访问标志
标志名称 标志值 含义
ACC_PUBLIC 0x00001 是否为public类型
ACC_FINAL 0x0010 是否为声明为final
ACC_SUPER 0x0020 是否允许使用invokespecial字节码指令的新语意
ACC_INTERFACE 0x0200 标识这是一个接口
ACC_ABSTRACT 0x0400 是否为abstract类型
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生的
ACC_ANNOTATION 0x2000 标识这时一个注解
ACC_ENUM 0x4000 标识这是一个枚举
字段的访问标志

|标志名称|标志值|含义| |:—-:|:—-:|:—-:| |ACC_PUBLIC| |ACC_PRIVATE| |ACC_PROTECTED| |