分享

字节码解析

 且看且珍惜 2014-12-10

看完这篇文章,你需要准备足够多的勇气,因为它像王大娘的裹脚一样又臭又长。

字节码是java源代码经编译后生成的二进制数据,通俗点说class文件中保存的就是字节码。

字节码处理也有很多成熟且越来越笨重的工具,比如说用得非常广泛的cglib,而cglib又用到了另一个成熟且越来越笨重的工具,这就是asm。

网上抄来抄去的关于aop的实现,都会提到cglib,但很少会去深究asm,更少会去深层次的剖析字节码。因此,我觉得有必要体现自己的2B之处,就是要通俗易懂的揭开字节码的面纱。

字节码是由字节组成的,1个字节就是一个byte(不是bit,1个byte有8个bit),说到字节码,这里得提一点:java中的byte是有符号的,也就是-128到127之间(包含),而实际上字节码中byte是无符号的,也就是0到255之间(包含),特别是字节码中经常有2个byte来表示数据长度的时候,一定要注意换算为无符号int型,因为长度不可能为负数。

下面开始分析字节码(class)文件的结构:

4个字节,每个字节码(class)文件的开头,都有4个字节组成的被称作“魔数(magic)”的东东,它们是固定值:[0xCA,0xFE,0xBA,0xBE]。

2个字节,组成一个整数,表示次版本号(minor version),如[0,0]转换int后是0。

2个字节,组成一个整数,表示主版本号(major version),如[0,45]转换int后是45。

2个字节,组成一个整数,表示常量池(constant pool)的大小,如[0,31]转换int后是31,表示后面会有一个31个元素的数组,姑且叫它数组吧,这个数组把后面经常会用到的一些常量按顺序放进来,后面需要用到的某个常量的时候,直接使用一个下标索引值,就指向了这个数组中的常量,这样做的目的在于减小文件的体积。

虽然定义了一个31个元素的数组,但是,后面的数据并不是从0下标开始填充的(这算一个坑吧),而是跳过0下标,从1下标开始填充,也就是说,sonstant_pool[0]只是纯粹打酱油的,后面如果谁指向了0,或者指向了大于30的下标,说明这个class是不符合要求的,或者解析错了吧。

常量池这个数组中的元素之间,是不需要额外的特殊字节来分隔的,它们一个挨一个,排列得非常紧凑。每一个元素的起始,都有1个字节的数字来表示自己是什么类型,类型只有以下这么几种,如果出现了其他数字,说明class有问题,或者解析有问题。

1代表1个字符串,这个字符串是utf-8编码的。(utf)

3代表1个int数。(int)

4代表1个float数。(float)

5代表1个long数。(long)

6代表1个double数。(double)

7代表1个class引用,其值也是1个数字,指向1个1类型的下标。(class_ref)

8代表1个string引用,其值也是1个数字,指向1个1类型的下标。(string_ref)

9代表1个成员变量引用,其中包含2个数字,一个指向1个7类型下标(类),另一个指向1个12类型的下标(成员变量)。(field_ref)

10代表1个方法引用,其中包含2个数字,一个指向1个7类型下标(类),另一个指向1个12类型的下标(方法)。(method_ref)

11代表1个接口方法引用,其中包含2个数字,一个指向1个7类型下标(类),另一个指向1个12类型的下标(方法)。(interface_method_ref)

12代表1个带描述的名称的引用,其中包含2个数字,一个指向1个1类型下标(名称),另一个指向1个1类型的下标(描述)。(name_and_type)

为什么没有boolean、byte、char、short?因为这几种类型都是按int类型存放的。

如果判断出这个元素是1类型(utf),会紧跟着2个字节,表示这个字符串以utf-8编码后的字节的长度,根据这个长度,去读取后面的这么多字节,就将这个字符串取出来了。

如果判断出这个元素是3类型(int),会紧跟着4个字节,表示这个int型的值。

如果判断出这个元素是4类型(float),会紧跟着4个字节,表示这个float型的值。

