发布于2026-07-12 阅读(0)
扫一扫,手机访问
坦率地说,在Ja va生态里摸爬滚打这么多年,一个最直观的感受就是:ja vac编译器真的非常“老实”。作为当下最主流的源码编译器,它几乎不对代码做优化,只执行那些Ja va语言规范明确要求或推荐的小修小补;也不做任何混淆——无论是名字混淆还是控制流混淆,统统不碰。这意味着ja vac生成的字节码能极其忠实地保留与源码/AST之间的对应关系。换句话说:用ja vac编译出来的代码,非常容易被反编译。
再加上Ja va Class文件天生就富含各种符号信息。ja vac在默认编译参数下还会生成行号表,这些元数据进一步帮助反编译工具建立起源码与字节码之间清晰的映射。理解了这一点,你就明白为什么Ja va反编译工具如此成熟且准确。

关于Ja va语法结构如何映射到字节码,JVM规范里其实给出了相当好的示范:Chapter 3. Compiling for the Ja va Virtual Machine。把这章认真啃下来,基本上就能手写字节码了。核心要点只有一个:所有的“运算”都在操作数栈(operand stack)上进行,每个运算的输入参数全部来自栈顶,运算结果也压回栈顶。多数Ja va语句执行之间,操作数栈为空。
题主之前提到“从来不觉得阅读底层语言很容易,无论是汇编还是ByteCode还是IL”。我个人觉得,只要愿意花点时间读读资料——比如Charles Nutter的JVM Bytecodes for Dummies,再配合The Ja va Virtual Machine Instruction Set——理解Ja va字节码真的不算难。
口说无凭,直接上例子吧。把这些简单的例子组装起来,你就可以拼出完整方法的字节码。
每个例子前半是Ja va代码,后面的注释是对应的Ja va字节码。每条指令后面我还加了注释来表示执行完该指令后操作数栈的状态,记法和JVM规范一致:左边是栈底,右边是栈顶,省略号表示不关心栈顶附近之外的值。
读取一个局部变量用
local_var_0
// // ... ->
// iload_0 // ..., value0
存储一个局部变量用
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_1
true // iconst_1
// JVM的类型系统里,整型比int窄的类型都统一带符号扩展到int来表示
127 // bipush 127 // 能用一个字节表示的带符号整数常量
1234 // sipush 1234 // 能用两个字节表示的带符号整数常量
12.5 // ldc 12.5 // 较大的整型常量、float、double、字符串常量用ldc
创建一个对象,用空参数的构造器:
new Object()
// // ... ->
// new ja va/lang/Object // ..., ref ->
// dup // ..., ref, ref ->
// invokespecial ja va/lang/Object.()V // ..., ref
关于这段字节码的解释,关键点在于:new指令只负责分配内存与默认初始化——设置对象类型、将字段初始化为默认值;调用构造器完成用户层面的初始化是后面跟着的一条invokespecial完成的。
使用this:
this
// // ... ->
// aload_0 // ..., this
这涉及到Ja va字节码层面的“方法调用约定”(calling convention):参数从哪里传出和传入,通过哪里返回。静态方法,参数会从局部变量区的第0~(n-1)个slot从左到右传入(假如有n个参数);实例方法,参数会从第1~n个slot从左到右传入(假如有n个显式参数),第0个slot传入this的引用。所以在Ja va源码里使用this,到字节码里就是aload_0。
在被调用方看有传入的东西,必然都是在调用方显式传出的。传出的办法就是在invoke指令之前把参数压到操作数栈上。当然,“this”的引用也是这样传递的。
方法真正的局部变量分配在参数之后的slot里。常见不做啥优化的Ja va编译器会按照源码里局部变量出现的顺序来分配slot;如果有局部变量的作用域仅在某些语句块里,那么在它离开作用域后后面新出现的局部变量可以复用前面离开了作用域的局部变量的slot。这方面可以参考我以前写的一个演示稿的第82页:Ja va 程序的编译,加载 和 执行。
继续举例。
调用一个静态方法:
int local_var_2 = Math.max(local_var_0, local_var_1);
// // ... ->
// iload_0 // ..., value0 ->
// iload_1 // ..., value0, value1 ->
// invokestatic ja va/lang/Math.max(II)I // ..., result ->
// istore_2 // ...
调用一个公有实例方法:
local_var_0.equals(local_var_1)
// aload_0 // 压入对象引用,作为被调用方法的“this”传递过去
// aload_1 // 压入参数
// invokevirtual ja va/lang/Object.equals(Lja va/lang/Object;)Z
Ja va字节码的方法调用使用“符号引用”(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 // ...
循环的代码生成例子,之前在对C语义的for循环的基本代码生成模式里发过,这里就不写了。其它控制流——条件分支与无条件分支——感觉都没啥特别需要说的。异常处理……有人问到再说吧。
上面说的是从Ja va源码→Ja va字节码方向的对应关系,那么反过来呢?反过来的过程也就是“反编译”。反编译Ja va既有现成的反编译器(Procyon、JD、JAD之类,这里有更完整的列表),也有些现成的资料描述其做法,例如:
两本书里前一本靠谱一些,后一本过于简单不过入门读读可能还行。论文是日文的但写得挺有趣,它的特点是通过dominator tree来恢复出Ja va层面的控制流结构。它的背景是当时有个用Ja va写的研究性Ja va JIT编译器叫OpenJIT,先把Ja va字节码反编译为Ja va AST,然后再对AST应用传统的编译技术编译到机器码。这种做法在90年代末的JIT挺常见,JRockit最初的JIT编译器也是用这个思路实现。但很快大家就发现,干嘛一定要费力气先反编译Ja va字节码到AST再编译到机器码呢?直接把Ja va字节码转换为基于图的、有显式控制流和基本块的IR不就好了么。所以比较新的Ja va JIT编译器都不再做“反编译”这一步了。
这些比较老的资料从现在的角度看最大的问题是对JDK 1.4.2之后的ja vac对try...catch...finally生成的代码的处理不完善。由于较新的ja vac会把finally块复制到每个catch块的末尾,生成了冗余代码,在复原源码时需要识别出重复的代码并做tail deduplication(尾去重)才行。以前老的编译方式则是用jsr/ret,应对方式不一样。
首先,我们要掌握一些工具,帮助我们把二进制的Class文件转换(“反汇编”)为比较好读的文本形式。最常用的是JDK自带的ja vap。要获取最详细的信息,用以下命令:
ja vap -cp -c -s -p -l -verbose
例如,要看ja va.lang.Object的Class文件的内容,可以执行:
ja vap -c -s -p -l -verbose ja va.lang.Object
提取其中ja va.lang.Object.equals(Object)的部分出来:
public boolean equals(ja va.lang.Object);
Signature: (Lja va/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 ]
(为了演示方便我删除了一些重复输出的属性表)可以看到这里不但有Ja va字节码,还有丰富的元数据(metadata)描述这段代码。
先从Ja va字节码的部分看起。在Class文件里,Ja va字节码位于方法的Code属性表里。
0: aload_0
ja vap的这个显示格式,开头的数字就是bci(bytecode index,字节码偏移量)。bci是从该方法的字节码起始位置开始算的偏移量。后面跟的是字节码指令以及可选的字节码参数。
如何把字节码转换回成Ja va代码呢?有些不错的算法可以机械地复原出Ja va AST。这个例子我们先用一个比较简单的思路人肉走一遍流程。
下面用一种新的记法来跟踪Ja va程序的局部变量与表达式临时值的状态,例如:
[ 0: this, 1: x, 2: undefined | this, null ]
这个记法用方括号括住一个Ja va栈帧的状态。中间竖线是分隔符,左边是局部变量区,右边是操作数栈。局部变量区每个slot有标号(slot number),可以随机访问;操作数栈的slot则没有标号,通常只能访问栈顶或栈顶附近的slot。跟之前用的记法类似,操作数栈靠左边是栈底,靠右边是栈顶。局部变量区里如果有slot尚未赋初始值,则标记为undefined。
试着用这个记法来跟踪一下Object.equals(Object)的程序状态。根据上文提到的Ja va calling convention,从该方法的signature——(Object)boolean(或者用JVM内部表现(Lja va/lang/Object;)Z)——可以知道在进入该方法时局部变量区的头两个slot已经填充上了参数——实例方法的slot 0是this,slot 1是第一个显式参数。局部变量区有多少个slot是传入的参数可以看ja vap输出的“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),这个例子非常简单所以先偷个懒硬上。
其实上述过程就是一种“抽象解释”(abstract interpretation):我们实际上对字节码做了解释执行,只不过不以“运算出最终结果”为目的,而是以“提取出代码的某些特点”为目的。Wikipedia的Decompiler词条也值得一读,了解一下大背景。
把上面记录下的代码整理出来,就是:
if (this == arg0) {
tmp0 = 1;
} else {
// bci_9:
tmp0 = 0;
}
// bci_10:
return tmp0;
这里做了几项“整理”:把if的判断条件“反过来”,跳转目标也“反过来”。这是因为ja vac在为条件分支生成代码时,通常把then分支生成为fall through(直接执行下一条指令而不跳转),而把else分支生成为显式跳转。这样跳转的条件就正好跟源码相反。既然我们要从字节码恢复出源码,这里就得再反回去。把操作数栈上出现了phi函数的slot在恢复出的源码里用临时变量tmp来代替。这样就可以知道到底哪个分支里应该取哪个值。
现在这个源码已经挺接近真正的源码。还需要做少许修正:通过方法的signature,我们知道Object.equals(Object)返回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;
}
而这跟Object.equals(Object)真正的源码几乎一样了:
public boolean equals(Object obj) {
return (this == obj);
}
如何?小试牛刀感觉还不错吧?
再试一个简单的算术运算例子。假如有下述字节码(及signature):
public static ja va.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 ja va/lang/Integer.valueOf:(I)Lja va/lang/Integer;
12: areturn
跟前面的例子一样,我们先根据方法的signature创建出初始的栈帧状态,然后一条条指令抽象解释下去。这是个静态方法,没有隐含参数this。根据args_size=3可知slot 0-2是传入的参数,locals=4所以有一个显式声明的局部变量,stack=2所以操作数栈最高高度为2。
// [ 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 ja va/lang/Integer.valueOf:(I)Lja va/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
上一例也提到过,我们要尽量消除掉新添加的tmp临时变量,因为它们不是原本源码里存在的局部变量。修正后得到:
public static Object add3(int arg0, int arg1, int arg2) {
int loc3 = arg0 + arg1;
loc3 = loc3 + arg2;
return Integer.valueOf(loc3);
}
留意:包装类型的valueOf()方法可能是源码里显式调用的,也可能是编译器给自动装箱(autoboxing)生成代码时生成的。所以遇到Integer.valueOf(loc3)的话,反编译出loc3也正确,让编译器去做自动装箱。
整理出来的代码跟我原本写的源码一致:
public static Object add3(int x, int y, int z) {
int result = x + y;
result = result + z;
return result;
}
就差参数/局部变量名和行号了。其次,我们要充分利用Ja va Class文件里包含的符号信息。
如果我们用的是debug build的JDK,那么ja vap得到的信息会更多。还是以ja va.lang.Object.equals(Object)为例:
public boolean equals(ja va.lang.Object);
Signature: (Lja va/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 Lja va/lang/Object;
0 11 1 obj Lja va/lang/Object;
StackMapTable: number_of_entries = 2
frame_type = 9 /* same */
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]
Class文件里每个方法可以有许多元数据,里面可以包含丰富的符号信息。其中有3个属性表含有非常重要的符号信息:
这三个属性表通常被称为“调试符号信息”。Ja va的调试器就是通过它们来在某行下断点、读取局部变量的值并映射到源码的变量的。换句话说,如果没有LocalVariableTable,调试器就无法显示参数/局部变量的值(因为不知道某个名字的局部变量对应到第几个slot);如果没有LineNumberTable,调试器就无法在某行上下断点(因为不知道行号与bci的对应关系)。Oracle/Sun JDK的product build里,rt.jar里的Class文件都只有LineNumberTable而没有LocalVariableTable,所以只能下断点调试却不能显示参数/局部变量的值。
我个人推荐用ja vac编译Ja va源码时总是传-g参数,保证所有调试符号信息都生成出来,以备不时之需。像Ma ven的Ja va compiler插件默认配置
回到例子,这个例子不是泛型方法所以没有LocalVariableTypeTable,只有LineNumberTable和LocalVariableTable。LineNumberTable只有一项,说明这个方法只有一行有效的源码,第150行映射到bci [0,11)这个半开区间。LocalVariableTable有两项,正好描述的都是参数。它们的作用域都是bci [0,11)这个半开区间,类型都是引用类型ja va.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
}
与原本的源码完美吻合。
终于铺垫了足够背景知识,回头讲讲题主在“ja va.lang.NullPointerException为什么不设计成显示null对象的名字或类型”问题下的疑问。假如一行源码有多个地方要解引用(dereference),每个地方都有可能抛出NullPointerException,但由此得到的stack trace的行号都是一样的,无法区分到底是哪个解引用出了问题。假如stack trace带上bci,问题就可以得到完美解决——前提是用户得能看懂bci对应到源码的什么位置。
于是让我们试一个例子。我先不说这是什么方法,只给出一小段字节码以及相关的调试符号信息:
44: aload_1
45: aload_0
46: getfield #12 // Field elementData:[Lja va/lang/Object;
49: iload_2
50: aaload
51: invokevirtual #31 // Method ja va/lang/Object.equals:(Lja va/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 Lja va/util/ArrayList;
0 67 1 o Lja va/lang/Object;
LocalVariableTypeTable:
Start Length Slot Name Signature
0 67 0 this Lja va/util/ArrayList;
从LineNumberTable可以知道,源码第302行对应到bci [44,57)的半开区间。从LocalVariableTable可以知道在这段字节码的范围内每个slot到局部变量名的映射关系。仅凭以上信息无法知道当前操作数栈的高度,不过这种上下文里通常我们可以不关心它的初始高度,暂时忽略就好。
然后来抽象解释一下这段字节码:
// [ 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:[Lja va/lang/Object;
// [ 0: this, 1: o, 2: i | ..., o, tmp0 ]
// tmp0 = this.elementData
49: 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 ja va/lang/Object.equals:(Lja va/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 46
tmp1 = tmp0[i] // bci 50
tmp2 = o.equals(tmp1) // bci 51
if (tmp2) goto bci_59 // bci 54
可以很明显的看到这行代码有3处解引用,分别位于bci 46、50、51。当然,Ja va的实例方法的语义保证了此处this不会是null,所以能抛NPE的只能是bci 50和51两处。消除掉临时变量恢复出源码,这行代码是:
if (o.equals(this.elementData[i])) {
// ...
实际源码在此:http://hg.openjdk.ja va.net/jdk8/jdk8/jdk/file/tip/src/share/classes/ja va/util/ArrayList.ja va#l302 是ja va.util.ArrayList.indexOf(Object)int的其中一行。
假如有NullPointerException的stack trace带有bci,显示:
ja va.lang.NullPointerException
at ja va.util.ArrayList.indexOf(ArrayList.ja va:line 302, bci 51)
...
那么我们很容易就知道这里o是null,而不是elementData是null。通常大家会写在一行上的代码都不会很多,很少会有复杂的控制流,用这种简单的人肉分析法足以应付分析抛NPE时bci到源码的对应关系。
爽不?
实际的Ja va Decompiler是怎么做的,可以参考开源的Procyon的实现。上面的讨论都是基于“要分析的字节码来自ja vac编译的Ja va源码”。如果不是ja vac或者ecj这俩主流编译器生成的,或者是经过了后期处理(各种优化和混淆过),那就没那么方便了,必须用更强力的办法来抵消掉一些优化或混淆带来的问题。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8