Class文件解析实战

java跨平台的实现是基于JVM虚拟机的,编写的java源码,编译后会生成一种.class文件,称为字节码文件。java虚拟机就是负责将字节码文件翻译成特定平台下的机器码然后运行。为了保证Class文件在多个平台的通用性,java官方制定了严格的Class文件格式。了解Class文件结构,有利于我们反编译 .class 文件或在程序编译期间修改字节码做代码注入。

Class文件结构概览

首先先创建一个java类:

public class HelloWorld {

	private static int num = 0;
	public String name = "HelloWorld";

	public static void main(String[] args) {

		String[] strs = {"bigkai1", "bigkai2"};

		for (int i = 0; i < 10; i++) {
			num++;
			if(i == 5) continue;
			System.out.println("HelloWorld!");
		}
	}
}
复制代码

然后进去当前类目录下执行javac命令生成类文件:

$ javac HelloWorld.java
复制代码

我们便可以看到在java文件下生成了一个HelloWorld.class文件,使用类文件解析器classpy打开该文件,可以看到文件的整体结构:

1593046004146

Class文件的整体结构为:

ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}
复制代码

我将Class文件的结构做了一个简单的图示:

1592986196457

在JVM中,Class文件使用的是类C语言进行描述的,统一使用无符号整数作为基本数据类型:单字节u1、2字节u2、4字节u4、8字节u8

下面就对文件各部分一一进行解析。

魔数

魔数(Magic Number)是Class文件的标识符,它是一个4字节的整数,只有当前四个字节为 0xCAFEBABE(可以记忆为咖啡宝贝的英译)时,虚拟机才会认为这是一个Class文件。这种开头固定标识符的做法在很多地方用到过,比如zip的压缩文件

查看我们的Class文件,是否有这个标识符:

1593046021503

当我人为地将CA FE BA BE修改为CA FE BA BA时,让虚拟机对类文件加载 ,虚拟机在校验文件时会抛出以下错误:

1593002106582

版本号

在魔数的后面,就是Class的版本号,它一共有两种:小版本号(minor_version)和大版本号(major_version)。它们组合起来表示当前Class文件是由哪个版本的JDK编译产生的。以下是截取自java官网的版本图:

1593046150870

对照此图,我们可以通过版本号查看对应的jdk版本:

1593046055854

在我的Class文件中,版本号为0x0037,换算为十进制为55,即对应jdk11。

对于major_version为56或以上的类文件,minor_version必须为0或65535。

对于major_version在45到55之间的类文件,minor_version可以是任何值。

当我人为的将大版本号修改为0x0039,即对应jdk14版本,然后加载类文件,由于我的jdk版本是11,虚拟机只能向下兼容,所以会报错:

1593002837588

常量池

常量池是Class文件中内容最重要的组成之一,常量池大体分为静态常量池和运行时常量池,静态常量池存放在Class文件中,运行时常量池指的是将Class文件加载进内容后,保存了常量池的方法区。这里我们解析的是静态常量池。

静态常量池的每个表项的格式为:

cp_info {
    u1 tag;
    u1 info[];
}
复制代码

tag表示指示条目所表示的常量类型。共有17种常数:

1593003638021

我对生成的Class文件常量池第一项进行分析:

1593046191758

可以看出它的tag0A,根据上表得出它是一个CONSTANT_Methodref,该结构为:

CONSTANT_Methodref_info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}
复制代码

然后根据它后面的0x000C,得出class_index在常量池中第12项

class_index的值为常量池的索引,表示具有字段或方法作为成员的类或接口类型。

  • 在CONSTANT_Fieldref_info结构中,class_index项可以是类类型或接口类型。
  • 在CONSTANT_Methodref_info结构中,class_index项必须是类类型,而不是接口类型。
  • 在CONSTANT_InterfaceMethodref_info结构中,class_index项必须是接口类型,而不是类类型。

然后又往后读取两个字节0x001C,它表示常量池中字段或方法的名称和描述符的索引值。

我们从class_index查看它所在的类:

