【笔记】如何阅读字节码

从Class文件开始

Java 字节码是 JVM 里面指令的型式, Java 的源码经过 Java 编译器会形成 Java 字节码,这的字节码才能在 Java 虚拟机中运行。

以一段Java代码为例

public class Hello {
	public static void main(String[] args) {
		int a = 1;
		int b = 1;
		int c = add(a, b);
		System.out.println(c);
	}
	public static int add(int a, int b) {
		return a + b;
	}
}

使用javac贬义生成的.class是二进制文件

阅读二进制class


这是一个二进制表,从00000000作为第一行,00为第一列开始阅读。

两个魔数

前面的 CA FE BA BE 这个四个字节均为魔数,是固定的开头,JVM 根据这个开头来判断一个文件是否可能为 .class 文件,如果是才会继续执行。
据说Java编程语言之父,詹姆斯•高斯林(James Gosling)曾说CAFEBABE灵感来源于常去的饭店的昵称CAFEDEAD

版本号

魔数后面四个字节 00 00 00 37 是版本号,前两个字节为次版本号,后两个字节为主版本号,在对主版本号进行转换可以得到 55,该序号对应的 Java 版本为 java11。
查阅Java 版本对应的版本号,可以在wiki/Java_class_file上查到。

常量池``

版本号后面都是常量池。这部分又分为常量池计数器和常量池数据区。
00 1D代表常量池计数器,即constant_pool_count = 0x001D
常量池计数器的值是指常量池数据区拥有constant_pool_count - 1个常量。

javap命令反解析字节码

你可以通过 javap -help 来查看所有参数的说明,这里为了显示尽量详细的内容,使用 javap -verbose Hello

反解析后的字节码

Classfile /Volumes/白蛇/WorkSpace/JavaProject/Hello.class
  Last modified 2023年3月29日; size 468 bytes
  MD5 checksum 4a38c223ad717f28f300f60747576892
  Compiled from "Hello.java"
public class Hello
  minor version: 0
  major version: 55
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #5                          // Hello
  super_class: #6                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 3, attributes: 1
Constant pool:
   #1 = Methodref          #6.#17         // java/lang/Object."<init>":()V
   #2 = Methodref          #5.#18         // Hello.add:(II)I
   #3 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #21.#22        // java/io/PrintStream.println:(I)V
   #5 = Class              #23            // Hello
   #6 = Class              #24            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               add
  #14 = Utf8               (II)I
  #15 = Utf8               SourceFile
  #16 = Utf8               Hello.java
  #17 = NameAndType        #7:#8          // "<init>":()V
  #18 = NameAndType        #13:#14        // add:(II)I
  #19 = Class              #25            // java/lang/System
  #20 = NameAndType        #26:#27        // out:Ljava/io/PrintStream;
  #21 = Class              #28            // java/io/PrintStream
  #22 = NameAndType        #29:#30        // println:(I)V
  #23 = Utf8               Hello
  #24 = Utf8               java/lang/Object
  #25 = Utf8               java/lang/System
  #26 = Utf8               out
  #27 = Utf8               Ljava/io/PrintStream;
  #28 = Utf8               java/io/PrintStream
  #29 = Utf8               println
  #30 = Utf8               (I)V
{
  public Hello();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_1
         3: istore_2
         4: iload_1
         5: iload_2
         6: invokestatic  #2                  // Method add:(II)I
         9: istore_3
        10: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: iload_3
        14: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        17: return
      LineNumberTable:
        line 3: 0
        line 4: 2
        line 5: 4
        line 6: 10
        line 7: 17

  public static int add(int, int);
    descriptor: (II)I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=2
         0: iload_0
         1: iload_1
         2: iadd
         3: ireturn
      LineNumberTable:
        line 10: 0
}

文件信息 Classfile

开头Classfilepublic class Hello上一行结束。

类信息