如果判断出这个元素是5类型(long),会紧跟着8个字节,表示这个long型的值,遇到这种情况的话,后面的元素不能填充到接下来的数组下标中,必须跳1格,这是网上所有的抄袭者都不会告诉你的(好大一个坑)。

如果判断出这个元素是6类型(double),会紧跟着8个字节,表示这个double型的值,遇到这种情况的话,后面的元素不能填充到接下来的数组下标中,必须跳1格,这也是网上所有的抄袭者都不会告诉你的(这个和long一样,巨坑)。

如果判断出这个元素是7类型(class_ref),会紧跟着2个字节,把这2个字节转换为int值,就得到了一个下标,拿这个下标到常量池里面去取,就是一个1类型的字符串,比如说当前类名、继承的父类名、实现的接口名等,都是这种形式。

如果判断出这个元素是8类型(string_ref),会紧跟着2个字节,把这2个字节转换为int值,就得到了一个下标,拿这个下标到常量池里面去取,就是一个1类型的字符串,是不是有种脱了裤子放屁的感脚?

如果判断出这个元素是9类型(field_ref),会紧跟着2个字节,把这2个字节转换为int值,就得到了一个下标,拿这个下标到常量池里面去取,就是一个7类型,然后再拿这个7类型的值去取一个1类型的字符串,就得到了类名啦,还没完,后面还紧跟了2个字节,把这2个字节转换为int值,又得到一个下标,拿这个下标到常量池取回来一个12类型的我擦,再拿12类型的指向的下标去取,才能取回来属性名和描述,绕这么大一个弯是要作死的节奏啊。

如果判断出这个元素是10类型(method_ref),和9雷同,只不过对应的是方法。

如果判断出这个元素是11类型(interface_method_ref),和10雷同,只不过对应的是接口方法,谁这么设计,一定有他的道理。

如果判断出这个元素是12类型(name_and_type),会紧跟着2个字节,把这2个字节转换为int值,就得到了一个下标,拿这个下标到常量池里面去取,就是一个1类型的字符串,就得到属性名或方法名了,然后紧跟着的2个字节,同样原理去找,找到一个1类型的字符串,就得到描述了。

把这些理解清楚了,再大的事都不是事了。

切记,常量池相当于一个数组,但这个数组中某些位置是空着的(挖的坑)。

常量池绕完了,就该进入正文了。

首先遇到的是2个字节,这2个字节学问大,代表了这个类的各种修饰符的组合,比如说public、abstract、final等等等等,我们并不需要知道每个值代表什么修饰符,因为有多少种组合,我们也不需要关心,我们可以用Modifier.isAbstract(这个值)来判断这个类是不是抽象类。

然后又是2个字节,这2个字节转换为int值,指向的是常量池中1个7类型(class_ref)的下标,再绕到1类型(utf)的时候,其实对应的就是这个类的名称,不过是用/分隔了包名,如test/Hello。

然后又是2个字节,这2个字节转换为int值,指向的是常量池中1个7类型(class_ref)的下标,再绕到1类型(utf)的时候,其实对应的就是这个类继承的父类的名称,不过是用/分隔了包名,如java/lang/Object,因为java是单继承嘛,所以这里连一点退路都没给,直接就只能是1个父类。

然后又是2个字节,为什么和2这么有缘呢?这2字节转换为int值,表示这个类直接实现的接口数量,如果为0,那么后面就跳过了,如果不是0,那么接下来就相当于来了一个接口的数组。

这个数组很简单,没有常量池那么多招数,每个元素都是2字节,这2字节转换为int值,指向的是常量池中1个7类型(class_ref)的下标,再绕到1类型(utf)的时候,其实对应的就是这个接口的名称,还是用/分隔了包名。

接口完了,就该是成员变量了。

首先,按理有2个字节,转换为int值,表示成员变量的数量。

到了成员变量内部,最开始会有2个字节,表示变量的修饰符,和类的修饰符一样的道理。

