看完这篇文章,你需要准备足够多的勇气,因为它像王大娘的裹脚一样又臭又长。 字节码是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命令来校验你的解析是否正确,尤其是常量池的下标是否对应。 |
|