public class HelloConstant pool上一行结束。

  • minor version: 次版本号
  • major version: 主版本号
  • flags:访问标志
    访问标志表格:

    ACC_SUPER一般是编译器默认添加的,方便子类通过invokespecial指令调用父类方法。

常量池 Constant pool

在类信息的下面,则是常量池,它类似一个表,每个常量由编号、类型、值,这 3 个部分组成。
常量池中每个常量都有一个编号,以#开头,编号后面是=加上该常量的类型,具体类型说明请参考官方 jvms 文档的 The Constant Pool 的解释。
节选部分常量,可以看到#2的值(Methodref的值是方法名称)如何获取的。
在常量池中我们可以看到,#5 是一个类,它的值和方法一样都是名称,因此它引用了常量 #32,对于 Utf8 类型的常量,其值则是一个字符串,也就是常量 #32 的值就是字符串 Hello。因此 #5 的值就是 Hello。同样的 #27 的值是 add:(II)I,将它们组合起来 #2 的值就是 Hello.add:(II)I 了。

Constant pool:
   #2 = Methodref          #5.#27         // Hello.add:(II)I
   #5 = Class              #32            // Hello
  #22 = Utf8               add
  #23 = Utf8               (II)I
  #27 = NameAndType        #22:#23        // add:(II)I
  #32 = Utf8               Hello

包含的方法

与 Java 代码一样,我们所定义的方法在类里面,而在字节码中我们定义在类中的方法也放在大括号里面,而这个大括号就在常量池下方。
对于每个方法,都包含首行的声明,以及紧接在后面的 descriptor(描述符号),flags(访问标识),Code(代码)。
descriptor:这其实是这个方法的参数以及返回值的缩写。当我们在 Java 中编写重载方法时,由于方法名一样,JVM 可以通过 descriptor 来区分所调用的方法是哪一个。
flags:方法的修饰符。
Code:方法的代码对应执行的指令。

