Java Class文件含有丰富的符号信息。而且javac默认的编译参数会让编译器生成行号表,这些都有助于了解对应关系。
关于Java语法结构如何对应到Java字节码,在JVM规范里有相当好的例子:
好好读完这章基本上就能手码字节码了。记住一个要点就好:“运算”全部都在“操作数栈”(operand stack)上进行,每个运算的输入参数全部都在“操作数栈”上,运算完的结果也放到“操作数栈”顶。在多数Java语句之间“操作数栈”为空。从Java源码对应到Java字节码的例子
题主之前说“从来不觉得阅读底层语言很容易,无论是汇编还是ByteCode还是IL”。我是觉得只要能耐心读点资料,Charles Nutter的,然后配合,要理解Java字节码真的挺容易的。
口说无凭,举些简单的例子吧。把这些简单的例子组装起来,就可以得到完整方法的字节码了。
每个例子前半是Java代码,后面的注释是对应的Java字节码,每行一条指令。每条指令后面我还加了注释来表示执行完该指令后操作数栈的状态,就像一样,左边是栈底右边是栈顶,省略号表示不关心除栈顶附近几个值之外操作数栈上的值。
读取一个局部变量用<type>load系指令。local_var_0// // ... ->// iload_0 // ..., value0
- b: byte
- s: short
- c: char
- i: int
- l: long
- f: float
- d: double
- a: 引用类型
local_var_0 = ...// // ..., value0 ->// istore_0 // ...
local_var_1 = local_var_0;// // ... -> // iload_0 // ..., value0 -> // istore_1 // ...
... + ...// // ..., value1, value2 ->// iadd // ..., sum
local_var_0 + local_var_1// // ... ->// iload_0 // ..., value0 -> // iload_1 // ..., value0, value1 -> // iadd // ..., sum
local_var_2 = local_var_0 + local_var_1; // // ... -> // iload_0 // ..., value0 -> // iload_1 // ..., value0, value1 -> // iadd // ..., sum -> // istore_2 // ...
local_var_3 = local_var_0 + local_var_1 + local_var_2 // // ... -> // iload_0 // ..., value0 -> // iload_1 // ..., value0, value1 -> // iadd // ..., sum1 -> // iload_2 // ..., sum1, value2 -> // iadd // ..., sum2 -> // istore_3 // ...
return ...;// // ..., value ->// ireturn // ...
return local_var_0;// // ... ->// iload_0 // ..., value0 -> // ireturn // ...
return local_var_0 + local_var_0// // ... -> // iload_0 // ..., value0 -> // dup // ..., value0, value0 -> // iadd // ..., sum -> // ireturn // ...
1 // iconst_1true // iconst_1 // JVM的类型系统里,整型比int窄的类型都统一带符号扩展到int来表示127 // bipush 127 // 能用一个字节表示的带符号整数常量 1234 // sipush 1234 // 能用两个字节表示的带符号整数常量 12.5 // ldc 12.5 // 较大的整型常量、float、double、字符串常量用ldc
new Object()// // ... ->// new java/lang/Object // ..., ref -> // dup // ..., ref, ref -> // invokespecial java/lang/Object. ()V // ..., ref
关键点在于:new指令只复制分配内存与默认初始化,包括设置对象的类型,将对象的Java字段都初始化到默认值;调用构造器来完成用户层面的初始化是后面跟着的一条invokespecial完成的。
使用this:this// // ... ->// aload_0 // ..., this
这涉及到Java字节码层面的“方法调用约定”(calling convention):参数从哪里传出和传入,通过哪里返回。读读和就好了。
静态方法,方法参数会从局部变量区的第0~(n-1)个slot从左到右传入,假如有n个参数;实例方法,方法参数会从局部变量区的第1~n个slot从左到右传入,假如有n个显式参数,第0个slot传入this的引用。所以在Java源码里使用this,到字节码里就是aload_0。在被调用方看有传入的东西,必然都是在调用方显式传出的。传出的办法就是在invoke指令之前把参数压到操作数栈上。当然,“this”的引用也是这样传递的。
方法真正的局部变量分配在参数之后的slot里。常见的不做啥优化的Java编译器会按照源码里局部变量出现的顺序来分配slot;如果有局部变量的作用域仅在某些语句块里,那么在它离开作用域后后面新出现的局部变量可以复用前面离开了作用域的局部变量的slot。
这方面可以参考我以前写的一个演示稿的第82页: 继续举例。 调用一个静态方法:int local_var_2 = Math.max(local_var_0, local_var_1); // // ... -> // iload_0 // ..., value0 -> // iload_1 // ..., value0, value1 -> // invokestatic java/lang/Math.max(II)I // ..., result -> // istore_2 // ...
local_var_0.equals(local_var_1) // aload_0 // 压入对象引用,作为被调用方法的“this”传递过去 // aload_1 // 压入参数 // invokevirtual java/lang/Object.equals(Ljava/lang/Object;)Z
Java字节码的方法调用使用“符号引用”(symbolic reference)来指定目标,非常容易理解,而不像native binary code那样用函数地址。
读取一个字段:this.x // 假设this是mydemo.Point类型,x字段是int类型// // ... -> // aload_0 // ..., ref -> // getfield mydemo.Point.x:I // ..., value
this.x = local_var_1 // 假设this是mydemo.Point类型,x字段是int类型 // // ... -> // aload_0 // ..., ref -> // iload_1 // ..., ref, value -> // putfield mydemo.Point.x:I // ...
循环的代码生成例子,我在发过一个。这里就不写了。
其它控制流,例如条件分支与无条件分支,感觉都没啥特别需要说的…异常处理…有人问到再说吧。
从Java字节码到Java源码
上面说的是从Java源码->Java字节码方向的对应关系,那么反过来呢? 反过来的过程也就是“反编译”。反编译Java既有现成的反编译器( 、 、 之类, 有更完整的列表),也有些现成的资料描述其做法,例如:- 书:
- 书:
- 老论文:(An Effective Decompilation Algorithm for Java Bytecodes)
两本书里前一本靠谱一些,后一本过于简单不过入门读读可能还行。
论文是日文的不过写得还挺有趣,可读。它的特点是通过来恢复出Java层面的控制流结构。
它的背景是当时有个用Java写的研究性Java JIT编译器叫,先把Java字节码反编译为Java AST,然后再对AST应用传统的编译技术编译到机器码。这种做法在90年代末的JIT挺常见,JRockit最初的JIT编译器也是用这个思路实现。但很快大家就发现干嘛一定要费力气先反编译Java字节码到AST再编译到机器码呢,直接把Java字节码转换为基于图的、有显式控制流和基本块的IR不就好了么。所以比较新的Java JIT编译器都不再做“反编译”这一步了。这些比较老的资料从现在的角度看最大的问题是对JDK 1.4.2之后的javac对try...catch...finally生成的代码的处理不完善。由于较新的javac会把finally块复制到每个catch块的末尾,生成了冗余代码,在复原源码时需要识别出重复的代码并对做tail deduplication(尾去重)才行。以前老的编译方式则是用jsr/ret,应对方式不一样。
从Java字节码对应到Java源码的例子
首先,我们要掌握一些工具,帮助我们把二进制的Class文件转换(“反汇编”)为比较好读的文本形式。最常用的是JDK自带的 。要获取最详细的信息的话,用以下命令:javap -cp -c -s -p -l -verbose
javap -c -s -p -l -verbose java.lang.Object
public boolean equals(java.lang.Object); Signature: (Ljava/lang/Object;)Z flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: aload_1 2: if_acmpne 9 5: iconst_1 6: goto 10 9: iconst_0 10: ireturn LineNumberTable: line 150: 0 StackMapTable: number_of_entries = 2 frame_type = 9 /* same */ frame_type = 64 /* same_locals_1_stack_item */ stack = [ int ]
(为了演示方便我删除了一些重复输出的属性表)
可以看到这里不但有Java字节码,还有丰富的元数据(metadata)描述这段代码。 让我们先从Java字节码的部分看起。在Class文件里,Java字节码位于方法的Code属性表里。0: aload_0
javap的这个显示格式,开头的数字就是bci(bytecode index,字节码偏移量)。bci是从该方法的字节码起始位置开始算的偏移量。后面跟的是字节码指令,以及可选的字节码参数。
如何把字节码转换回成Java代码呢?有些不错的算法可以机械地复原出Java AST。这个例子我们先用比较简单的思路人肉走一遍流程。 下面用一种新的记法来跟踪Java程序的局部变量与表达式临时值的状态,例如:[ 0: this, 1: x, 2: undefined | this, null ]
这个记法用方括号括住一个Java栈帧的状态。中间竖线是分隔符,左边是局部变量区,右边是操作数栈。局部变量区每个slot有标号,也就是slot number,这块可以随机访问;操作数栈的slot则没有标号,通常只能访问栈顶或栈顶附近的slot。
跟之前用的记法类似,操作数栈也是靠左边是栈底,靠右边是栈顶。局部变量区里如果有slot尚未赋初始值的话,则标记为undefined。 让我们试着用这个记法来跟踪一下Object.equals(Object)的程序状态。 根据上文提到的Java calling convention,从该方法的signature(方法参数列表类型和返回值类型。 是Java层面的叫法;在JVM层面叫做 )——(Object)boolean,或者用JVM内部表现方式 (Ljava/lang/Object;)Z——我们可以知道在进入该方法的时候局部变量区的头两个slot已经填充上了参数——实例方法的slot 0是this,slot 1是第一个显式参数。 局部变量区有多少个slot是传入的参数可以看javap输出的“args_size”属性,此例为2;局部变量区总共有多少个slot可以看“locals”属性,此例为2,跟args_size一样说明这个方法没有声明任何具名的局部变量;操作数栈最高的高度可以看“stack“属性,此例为2。 我们先不管具体的参数名,后面再说;先用arg0来指代“第一个参数”。// [ 0: this, 1: arg0 | ] 0: aload_0 // [ 0: this, 1: arg0 | this ] 1: aload_1 // [ 0: this, 1: arg0 | this, arg0 ] 2: if_acmpne 9 // [ 0: this, 1: arg0 | ] // if (this != arg0) goto bci_9 5: iconst_1 // [ 0: this, 1: arg0 | 1 ] 6: goto 10 // [ 0: this, 1: arg0 | 1 ] // goto bci_10 9: iconst_0 // [ 0: this, 1: arg0 | 0 ]10: ireturn // [ 0: this, 1: arg0 | phi(0, 1) ] // return phi(0, 1)
- 当指令使值从局部变量压到操作数栈的时候,我们只是记下栈的变化,其它什么都不用做。
- 当指令从操作数栈弹出值并且进行运算的时候,我们记下栈的变化并且记下运算的内容。
- 当指令是控制流(跳转)时,记录下跳转动作。
- 当指令是控制流交汇处(例如这里的bci 10的位置,既可以来自bci 6也可以来自bci 9),用“phi”函数来合并栈帧中对应位置的值的状态。这里例子里,phi(0, 1)表示这个slot既可能是0也可能是1,取决于前面来自哪条指令。
- 正统的做法应该把基本块(basic block)划分好并且构建出控制流图(CFG,control flow graph)。这个例子非常简单所以先偷个懒硬上。
其实上述过程就是一种“抽象解释”():我们实际上对字节码做了解释执行,只不过不以“运算出最终结果”为目的,而是以“提取出代码的某些特点”为目的。
之前有另外一个问题:,这就是抽象解释的一个应用例子。Wikipedia的词条也值得一读,了解一下大背景。 把上面记录下的代码整理出来,就是:if (this == arg0) { tmp0 = 1; } else { // bci_9: tmp0 = 0; } // bci_10: return tmp0;
- 把if的判断条件“反过来”,跳转目标也“反过来。这是因为javac在为条件分支生成代码时,通常把then分支生成为fall through(直接执行下一条指令而不跳转),而把else分支生成为显式跳转。这样跳转的条件就正好跟源码相反。既然我们要从字节码恢复出源码,这里就得再反回去。
- 把操作数栈上出现了phi函数的slot在恢复出的源码里用临时变量tmp来代替。这样就可以知道到底哪个分支里应该取哪个值。
- 通过方法的signature,我们知道Object.equals(Object)boolean返回值是boolean类型的。前面提到了JVM字节码层面的类型系统boolean是提升到int来表示的,所以这里的1和0其实是true和false。
- if (compare) { true } else { false },其实就是compare本身。只不过JVM字节码指令集没有返回boolean结果的比较指令,而只有带跳转的比较指令,所以生成出的代码略繁琐略奇葩。这样可以化简出tmp0 = this == arg0;
- 所有在我们的整理过程中添加的tmp变量在原本的源码里肯定不是有名字的局部变量,而是没有名字的临时值。在恢复源码时要尽量想办法消除掉。例如说return tmp0;就应该尽量替换成return ...,其中...是计算tmp0的表达式。
public boolean equals(Object arg0) { return this == arg0; }
public boolean equals(Object obj) { return (this == obj); }
如何?小试牛刀感觉还不错?
我们可以再试一个简单的算术运算例子。假如有下述字节码(及signature):public static java.lang.Object add3(int, int, int); Code: stack=2, locals=4, args_size=3 0: iload_0 1: iload_1 2: iadd 3: istore_3 4: iload_3 5: iload_2 6: iadd 7: istore_3 8: iload_3 9: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 12: areturn
// [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | ] 0: iload_0 // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | arg0 ] 1: iload_1 // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | arg0, arg1 ] 2: iadd // [ 0: arg0, 1: arg1, 2: arg2, 3: undefined | tmp0 ] // tmp0 = arg0 + arg1 3: istore_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ] // int loc3 = tmp0 4: iload_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3 ] 5: iload_2 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3, arg2 ] 6: iadd // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | tmp1 ] // tmp1 = loc3 + arg2 7: istore_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ] // loc3 = tmp1 8: iload_3 // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | loc3 ] 9: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | tmp2 ] // tmp2 = Integer.valueOf(loc3)12: areturn // [ 0: arg0, 1: arg1, 2: arg2, 3: loc3 | ] // return tmp2
- 显式声明的局部变量,在还没有进入作用域之前还没有值,记为undefined。当抽象解释到某个局部变量slot首次被赋值,也就是从undefined变为有意义的值的时候,把记录下的代码写成局部变量声明,类型就用赋值进来的值的类型。后面我们会看到局部变量的声明的类型有可能还要受后面代码的影响而需要调整,现在可以先不管。
- 每当从操作数栈弹出值,进行运算后要把结果压回到操作数栈上。为了方便记录,我们把运算用临时变量记着,并把临时变量压回到栈上。这样就不用把栈里的状态写得那么麻烦。
tmp0 = arg0 + arg1 int loc3 = tmp0 tmp1 = loc3 + arg2 loc3 = tmp1 tmp2 = Integer.valueOf(loc3) return tmp2
public static Object add3(int arg0, int arg1, int arg2) { int loc3 = arg0 + arg1; loc3 = loc3 + arg2; return Integer.valueOf(loc3); }
public static Object add3(int x, int y, int z) { int result = x + y; result = result + z; return result; }
就差参数/局部变量名和行号了。
其次,我们要充分利用Java Class文件里包含的符号信息。
如果我们用的是debug build的JDK,那么javap得到的信息会更多。还是以java.lang.Object.equals(Object)为例,public boolean equals(java.lang.Object); Signature: (Ljava/lang/Object;)Z flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: aload_1 2: if_acmpne 9 5: iconst_1 6: goto 10 9: iconst_0 10: ireturn LineNumberTable: line 150: 0 LocalVariableTable: Start Length Slot Name Signature 0 11 0 this Ljava/lang/Object; 0 11 1 obj Ljava/lang/Object; StackMapTable: number_of_entries = 2 frame_type = 9 /* same */ frame_type = 64 /* same_locals_1_stack_item */ stack = [ int ]
- LineNumberTable:行号表。顾名思义,它记录了 源码里的行号 -> 该行的代码的起始bci 的映射关系。javac默认会生成该属性表,也可以显式通过-g:lines参数指定生成。
- LocalVariableTable:局部变量表。它记录了 源码里的变量名和类型 -> 局部变量区的slot number以及作用域在什么bci范围内。javac默认不会生成该属性表,需要通过-g:vars或-g参数来指定生成。该属性表记录的类型是“擦除泛型”之后的类型。
- LocalVariableTypeTable:局部变量类型表。这是泛型方法才会有的属性表,用于记录擦除泛型前源码里声明的类型。javac默认也不会生成该属性表,跟上一个表一样要用参数指定。
这三个属性表通常被称为“调试符号信息”。事实上,Java的调试器就是通过它们来在某行下断点、读取局部变量的值并映射到源码的变量的。放几个传送门:
换句话说,如果没有LocalVariableTable,调试器就无法显示参数/局部变量的值(因为不知道某个名字的局部变量对应到第几个slot);如果没有LineNumberTable,调试器就无法在某行上下断点(因为不知道行号与bci的对应关系)。Oracle/Sun JDK的product build里,rt.jar里的Class文件都只有LineNumberTable而没有LocalVariableTable,所以只能下断点调试却不能显示参数/局部变量的值。我是推荐用javac编译Java源码时总是传-g参数,保证所有调试符号信息都生成出来,以备不时之需。像Maven的Java compiler插件默认配置<debug>true</debug>,实际动作就是传-g参数给javac,如果想维持可调试性的话请不要把它配置为false。这些调试符号信息消耗不了多少空间,不会影响运行时性能,不要白不要——除非您的目的是想阻挠别人调试⋯ 这个例子不是泛型方法所以没有LocalVariableTypeTable,只有LineNumberTable和LocalVariableTable。 LineNumberTable只有一项,说明这个方法只有一行有效的源码,第150行映射到bci [0, 11)这个半开区间。 LocalVariableTable有两项,正好描述的都是参数。它们的作用域都是bci [0, 11)这个半开区间;start和length描述的是 [start, start+length) 范围。它们的类型都是引用类型java.lang.Object。它们的名字,slot 0 -> this,slot 1 -> obj。 应用上这些符号信息,我们就可以把前面例子中反编译得到的:public boolean equals(Object arg0) { return this == arg0; }
public boolean equals(Object obj) { return this == obj; // line 150 }
与原本的源码完美吻合。
终于铺垫了足够背景知识来回过头讲讲题主原本在下的疑问了。
假如一行源码有多个地方要解引用(dereference),每个地方都有可能抛出NullPointerException,但由此得到的stack trace的行号都是一样的,无法区分到底是哪个解引用出了问题。假如stack trace带上bci,问题就可以得到完美解决——前提是用户得能看懂bci对应到源码的什么位置。 于是让我们试一个例子。我先不说这是什么方法,只给出一小段字节码以及相关的调试符号信息:44: aload_1 45: aload_0 46: getfield #12 // Field elementData:[Ljava/lang/Object; 49: iload_2 50: aaload 51: invokevirtual #31 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z 54: ifeq 59 LineNumberTable: line 302: 44 line 303: 57 LocalVariableTable: Start Length Slot Name Signature 36 29 2 i I 0 67 0 this Ljava/util/ArrayList; 0 67 1 o Ljava/lang/Object; LocalVariableTypeTable: Start Length Slot Name Signature 0 67 0 this Ljava/util/ArrayList ;
// [ 0: this, 1: o, 2: i | ... ]44: aload_1 // [ 0: this, 1: o, 2: i | ..., o ]45: aload_0 // [ 0: this, 1: o, 2: i | ..., o, this ]46: getfield #12 // Field elementData:[Ljava/lang/Object; // [ 0: this, 1: o, 2: i | ..., o, tmp0 ] // tmp0 = this.elementData49: iload_2 // [ 0: this, 1: o, 2: i | ..., o, tmp0, i ]50: aaload // [ 0: this, 1: o, 2: i | ..., o, tmp1 ] // tmp1 = tmp0[i]51: invokevirtual #31 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z // [ 0: this, 1: o, 2: i | ..., tmp2 ] // tmp2 = o.equals(tmp1)54: ifeq 59 // [ 0: this, 1: o, 2: i | ... ] // if (tmp2) goto bci_59
tmp0 = this.elementData // bci 46tmp1 = tmp0[i] // bci 50tmp2 = o.equals(tmp1) // bci 51if (tmp2) goto bci_59 // bci 54
if (o.equals(this.elementData[i])) { // ...
实际源码在此: 是 java.util.ArrayList.indexOf(Object)int 的其中一行。
假如有NullPointerException的stack trace带有bci,显示:java.lang.NullPointerException at java.util.ArrayList.indexOf(ArrayList.java:line 302, bci 51) ...
那么我们很容易就知道这里o是null,而不是elementData是null。
通常大家会写在一行上的代码都不会很多,很少会有复杂的控制流所以通常可以不管它,用这种简单的人肉分析法以及足以应付分析抛NPE时bci到源码的对应关系。
爽不?
实际的Java Decompiler是怎么做的,可以参考开源的的实现。
上面的讨论都是基于“要分析的字节码来自javac编译的Java源码”。如果不是javac或者ecj这俩主流编译器生成的,或者是经过了后期处理(各种优化和混淆过),那就没那么方便了,必须用更强力的办法来抵消掉一些优化或混淆带来的问题。