接着有2个字节,转换为int值,指向常量池中1类型(utf)的下标,取回来变量名称。

接着有2个字节,转换为int值,指向常量池中1类型(utf)的下标,取回来变量描述。

为什么不直接指向9类型(field_ref)?估计开发编译器的人脑壳也整晕了吧。

成员变量还会有一些属性(attribute),所以接下来又来一个数组,首当其冲的是2个字节,转换为int值,表示attribute数组的大小。

attribute数组也没有多大的坑,每个元素也是紧密挨着。

每个元素的开头都有2个字节,转换为int值,指向常量池,表示属性名称。

接下来有4个字节,转换为int值,表示属性的数据大小(紧跟着的字节数),然后就是属性的数据,因为属性也分很多种情况,像剥洋葱,一层一层剥进去,数据格式都大同小异,如果有需要你就读出来吧,没需要直接skip。

属性数组读完了,成员变量也就读完了。

接下来该是方法了,方法和成员变量如出一辙,先是2个字节表示方法的数量,然后一个挨一个的方法数据,没什么好说的,太细节的也就是属性(Attribute),而属性又分很多种,不说个三天两夜也说不完。

方法完了,还有很多类的属性(Attribute),也可以看作一个数组。

所以,按理,也有2个字节,转换为int值,表示类属性(Attribute)的数量。

有了数量,开始遍历,每个属性开头都有2个字节,转换为int值,指向常量池中1类型(utf)或7类型(class_ref)的下标,最终都能取出一个字符串值,包括SourceFile、InnerClasses等等。

紧跟着有4个字节,转换为int值,表示属性的数据大小(紧跟着的字节数),然后就是属性的数据。

根据属性名称的不同,分为不同的属性类型,每种属性类型占用的空间数量不等。

比如说SourceFile这种代表源代码,属性数据就只有2字节,这2字节转换为int值,执行常量池中1类型(utf)的下标,取回来一个字符串值,就是源代码文件的名称(不含路径),如Hello.java。

而InnerClasses则代表内部类,里面有多组数据,不仅指向自己的类名,如test/Hello$1.class、test/Hello$2.class,还指向自己所在外部类的类名,如test/Hello等,这里就不多说了,能看懂这篇文章的,细节的东西参考网上的资料,也能解析好了。

类的属性读完,整个class也就戛然而止,有点意犹未尽的感脚。

这篇文章主要说了字节码的主体结构和一些比较坑的地方,细节还需自行打磨,一步一步按照上面所述的思路,我相信你也能写代码将字节码解析出来,但是这一步解析,并不是一步到位反编译成源代码,更多的是了解字节码的机制,字节码已经了然于胸,还有什么事不能干?

比如说扫描所有的class文件,通过解析字节码,获取该class实现了哪些接口,然后当我们看到某个接口的时候,能自动列出来这个接口有哪些实现类,这就是eclipse里Ctrl+T的功能。

反射也可以做这个事情,但是反射慢,反射还会触发类中静态代码块的执行,而很多时候我们不希望这些静态代码块这么早的被执行。

要做好字节码的解析,最重要的一步,就是把常量池给解析正确,否则,稍微有一点差错,就是失之毫厘谬以千里。

最后用一个简单的例子来分析:

源代码文件名:Hello.java

源代码:

package test;

public class Hello{

  public void say(){

    System.out.println("hello");

  }

}

