展开菜单
首页 精品内容 本月促销 装机必备 Windows PDF教程 macOS软件 IOS软件 Android AI 专题
全部分类

当前位置:

首页 > 数据库 > 编程开发 > 从Java源码到Java字节码

从Java源码到Java字节码

坦率地说,在Ja va生态里摸爬滚打这么多年,一个最直观的感受就是:ja vac编译器真的非常“老实”。作为当下最主流的源码编译器,它几乎不对代码做优化,只执行那些Ja va语言规范明确要求或推荐的小修小补;也不做任何混淆——无论是名字混淆还是控制流混淆,统统不碰。这意味着ja vac生成的字节码能

本文目录

    坦率地说,在Ja va生态里摸爬滚打这么多年,一个最直观的感受就是:ja vac编译器真的非常“老实”。作为当下最主流的源码编译器,它几乎不对代码做优化,只执行那些Ja va语言规范明确要求或推荐的小修小补;也不做任何混淆——无论是名字混淆还是控制流混淆,统统不碰。这意味着ja vac生成的字节码能极其忠实地保留与源码/AST之间的对应关系。换句话说:用ja vac编译出来的代码,非常容易被反编译。

    再加上Ja va Class文件天生就富含各种符号信息。ja vac在默认编译参数下还会生成行号表,这些元数据进一步帮助反编译工具建立起源码与字节码之间清晰的映射。理解了这一点,你就明白为什么Ja va反编译工具如此成熟且准确。

    从Ja va源码到Ja va字节码

    关于Ja va语法结构如何映射到字节码,JVM规范里其实给出了相当好的示范:Chapter 3. Compiling for the Ja va Virtual Machine。把这章认真啃下来,基本上就能手写字节码了。核心要点只有一个:所有的“运算”都在操作数栈(operand stack)上进行,每个运算的输入参数全部来自栈顶,运算结果也压回栈顶。多数Ja va语句执行之间,操作数栈为空。

    从Ja va源码对应到Ja va字节码的例子

    题主之前提到“从来不觉得阅读底层语言很容易,无论是汇编还是ByteCode还是IL”。我个人觉得,只要愿意花点时间读读资料——比如Charles Nutter的JVM Bytecodes for Dummies,再配合The Ja va Virtual Machine Instruction Set——理解Ja va字节码真的不算难。

    口说无凭,直接上例子吧。把这些简单的例子组装起来,你就可以拼出完整方法的字节码。

    每个例子前半是Ja va代码,后面的注释是对应的Ja va字节码。每条指令后面我还加了注释来表示执行完该指令后操作数栈的状态,记法和JVM规范一致:左边是栈底,右边是栈顶,省略号表示不关心栈顶附近之外的值。

    读取一个局部变量用load系指令。

    local_var_0
    // // ... ->
    // iload_0 // ..., value0

    是类型前缀,有b(byte/short)、c(char)、i(int)、l(long)、f(float)、d(double)、a(引用类型)。load后面的参数是局部变量所在的slot number。其中对0到3的slot有特化的简短指令,例如iload_0。4及以上就用通用load指令,例如iload 4。

    存储一个局部变量用store系指令。

    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 // ...

    const_、bipush、sipush、ldc这些指令都用于向操作数栈压入常量。例如:

    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源码→Ja va字节码方向的对应关系,那么反过来呢?反过来的过程也就是“反编译”。反编译Ja va既有现成的反编译器(Procyon、JD、JAD之类,这里有更完整的列表),也有些现成的资料描述其做法,例如:

    • 书:Covert Ja va: Techniques for Decompiling, Patching, and Reverse Engineering (Alex Kalinovsky)
    • 书:Decompiling Ja va (Godfrey Nolan)
    • 老论文:Ja va バイトコードをデコンパイルするための効果的なアルゴリズム(An Effective Decompilation Algorithm for Ja va Bytecodes)

    两本书里前一本靠谱一些,后一本过于简单不过入门读读可能还行。论文是日文的但写得挺有趣,它的特点是通过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,应对方式不一样。

    从Ja va字节码对应到Ja va源码的例子

    首先,我们要掌握一些工具,帮助我们把二进制的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个属性表含有非常重要的符号信息:

    • LineNumberTable:行号表。记录源码里的行号→该行代码的起始bci的映射关系。ja vac默认会生成该属性表,也可以显式通过-g:lines参数指定生成。
    • LocalVariableTable:局部变量表。记录源码里的变量名和类型→局部变量区的slot number以及作用域在什么bci范围内。ja vac默认不会生成该属性表,需要通过-g:vars或-g参数来指定生成。该属性表记录的类型是“擦除泛型”之后的类型。
    • LocalVariableTypeTable:局部变量类型表。泛型方法才会有的属性表,用于记录擦除泛型前源码里声明的类型。ja vac默认也不会生成,跟上一个表一样要用参数指定。

    这三个属性表通常被称为“调试符号信息”。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插件默认配置true,实际动作就是传-g参数给ja vac,如果想维持可调试性的话请不要把它配置为false。这些调试符号信息消耗不了多少空间,不会影响运行时性能,不要白不要——除非你的目的是想阻挠别人调试……

    回到例子,这个例子不是泛型方法所以没有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这俩主流编译器生成的,或者是经过了后期处理(各种优化和混淆过),那就没那么方便了,必须用更强力的办法来抵消掉一些优化或混淆带来的问题。

    本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系bd@zhengruan.com
    作者最新文章
    数据库 编程开发 Java
    相关文章 更多
    精品专题 更多
    装机必备

    正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

    Windows

    正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

    PDF教程

    正软商城PDF教程频道提供PDF编辑、转换、合并、拆分、压缩及格式处理方法,同时介绍常用PDF软件和工具的使用技巧。

    macOS软件

    正软商城macOS软件专区,精选适用于Mac电脑的办公、设计、影音、效率、开发和系统工具,提供软件功能介绍、macOS兼容版本、正版授权及购买下载服务。

    IOS软件

    正软商城iOS软件专区,精选适用于iPhone和iPad的办公、学习、影音、设计、效率及AI应用,提供功能介绍、适用设备、系统要求和正版获取方式等信息。

    AI

    正软商城AI软件专区,汇集AI写作、AI绘画、AI视频、AI办公、AI编程、AI翻译、智能客服和数据分析等人工智能工具,提供功能介绍、适用平台、收费方式及正版购买信息。

    Mac软件 更多
    Postman macOS版
    Postman macOS版

    Postman是一套面向API全生命周期的开发与测试平台,可在Mac上发送和调试多种协议请求,管理集合、环境及认证信息,并完成脚本测试、模拟服务、监控、文档发布和团队协作。它适合开发、测试及运维人员使用,提供个人免费方案。

    Docker Desktop macOS版
    Docker Desktop macOS版

    Docker Desktop 是 Docker 官方为 Mac 提供的容器开发环境,集成 Docker Engine、命令行工具、Compose、Build、Kubernetes 与图形化管理界面。

    Android Studio macOS版
    Android Studio macOS版

    Android Studio 是 Google 面向安卓应用开发提供的官方集成开发环境,基于 IntelliJ IDEA 构建。它整合智能代码编辑、Gradle 构建、界面预览、设备模拟、性能分析、调试测试及 APK 分析等工具。

    Xcode macOS版
    Xcode macOS版

    Xcode 是苹果面向 Mac 提供的官方集成开发环境,将代码编辑、项目构建、SwiftUI 实时预览、设备模拟、自动化测试、断点调试、性能分析和应用分发整合在同一套工作流中,适合开发与维护 iPhone、iPad、Mac、Apple Wa

    JetBrains WebStorm macOS版
    JetBrains WebStorm macOS版

    JetBrains WebStorm 是面向 JavaScript、TypeScript 和现代 Web 技术的专业集成开发环境,可智能理解项目结构,提供代码补全、重构、错误检查、运行调试、测试及 Git 工具。

    PhpStorm macOS版
    PhpStorm macOS版

    PhpStorm 是 JetBrains 推出的专业 PHP 集成开发环境,可在 Mac 上完成代码编写、智能检查、重构、调试、测试和数据库管理。它支持主流 PHP 框架、Composer、Git、Docker 与远程解释器。

    JetBrains PyCharm macOS版
    JetBrains PyCharm macOS版

    JetBrains PyCharm 是面向 Python 开发者的集成开发环境,提供智能补全、代码检查、重构、调试、测试、版本控制及虚拟环境管理功能。Mac 版兼容 Intel 与 Apple 芯片,核心功能可免费使用。

    JetBrains IntelliJ IDEA macOS版
    JetBrains IntelliJ IDEA macOS版

    IntelliJ IDEA 是 JetBrains 推出的专业集成开发环境,重点服务于 Java、Kotlin 与 JVM 项目开发。软件集成代码补全、静态检查、重构、调试、测试、版本控制及构建工具,并可借助插件扩展语言和框架支持。

    Visual Studio Code macOS版
    Visual Studio Code macOS版

    Visual Studio Code 是微软提供的免费代码编辑器,支持 macOS,并为 Intel 与 Apple 芯片 Mac 提供适配版本。软件集代码编辑、调试、Git 源代码管理、集成终端和扩展市场于一体。

    REAPER macOS版
    REAPER macOS版

    REAPER是Cockos开发的数字音频工作站,提供多轨音频与MIDI录制、剪辑、处理、混音和母带制作工具。Mac版兼容Intel与Apple芯片,支持AU、VST、VST3、CLAP等插件格式,并提供高度可定制的工作流程。

    Adobe Audition macOS版
    Adobe Audition macOS版

    Adobe Audition 是面向播客、影视后期、音乐制作及内容创作者的专业音频工作站,提供波形精修、多轨录制与混音、频谱编辑、降噪修复、响度匹配和效果处理工具,并可与 Adobe Premiere 协同完成视频声音制作。

    Pro Tools macOS版
    Pro Tools macOS版

    Pro Tools 是 Avid 推出的专业数字音频工作站,覆盖多轨录音、无损剪辑、MIDI 编曲、插件处理、自动化混音及声音与画面同步等工作。它拥有 Intro、Artist、Studio 和 Ultimate 等方案。

    WINDOWS 更多
    PixPix 网页版
    PixPix 网页版

    PixPix 是面向品牌、卖家和内容团队的在线 AI 图片视频创作平台,提供图视频 Agent、无限画布、图片生成、视频生成、自然语言编辑及商品套图、产品精修和带货视频等电商工具。

    Postman Windows版
    Postman Windows版

    Postman 是面向 API 全生命周期的开发与协作平台。Windows 桌面版可用于构建和发送 HTTP、GraphQL、gRPC、WebSocket、MQTT 等请求,并提供环境变量、集合、脚本测试、模拟服务、监控、文档及团队工作区等

    Docker Desktop Windows版
    Docker Desktop Windows版

    Docker Desktop 为 Windows 开发者提供集成式容器环境,内置 Docker Engine、命令行工具、Compose、Build 与 Kubernetes 等组件,可在本机完成镜像构建、容器运行、日志查看和资源管理。

    Android Studio Windows版
    Android Studio Windows版

    Android Studio 是 Google 面向 Android 应用开发提供的官方集成开发环境,以 IntelliJ IDEA 为基础,集成智能代码编辑、Gradle 构建、设备模拟、界面预览、调试测试及性能分析工具,

    JetBrains WebStorm Windows版
    JetBrains WebStorm Windows版

    WebStorm 是 JetBrains 面向 JavaScript、TypeScript 和现代 Web 技术打造的集成开发环境。它提供项目级代码分析、智能补全、安全重构、导航搜索,并集成调试、测试、终端、Git 与常用前端工具,

    JetBrains PhpStorm Windows版
    JetBrains PhpStorm Windows版

    PhpStorm 是 JetBrains 面向 PHP 与现代 Web 开发打造的专业集成开发环境,提供代码补全、静态检查、重构、调试、测试、数据库、版本控制及前端语言支持。Windows 用户可直接下载安装程序,

    JetBrains PyCharm Windows版
    JetBrains PyCharm Windows版

    PyCharm 是 JetBrains 推出的 Python 集成开发环境,提供智能代码补全、错误检查、重构、调试、测试、Git、终端及 Jupyter Notebook 支持。统一版安装后可体验 30 天 Pro 功能,

    JetBrains IntelliJ IDEA Windows版
    JetBrains IntelliJ IDEA Windows版

    JetBrains IntelliJ IDEA 是以 Java、Kotlin 和 JVM 项目为核心的专业集成开发环境,提供智能补全、代码检查、安全重构、调试、测试、版本控制及构建工具集成。统一发行版的核心功能可免费用于商业和非商业项目,

    Visual Studio Code Windows版
    Visual Studio Code Windows版

    Visual Studio Code 是微软推出的免费代码编辑器,Windows 版集成智能补全、代码导航、调试、终端和版本控制,并可通过扩展添加语言、格式化及远程开发能力。它启动较快、配置灵活,

    REAPER Windows版
    REAPER Windows版

    REAPER 是 Cockos 推出的数字音频工作站,适用于录音、MIDI 编曲、音频编辑、混音、母带制作及声音设计。软件安装包小巧,支持多轨录制、灵活路由、自动化、第三方插件和脚本扩展,并提供完整功能的 60 天免费评估期。

    Adobe Audition Windows版
    Adobe Audition Windows版

    Adobe Audition 是面向播客、影视后期、广播与音乐制作的专业音频工作站,提供波形编辑、多轨混音、频谱分析、录音、降噪修复和效果处理等功能。Windows 版通过 Adobe Creative Cloud 安装和管理,

    Pro Tools Windows版
    Pro Tools Windows版

    Pro Tools 是 Avid 推出的专业数字音频工作站,提供多轨录音、音频剪辑、MIDI编曲、插件处理、自动化混音及影视声音制作能力。它适合音乐人、录音师、制作人和音频后期团队使用,并提供免费入门版、订阅方案及永久授权选择。