1593046277317

可以看到它的tag是7,指示的是CONOSTANT_CLASS,结构为:

CONSTANT_Class_info {
    u1 tag;
    u2 name_index;
}
复制代码

它的name_index指示的是类的名字,我们接着看0x0028对应的项:

1593046312094

可以看出它是CONSTANT_Utf8,结构为:

CONSTANT_Utf8_info {
    u1 tag;
    u2 length;
    u1 bytes[length];
}
复制代码

它的长度为0x0010=16,所以往后一直读16个字节,得出它的名字为:java/lang/Object

接着我们再看它的name_and_type_index指向,它对应表项28:

1593046347037

tag=12表示这是CONSTANT_NameAndType类型,结构为:

CONSTANT_NameAndType_info {
    u1 tag;
    u2 name_index;
    u2 descriptor_index;
}
复制代码

name_index已经知道是什么意思了,我们直接来看descriptor_index,它的作用是表示一个有效的字段描述符或方法描述符:

1593046375605

不同字母对应的字段描述符为:

1593005698366

对于方法描述符,它有参数描述符和返回描述符,对于返回描述符,只是增加了一个V,它对应返回值为void

类访问标记

在常量池后面,就是类访问标记,它是一个u2类型的字节,表示该类的访问信息,映射的访问修饰符如下:

1593006042082

每一种类型的表示都是通过设置访问标记中的特定位来表示的从图中可以看出我的Class文件为0x0021

1593046463720

那么可以知道类的访问修饰符为ACC_PUBLIC|ACC_SUPER0x0021=0x0020+x0001)。

类关系信息

在访问标记的后面,就是该类的类别this_class、父类类别(所有的类最上层父类都是Objectsuper_class以及实现的接口数量interface_count、接口类别interface_index

查看我的Class文件:

1593046497319

该类的类别是11,父类是12,接口数量为0,然后根据索引在常量池中查找相关信息:

1593046548310

看到它们都是CONSTANT_Class类型,根据后两个字节查看它们的名字:

1593046570556

1593046586677

它们都是CONSTANT_Utf8类型,按照对应的结构读取之后分别是HelloWorldjava/lang/Object。则可知该类名字为HelloWorld,它的父类是java.lang.Object,它并没有实现接口。

字段信息

在类信息后面就是字段信息,分别由字段数量(fields_count)和字段表(fields_info)组成,字段数量是一个u2类型,主要看下字段信息表的结构:

field_info {
    u2             access_flags; // 字段访问标记
    u2             name_index; // 字段名
    u2             descriptor_index; // 描述符
    u2             attributes_count; // 字段属性数量
    attribute_info attributes[attributes_count]; // 字段属性表 
}
复制代码

字段访问标记:类似类的访问标记,计算方式也和类的差不多。

1593046907462

字段名:指向常量池索引。

描述符:用于描述字段类型,指向常量池索引,字段的类型有:

1593046982725

字段属性数量:记录字段的属性个数,属性是字段的额外信息,比如初始化值、注释等。

字段属性:存放属性的具体内容。

以我生成的Class文件为例:

1593047131318

一共有两个字段,第一个字段字节码表示为00 0A 00 0D 0E 00 00,访问标记为ACC_PRIVATE | ACC_STATIC00 0A = 00 02 + 00 08),字段名为00 0D,描述符是00 0E,属性数量是00 00

关于属性表的内容放到方法中描述。

方法

Class文件的方法由方法数量和方法内容两部分组成,方法数量是一个u2类型的数据,后面就是方法信息,方法信息的结构为:

method_info {
    u2             access_flags; // 访问标记
    u2             name_index; // 方法名
    u2             descriptor_index; // 描述符
    u2             attributes_count; // 属性数量
    attribute_info attributes[attributes_count]; // 属性内容
}
复制代码

方法的访问标记就比字段的访问标记多得多了:

1593047758176

name_index是方法名的索引,descriptor_index表示方法的签名(参数、返回值等),方法描述符在常量池中的表现为(参数1参数2)返回值