Code结构
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_1
         3: istore_2
         4: iload_1
         5: iload_2
         6: invokestatic  #2                  // Method add:(II)I
         9: istore_3
        10: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: iload_3
        14: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        17: return
      LineNumberTable:
        line 3: 0
        line 4: 2
        line 5: 4
        line 6: 10
        line 7: 17
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  args   [Ljava/lang/String;
            2      16     1     a   I
            4      14     2     b   I
           10       8     3     c   I
代码的指令表

第一行代表相关信息

  • stack:操作数栈(operand stack)中最大深度,这个在分析指令执行过程中可以看出。
  • locals:本地变量的数量,在我们的main方法里面有定义了 3 个变量,加上一个参数,因此有 4 个变量;
  • args_size:方法参数的数量,非静态方法最少为1,因为编译器会自动添加对类的引用,也就是可以在方法内部使用的this
    后续代表字节码指令,一条指令包括偏移量以及执行的指令码,PC Register 利用偏移量来判断指令执行位置。
代码行对照表 LineNumberTable

line n: m含义是:代码中的第n行以偏移量为m的指令开始(对应上面字节码指令),直到执行到下一行(不一定是行号+1)代码对应的开始指令偏移量。
例子:
line 3: 0 代表 Java 源码文件中的第三行代码从偏移量为 0 的位置开始,而继续往下看可以看到第四行代码从偏移量为 2 的位置开始,也就是说第三行代码所对应的字节码指令有 iconst_1 和 istore_1 两条。这也可以让 JVM 执行指令出现错误时,帮助我们定位到对应的源码位置。

局部变量表 LocalVariableTable(可选)
  • Start 为这个变量可见的起始偏移位置,它的值必须是在 Code 中存在的偏移量值。
  • Length 为该变量的有效长度,在这个例子中,我们的变量直到方法末尾都有效,因此你会发现 start + lenth 的值都是 18 (方法中执行的指令数)。当我们在一个局部的代码块里面声明一个变量,那么它的有效期长度将会更短。
  • Slot 为变量在 local variable 中的位置,这可以帮助我们在指令中确定对应的变量。
  • Name 则是变量名。
  • Signature 为该变量的类型。

分析指令执行过程

JVM如何使用class文件

首先需要明白JVM虚拟机是什么样的。

JVM的架构

一个虚拟机有基于栈虚拟机(Stack based Virtual Machine)和 基于寄存器虚拟机(Register based Virtual Machine)之法, 它们的差别可以看这里
JVM是基于栈虚拟机
我们的 Java 程序在运行时是通过 main() 方法启动,它是程序的入口,我们的进程在启动时会为该方法创建一个主线程来执行代码。当我们使用多线程时,那么程序的进程将会拥有多个线程。每个线程的资源都拥有独自的资源,当然它们也可以共享进程的资源,那么在 JVM 中,根据资源的可用范围,可将内存区域分为线程独占线程共享两个类别。

程序计数器

用于记录当前线程指令的执行位置。由于一个进程可能有多个线程,而 CPU 会在不同线程之间切换,为了能够记录各个线程的当前执行的指令,每个线程都需要有一个 PC Register,来保证各个线程都可以进行独立运算。

本地方法栈

调用操作系统本地方法时使用的栈空间。

虚拟机栈

用于存放调用方法时压入栈的栈帧。相信学过数据结构的对栈应该不陌生,JVM Stack 压入的单位为栈帧(Frame),用于存储数据、动态链接、方法返回值和调度异常等。每次调用一个方法都会创建一个新的栈帧压入 JVM Stack 来存储该方法的信息,当该方法调用完成时,对应的栈帧也会跟着被销毁。一个栈帧都有自己的局部变量数组、操作数栈、对当前方法类的运行常量池的引用。

堆和方法区

每个线程都可用访问的内存空间为线程共享区域,它包含 Head 和 Method Area 两个部分,Head 用于存放实例对象,也是 GC 回收的主要区域,而 Method Area 用于存放类结构与静态变量。

class数据如何对应到JVM中

结合反解析的字节码和JVM内存结构可以看出,class中类信息,[常量池](#常量池 Constant pool),包含的方法首先都进入了方法区
然后随着程序的执行,主线程开始执行代码,这时候主线程的JVM栈中压入方法区中保存的类的初始函数,初始化完成后产生的类的实例将保存到中。
然后调用main方法,为主线程的JVM栈压入一个新的栈帧,栈帧中的 local variable(本地变量表)先存放方法的参数,也就是字节码中方法首行声明的参数。Reference to Constant Pool(动态链接)指向方法区中运行时常量池的方法引用。
然后程序计数器开始按照方法Code中的指令表的偏移量执行指令。这个过程中指令如果产生新的对象,那这个对象也将被分配到堆内存中,而指向该对象的引用会被压入操作栈。operand stack(操作数栈)有一个最大长度,由字节码中的Code中的字节码指令表的stack确定。

通过main方法分析指令执行

JVM中的指令需要查看指令表

假设程序运行 0 号指令前的状态如下,在 mian 方法栈帧里面,有着 operand stack(操作数栈),此外还有一个 local variable(本地变量表)来存放变量的值,其中下标为 0 的变量为主方法的参数 args,我们直接用这个字符串填充在那里来做一个标识(实际的值可能是一个空数组)。

main方法第一行代码查看代码行对照表可知对应的指令分别是偏移量为 0 和 1 的两个,最开始执行的是 0: iconst_1,该指令会把 int 常量 1 放置到 operand stack 中,之后执行的是 1: istore_1,把 operand stack 栈顶的 int 常量取出放到 local variable 下标为 1 的变量中,该过程图示如下。

我们可以通过查看 字节码中的LocalVariableTable得知下标为 1 的变量在我们的 Java 程序中是 int 变量 a,因此上面这两条指令常量 1 赋值给变量 a。同样的,后面两条指令则是将常量 1 赋值给变量 b。这里要注意,操作数栈的数是被取出操作,被取出的数将不会继续在 operand stack 里面。
执行完 0~3 这 4 条指令后,就来到了本例中最为关键的方法调用了。在执行 iload_1 和 iload_2 后,operand stack 中将会存放着变量 a 和 b 的值,作为 invokestatic 调用函数时传入的参数。
而执行到 invokestatic #2 这个指令的时候,该指令为调用一个 class 的 static 方法,也就是调用常量池中 #2 的方法,该方法为 Hello.add:(II)I

当执行 invokestatic 时会依次读取 operand stack 的数据作为方法的参数,并创建一个新的栈帧来执行方法,将数据放到 local variable 对应变量位置。

之后开始执行 add() 方法中的指令,首先执行的是两个 iload 指令,将 loca variable 对应下标的变量的值放到 operand stack 中,之后执行 iadd 取出 operand stack 中的值并进行加法运算,再把结果放到add方法栈帧中的操作数栈,最后执行 ireturn 取出 operand stack 顶部的 int 值进行返回。



当执行完 ireturn 后,add 方法也就执行完成了,对应的栈帧也会跟着销毁。之后回到 main 方法中继续往下执行,到 istore_3 指令,该指令将栈顶的 int 值取出放到了 local variable 中 Solt 为 3 的地方,这样执行完 4~9 这几条指令后就完成了我们代码中的 int c = add(a, b); 这一行代码。那么接下来就是执行 System.out.println(c); 对应的指令将 2 打印到控制台了。

IDEA中的Bytecode Viewer

在这里所展示的 Byte Code 格式与我们上面使用 javap 显示出来的不一样。抛弃了大部分文件,类和方法信息,去除了常量池。

在这里它会将每一行 Java 代码的指令都区分开。双击选中代码,Bytecode Viewer也会选中对应指令块。可以发现LINENUMBER 对应上面 javap 中的 LineNumberTable,直接在这里描述当前这一块的指令对应的代码在文件中的位置。
在方法的最后,会多出一块内容来描述方法的信息,在这里会将 LocalVariableTable 里的变量都列出来,但格式与 javap 的 LocalVariableTable 中的描述格式不一样,每一行 LOCALVARIABLE 代表一个变量,描述格式从左到右依次为变量名、类型、开始可见时的指令块、最后有效的位置、Solt。除了描述方法中出现的变量外,操作数栈最大深度和本地变量个数也在放在这里。

由于进行了分块查询对应代码的指令也很方便。但这个插件显示的内容也少了很多东西,如果需要查看初始常量池的内容,那就需要使用 javap 了。

JVM数据类型

  1. [[Java基本数据类型]]
    • 数字类型
    • boolean类型
    • returenAdress:指令指针
  2. 引用类型
    • 集合
    • 接口

boolean 类型在字节码里面支持有限。比如,并没有一个指令可以直接操作 boolean 类型的值。布尔值一般会被编译器转为 int 类型的值,并且用 int 相关的指令来操作。

补充说明

通过反编译查看编译器细节

比如:String在声明时,是字符串拼接,则转换为StringBuilder然后再调用toString。
这种可以写Java代码,然后转为Kotlin,获取到Bycode后再Decompile,反编译出来的代码会比原代码更接近编译器编译后的。

ACC_SUPER理解

  1. 问题:如何理解Java字节码中的ACC_SUPER
    在Java字节码中,ACC_SUPER是访问标志之一,表示当前类调用超类方法时,应该使用特殊的方法调用指令invokespecial而不是普通的虚拟方法调用指令invokevirtual。

在Java中,如果一个子类重写了其父类的方法,子类对象调用该方法时将会执行子类中的实现,而不是父类中的实现。但是,有时候我们希望能够在子类中调用父类中被重写的方法。这时,就需要使用ACC_SUPER访问标志了。

当一个类被标记为ACC_SUPER时,调用该类的方法时,JVM会首先调用父类的同名方法,然后再调用子类的同名方法。这样可以保证在子类中调用父类的方法时,父类方法会被正确地执行。

需要注意的是,ACC_SUPER标志只对于调用超类方法时有意义,对于其他情况则没有任何影响。通常情况下,编译器会自动地为每个类添加ACC_SUPER标志,因此在大多数情况下我们不需要手动地指定这个标志。

  1. 问题:类信息中包含这个flag,是不是意味着该类的初始函数也会调用Object的初始函数
    类信息中包含ACC_SUPER标志,并不意味着该类的构造函数会自动调用Object类的构造函数。
    Java中的类构造函数并不是继承自Object类的构造函数,而是独立定义在类中的。当一个类被实例化时,其构造函数会被自动调用,但并不会默认调用Object类的构造函数。
    ACC_SUPER标志的作用是在子类中调用父类方法时,能够正确地执行父类方法。具体来说,在字节码中,当使用invokespecial指令调用父类方法时,如果当前类的ACC_SUPER标志被设置,JVM会首先在父类中寻找该方法的定义,然后再执行子类中的同名方法。
    因此,ACC_SUPER标志的作用和类的构造函数没有直接关系。

  2. 问题:如果需要修改字节码才能到达使用invokevirtual指令,那这个指令的意义是什么?
    虽然在Java语言中,使用super关键字来调用父类方法会被自动翻译成invokespecial指令,但是在一些其他的语言或者代码生成工具中,可能会需要手动使用invokevirtual指令来调用父类方法,因此这个指令还是有一定的意义的。

    在Java字节码中,invokevirtual指令是用来调用一个对象的实例方法的,它根据对象的类型和方法的名称、描述符来查找目标方法,并调用该方法。在子类中调用父类方法时,如果使用invokespecial指令,则只会查找父类中的方法,而不会考虑到子类中是否有同名同参数类型的方法,因此可能会产生错误的行为。而使用invokevirtual指令则可以根据实际对象的类型来查找方法,从而保证正确的调用。
    因此,如果需要在子类中调用父类方法,并且希望在调用时考虑到实际对象的类型,那么手动使用invokevirtual指令来调用父类方法就是有意义的。

  3. 问题:那invokespeical和invokevirtual在什么情况下会有差异?
    invokespecial指令和invokevirtual指令的主要区别在于它们对于方法查找的方式不同。
    invokespecial指令用于调用构造方法、私有方法、父类方法和超接口方法,这些方法都是在编译期间就已经确定的,因此invokespecial指令会跳过虚拟机运行时的查找过程,直接调用目标方法。在Java语言中,使用super关键字调用父类构造方法、父类私有方法以及父类中被子类覆盖的方法时,都会被翻译成invokespecial指令。
    而invokevirtual指令用于调用虚方法,即在运行时根据对象的类型动态查找目标方法并调用。因为虚方法的具体实现只能在运行时才能确定,所以invokevirtual指令需要在运行时查找目标方法。在Java语言中,使用子类对象调用父类方法时,会被翻译成invokevirtual指令。
    因此,如果需要调用私有方法、构造方法、父类方法或者超接口方法,应该使用invokespecial指令,而如果需要调用虚方法,则应该使用invokevirtual指令。如果错误地使用了invokevirtual指令调用了构造方法、私有方法或者父类方法,则可能会导致运行时异常。

来源:chatgpt

指令表

各种指令可以查看不同版本Java Virtual Machine Specification的第二章,Instruction Set Summary部分。(这里以Java1.8举例)

指令 含义
iadd int加法
ladd long加法
fadd float加法
dadd double加法

Thanks:

主要:如何阅读 Java 字节码(Byte Code)
【译】如何阅读 Java 字节码
Java 虚拟机(一):Java 字节码
JVM 内部原理(六)— Java 字节码基础之一

posted @ 2023-06-25 13:01  holdonly  阅读(58)  评论(0)    收藏  举报