解析(16进制数据 //备注):

CAFEBABE 魔数

0000 //次版本号 0

0032 //主版本号 50

001F //常量池大小 31

//下边是常量池,#代表下标

     //#1

07   //类型7 

0002 //指向#2


     //#2

01   //类型1 

000A //字符串长度:10

746573742F48656C6C6F //字符串:test/Hello


     //#3

07   //类型7 

0004 //指向#4


     //#4

01   //类型1 

0010 //字符串长度:16

6A6176612F6C616E672F4F626A656374 //字符串:java/lang/Object


     //#5

01   //类型1 

0006 //字符串长度:6

3C696E69743E //字符串:<init>


     //#6

01   //类型1 

0003 //字符串长度:3

282956 //字符串:()V


     //#7

01   //类型1 

0004 //字符串长度:4

436F6465 //字符串:Code


     //#8

0A   //类型10 

0003 //指向#3

0009 //指向#9


     //#9

0C   //类型12 

0005 //指向#5

0006 //指向#6


     //#10

01   //类型1 

000F //字符串长度:15

4C696E654E756D6265725461626C65 //字符串:LineNumberTable


     //#11

01   //类型1 

0012 //字符串长度:18

4C6F63616C5661726961626C655461626C65 //字符串:LocalVariableTable


     //#12

01   //类型1 

0004 //字符串长度:4

74686973 //字符串:this


     //#13

01   //类型1 

000C //字符串长度:12

4C746573742F48656C6C6F3B //字符串:Ltest/Hello;


     //#14

01   //类型1 

0003 //字符串长度:3

736179 //字符串:say


     //#15

09   //类型9 

0010 //指向#16

0012 //指向#16


     //#16

07   //类型7 

0011 //指向#17


     //#17

01   //类型1 

0010 //字符串长度:16

6A6176612F6C616E672F53797374656D //字符串:java/lang/System


     //#18

0C   //类型12 

0013 //指向#19

0014 //指向#20


     //#19

01   //类型1 

0003 //字符串长度:3

6F7574 //字符串:out


     //#20

01   //类型1 

0015 //字符串长度:21

4C6A6176612F696F2F5072696E7453747265616D3B //字符串:Ljava/io/PrintStream;


     //#21

08   //类型8 

0016 //指向#22


     //#22

01   //类型1 

0005 //字符串长度:5

68656C6C6F //字符串:hello


     //#23

0A   //类型10 

0018 //指向#24

001A //指向#26


     //#24

07   //类型7 

0019 //指向#25


     //#25

01   //类型1 

0013 //字符串长度:19

6A6176612F696F2F5072696E7453747265616D //字符串:java/io/PrintStream


     //#26

0C   //类型12 

001B //指向#27

001C //指向#28


     //#27

01   //类型1 

0007 //字符串长度:7

7072696E746C6E //字符串:println


     //#28

01   //类型1 

0015 //字符串长度:21

284C6A6176612F6C616E672F537472696E673B2956 //字符串:(Ljava/lang/String;)V


     //#29

01   //类型1 

000A //字符串长度:10

536F7572636546696C65 //字符串:SourceFile


     //#30

01   //类型1 

000A //字符串长度:10

48656C6C6F2E6A617661 //字符串:Hello.java


0021 //类访问修饰符

0001 //本类指向#1 结果字符串:test/Hello

0003 //父类指向#:3 结果字符串:java/lang/Object

0000 //接口数量:0

0000 //成员变量数量:0

0002 //方法数量:2

     //方法0

0001 //访问修饰符

0005 //方法名指向#5 结果字符串:<init>

0006 //方法描述指向#6

0001 //属性数量为:1

     //属性0

0007 //属性名指向#7 实际字符串:Code

0000002F //属性数据长度:47

00010001000000052AB70008B100000002000A00000006000100000002000B0000000C000100000005000C000D0000 //属性数据略


     //方法1

0001 //访问修饰符

000E //方法名指向#14 结果字符串:say

0006 //方法描述指向#6

0001 //属性数量为:1

     //属性0

0007 //属性名指向#7 实际字符串:Code

00000037 //属性数据长度:55

0002000100000009B2000F1215B60017B100000002000A0000000A00020000000400080005000B0000000C000100000009000C000D0000 //属性数据略


0001 //类属性数量:1

     //属性0

001D //属性名指向#29 实际字符串:SourceFile

00000002 //属性数据长度:2

001E //属性数据,指向#30 实际字符串:Hello.java


//完毕。

最后说一句,你可以使用javap命令来校验你的解析是否正确,尤其是常量池的下标是否对应。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多