主要关注attribute_info,它的结构为:

attribute_info {
    u2 attribute_name_index; // 属性名
    u4 attribute_length; // 属性长度
    u1 info[attribute_length]; // 属性
}
复制代码

属性有多种:

1593048181717

简单看一下常用的属性:

对于下面属性,有些不是运行时必须的属性,可以在Javac中分别使用-g : none-g :vars选项来取消或要求生成这项信息。

Code

Code属性存放方法的字节码等信息,是方法的执行主体,Code属性的结构体为:

Code_attribute {
    u2 attribute_name_index; // 属性名——固定为Code
    u4 attribute_length; // 属性长度(不包括前面6个字节)
    u2 max_stack; // 操作数栈最大深度
    u2 max_locals; // 局部变量最大个数
    u4 code_length; // 方法字节码长度
    u1 code[code_length]; // 字节码内容
    u2 exception_table_length; // 异常处理表长度
    /*
    从方法字节码的start_pc偏移量开始到end_pc偏移量为止的代码中,如果遇到了catch_type所指定的异常,那么代码就跳转到handler_pc位置执行。
    */
    {   u2 start_pc;
        u2 end_pc;
        u2 handler_pc;
        u2 catch_type;
    } exception_table[exception_table_length]; // 异常处理表内容
    u2 attributes_count; // 属性个数
    attribute_info attributes[attributes_count]; // 属性内容
}
复制代码

ConstantValue

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。它的结构为:

ConstantValue_attribute {
    u2 attribute_name_index; // 固定ConstantValue
    u4 attribute_length; // 固定2
    u2 constantvalue_index; // 常量池的有效索引
}
复制代码

如果在field_info结构的access_flags项中设置了ACC_STATIC标志,那么field_info结构所表示的字段将被赋给它的ConstantValue属性所表示的值,作为声明该字段的类或接口的初始化的一部分,这以操作发生在调用类或接口的类或接口初始化方法之前。

Signature

Signature是JDK1.5时发布的,出现于类、属性表和方法表结构的属性表中。任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为他记录泛型签名信息。它的结构为:

Signature_attribute {
    u2 attribute_name_index; // 固定Signature
    u4 attribute_length; // 固定2
    
    /*
    如果该签名属性是类文件结构的属性,则该索引处的常量池项必须是表示类签名的常量信息结构);
    如果该签名属性是方法信息结构的属性,则必须是方法签名;否则,必须是字段签名。
    */
    u2 signature_index; // 常量池有效索引。
}
复制代码

之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,在字节码(Code属性)中,泛型信息编译(类型变量、参数化类型)之后都统统被擦除掉。使用擦除法的好处是实现简单(主要修改Javac编译器,虚拟机内部只做了很少的改动)、非常容易实现Backport,运行期也能够节省一些类型所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得到泛型信息。Signature属性就是为了弥补这个缺陷而增设的,现在java的反射API能够获取泛型类型,最终的数据来源也就是这个属性。

LineNumberTable

LineNumberTable用于记录字节码偏移量和行号的对应关系,不是运行时必须的属性,但默认生成到Class文件之中,如果选择不生成LineNumberTable属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。LineNumberTable属性的结构体为 :

LineNumberTable_attribute {
    u2 attribute_name_index; // 固定为LineNumberTable
    u4 attribute_length; // 属性长度
    u2 line_number_table_length; // 表项长度
    {   u2 start_pc; // 字节码偏移量
        u2 line_number;	 // 行号
    } line_number_table[line_number_table_length]; // 表项内容
}
复制代码

LocalVariableTable

LocalVariableTable属性为局部变量表,不是运行时必须的属性,但默认会生成到Class文件之中如果没有生成这项属性,当前其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用如arg0arg1之类的占位符代替原有的参数名。它的结构体如下:

LocalVariableTable_attribute {
    u2 attribute_name_index; // 固定LocalVariableTable
    u4 attribute_length; // 表项长度
    u2 local_variable_table_length;
    {   u2 start_pc; // 字节码偏移量
        u2 length; // 长度
        u2 name_index; // 局部变量名
        u2 descriptor_index; // 局部变量描述符
        u2 index; // 局部变量在当前栈帧的局部变量表中的槽位
    } local_variable_table[local_variable_table_length]; // 表项内容
}
复制代码

StackMapTable

它是JDK1.6引入的一个属性,位于Code属性的属性表,该接口存在若干个栈映射帧的数据,这个属性不是运行时必需的,仅做Class的类型校验。它会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用,目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。它的结构如下:

StackMapTable_attribute {
    u2              attribute_name_index; // 固定StackMapTable
    u4              attribute_length; // 表项长度
    u2              number_of_entries; // 栈映射帧属性
    stack_map_frame entries[number_of_entries]; // 栈映射帧具体内容
}
复制代码

stack_map_frame的结构为:

union stack_map_frame {
    /*
    same_frame {
    	u1 frame_type = SAME; // 0-63
	}
    */
    same_frame; // 表示当前代码所在位置和上一个比较位置的局部变量表是否相同,并且操作数栈为空
    
    /*
    same_locals_1_stack_item_frame {
        u1 frame_type = SAME_LOCALS_1_STACK_ITEM; // 64-127
        verification_type_info stack[1];
	}
    */
    same_locals_1_stack_item_frame; // 表示当前帧和上一帧有相同的局部变量,并且操作数栈中变量的数量为1
    
    /*
    same_locals_1_stack_item_frame_extended {
        u1 frame_type = SAME_LOCALS_1_STACK_ITEM_EXTENDED; // 247-
        u2 offset_delta;
        verification_type_info stack[1];
	}
    */
    same_locals_1_stack_item_frame_extended; // 表示当前帧和上一帧有相同的局部变量,操作数栈中变量的数量为1,并且offset_delta超过same_locals_1_stack_item_frame
    
    /*
    chop_frame {
        u1 frame_type = CHOP; // 248-250
        u2 offset_delta;
	}
    */
    chop_frame; // 表示操作数栈为空,当前局部变量表比前一帧少K(K=2510frrame_type)个局部变量
    
    /*
    same_frame_extended {
        u1 frame_type = SAME_FRAME_EXTENDED; // 251-
        u2 offset_delta;
	}
    */
    same_frame_extended; // 表示当前代码所在位置和上一个比较位置的局部变量表是否相同,并且操作数栈为空,支持的offset_delta更大
    
    /*
    append_frame {
        u1 frame_type = APPEND; // 252-254
        u2 offset_delta;
        verification_type_info locals[frame_type - 251];
	}
    */
    append_frame; // 表示当前帧比上一帧多了K(K=frame_type-251)个局部变量,且操作数栈为空
    
    /*
    full_frame {
        u1 frame_type = FULL_FRAME; // 255
        u2 offset_delta;
        u2 number_of_locals; // 局部变量表的数量
        verification_type_info locals[number_of_locals]; // 局部变量表的数据类型
        u2 number_of_stack_items; // 操作数栈的数量
        verification_type_info stack[number_of_stack_items]; // 操作数栈的类型
	}
    */
    full_frame; // 完整记录了局部变量表和操作数栈
}
复制代码

Exceptions

除了Code属性 ,每个方法可以有一个Exceptions属性,用于保存该方法可能抛出的异常。它结构如下:

Exceptions_attribute {
    u2 attribute_name_index; // 固定为Exceptions
    u4 attribute_length; // 属性长度
    u2 number_of_exceptions; // 表项数量,可能抛出的异常数
    u2 exception_index_table[number_of_exceptions]; // 存储了所有异常,每一项为执行常量池的一个索引 
}
复制代码

注意:方法的Exceptions表示一个方法可能抛出的异常,通常是由 throws关键字指定的,而Code内的异常表是异常处理机制,由try-catch语句生成的。

方法字节码分析

对我生成的Class文件方法部分做一个简单的分析:

1593062393840

对于main方法,可以看到它的访问标识为00 09(ACC_STATIC | ACC__PUBLIC),name_index对应常量池的00 15,描述符对应常量池的00 16,属性个数为00 01,然后查看它的属性表:

1593062545753

可以根据前面的内容进行一一对照,最大操作栈数量为4,最大局部变量数量为54,方法内长度为54,code里面存放的是让虚拟机执行的指令,下面我们主要对它的code内容进行分析,在此之前,要先了解虚拟机中的一些指令。

指令

JVM的指令集有很多,大体可以分为:

  • const系列:负责把简单的数值类型送到栈顶。比如对应int型才该方式只能把-1,0,1,2,3,4,5推送到栈顶,对于int型,其他的数值使用push系列命令。
  • push系列:该系列命令负责把一个整形数字(长度比较小)送到到栈顶。它需要一个参数,用于指定要送到栈顶的数字,对于超出范围的数据将使用ldc命令。
  • ldc系列:该系列命令负责把数值常量或String常量值从常量池中推送至栈顶。该命令后面需要给一个表示常量在常量池中位置(编号)的参数。对于const系列命令和push系列命令操作范围之外的数值类型常量,以及所有不是通过new创建的String,都放在常量池中。
  • load系列:
    • loadA系列:负责把本地变量的送到栈顶。这里的本地变量不仅可以是数值类型,还可以是引用类型。
    • loadB系列:负责把数组的某项送到栈顶。该命令根据栈里内容来确定对哪个数组的哪项进行操作。
  • store系列:
    • storeA系列:负责把栈顶的值存入本地变量。这里的本地变量不仅可以是数值类型,还可以是引用类型。
    • storeB系列:负责把栈顶项的值存到数组里。该命令根据栈里内容来确定对哪个数组的哪项进行操作。
  • pop系列:将栈顶元素弹出(或将栈顶元素赋值并压入)。
  • 类型转换系列:该指令专门用于类型转换;这类指令的助记符使用x2y的形式给出。其中x可能是i,f,l,d,y可能是i,f,l,d,c,s,b
  • 运算系列:为虚拟机提供基本的加减乘除运算功能。
  • 数组系列:对于对象的操作指令,可进一步细分为创建指令,字段访问指令,类型检查指令,数组操作指令。
  • 控制系列:代表条件控制。大体上分为比较指令,条件跳转指令,比较条件跳转指令,多条件分支跳转,无条件跳转指令等。
  • 函数系列:包括函数调用指令、函数返回指令。
  • 同步控制系列:Java虚拟机提供了monitorenter,monitorexit来完成临界区的进入和离开。达到多线程的同步。

具体的指令集命令可以查看该博客:CSDN JVM指令集整理

指令字节码分析

了解了字节码相关指令后,来对生成的Class文件进行一次实战吧:

1593132588982

实际的代码为:

private static int num = 0;

public static void main(String[] args) {

		String[] strs = {"bigkai1", "bigkai2"};

		for (int i = 0; i < 10; i++) {
			num++;
			if(i == 5) continue;
			System.out.println("HelloWorld!");
		}
	}
复制代码

首先用iconst_2存入一个int类型的2到栈顶,接着anewarray创建一个数组的引用并推送到栈顶(栈顶元素出栈作为数组长度),使用dup复制栈顶数值并将复制值压入栈顶,接着iconst_0int类型的0入栈,使用ldcString型常量值从常量池中推送至栈顶,此处指向的是常量池中的05——bigkai1,调用aastore将栈顶引用型数值存入指定数组的指定索引位置,它是根据栈顶的引用数值、数组下标、数组引用出栈,将数值存入对应的数组元素。此时就将第一个元素bigkai1存入字符串数组strs

接着调用dup将栈顶元素复制并再次压入栈顶,然后iconst_1压入int类型的1,ldc从常量池中取出bigkai2,接着调用aastore弹出栈的两个值,给数组的第二个元素赋值为bigkai2,此时完成了给字符串数组的所有赋值。

接着进入for方法,iconst_0压入int类型的0,然后istore_2将栈顶int型数值存入第2个本地变量,iload_2将第2个int型本地变量推送至栈顶,bipush将单字节的常量值(-128~127)推送至栈顶,if_icmpge比较栈顶两int型数值大小,当结果大于等于0时跳转到第53个指令(第53个执行是return,即结束函数,返回返回值),接着调用getstatic获取指定类的静态域(从常量池中获取num)并将其值压入栈顶,iconst_1压入int类型的1。iadd将栈顶两int型数值相加并将结果压入栈顶,然后putstaticnum赋值,此时是将num++执行完毕。

iload_2将第2个int型本地变量推送至栈顶(将i=0推送),然后iconst_5压入5,使用if_icmpne比较栈顶两int型数值大小,当结果不等于0时跳转到第39条指令,否则调用goto跳转到第47条指令。第47条指令是iinc,是将指定的int型变量增加指定值,需要两个变量,分别表示indexconstindex指第indexint型本地变量,const表示增加的值,然后又goto到第17条指令。此处是实现if(i == 5) continue

如果第33条指令if_icmpne不等于0,就跳转到第39条指令,第39条指令是getstatic,它获取的是常量池中的java/lang/System.out,然后执行ldc,将HelloWorld!压入栈顶,调用invokevirtual指令,它的作用是调用实例方法,根据对象的实际类型进行派发,支持多态。此处是实现System.out.println("HelloWorld!")

Class文件属性

Class文件也自带一些属性,由属性长度和属性内容组成,主要的属性有:

SourceFile

SourceFile属性用于描述当前这个Class文件是由哪个源代码文件编译出来的。

SourceFile_attribute {
    u2 attribute_name_index; // 固定SourceFile
    u4 attribute_length; // 属性长度,固定为2
    u2 sourcefile_index; // 源代码文件名,指向常量池索引
}
复制代码

BootstrapMethods

BootstrapMethods属性用于支持invokeDynamic指令,它是描述和保存引导方法。

invokeDynamic是JDK1.7支持动态类型语言开发的指令,所谓动态类型语言就是它的类型检查的主体过程是在运行期而不是编译期,典型的代表语言就是Python

引导方法可以简单地理解为一个查找方法的方法。

BootstrapMethods_attribute {
    u2 attribute_name_index; // 固定BootstrapMethods
    u4 attribute_length; // 属性总长度(不包含前6个字节)
    u2 num_bootstrap_methods; // 这个类中抱哈的引导方法的个数
    {   u2 bootstrap_method_ref; // 指明函数
        u2 num_bootstrap_arguments; // 指明引导方法的参数个数
        u2 bootstrap_arguments[num_bootstrap_arguments]; // 引导方法的参数
    } bootstrap_methods[num_bootstrap_methods];
}
复制代码

InnerClasses

它用来描述外部类和内部类之间的关系:

InnerClasses_attribute {
    u2 attribute_name_index; // 固定InnerClasses
    u4 attribute_length; // 属性长度
    u2 number_of_classes; // 内部类格式
    {   u2 inner_class_info_index; // 内部类类型
        u2 outer_class_info_index; // 外部类类型
        u2 inner_name_index; // 内部类名称
        u2 inner_class_access_flags; // 内部类访问标识符
    } classes[number_of_classes]; // 内部类内容
}
复制代码

内部类的访问标识符支持以下:

1593064751416

Deprecated

Deprecated可用于类、方法、字段等结构中,表示该类、方法、字段将在未来版本中被弃用。它的结构如下:

Deprecated_attribute {
    u2 attribute_name_index; // 固定Deprecated
    u4 attribute_length; // 固定为0
}
复制代码

当一个类、方法、字段被标记为Deprecated时,就会产生这个属性。

总结

只要遵循Class文件的规范,通过Class文件,各种语言都可以由源代码被编译成Class文件,并最终得以在虚拟机上执行。

猜你喜欢

转载自juejin.im/post/5ef556cae51d4534b302e811