0
点赞
收藏
分享

微信扫一扫

Java 进阶之字节码剖析

花海书香 2022-08-12 阅读 77

Java 进阶之字节码剖析

JVM 内存结构

JVM 在内存中主要分为「栈」,「堆」,「非堆」以及 JVM 自身,堆主要用来分配类实例和数组,非堆包括「方法区」、「JVM内部处理或优化所需的内存(如JIT编译后的代码缓存)」、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法的代码

我们主要关注栈,我们知道线程是 cpu 调度的最小单位,在 JVM 中一旦创建一个线程,就会为其分配一个线程栈,线程会调用一个个方法,每个方法都会对应一个个的栈帧压到线程栈里,JVM 中的栈内存结构如下

Java 进阶之字节码剖析

JVM 栈内存结构

至此我们总算接近 JVM 执行的真相了,JVM 是以栈帧为单位执行的,栈帧由以下四个部分组成

  • 返回值

  • 局部变量表(Local Variables):存储方法用到的本地变量

  • 动态链接:在字节码中,所有的变量和方法都是以符号引用的形式保存在 class 文件的常量池中的,比如一个方法调用另外的方法,是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用,这么说可能有人还是不理解,所以我们先执行一下?javap -verbose Demo.class?命令来查看一下字节码中的常量池是咋样的

Java 进阶之字节码剖析

注意:以上只列出了常量池中的?部分符号引用

可以看到 Object 的 init 方法是由 #4.#16 表示的,而 #4 又指向了 #19,#19 表示 Object,#16 又指向了 #7.#8,#7 指向了方法名,#8 指向了 ()V(表示方法的返回值为 void,且无方法参数),字节码加载后,会把类信息加载到元空间(Java 8 以后)中的方法区中,动态链接会把这些符号引用替换为调用方法的直接引用,如下图示

Java 进阶之字节码剖析

那为什么要提供动态链接呢,通过上面这种方式绕了好几个弯才定位到具体的执行方法,效率不是低了很多吗,其实?主要是为了支持 Java 的多态?,比如我们声明一个?Father f = new Son()?这样的变量,但执行 f.method() 的时候会绑定到 son 的 method(如果有的话),这就是用到了动态链接的技术,在运行时才能定位到具体该调用哪个方法,动态链接也称为后期绑定,与之相对的是静态链接(也称为前期绑定),即在编译期和运行期对象的方法都保持不变,静态链接发生在编译期,也就是说在程序执行前方法就已经被绑定,?java 当中的方法只有final、static、private和构造方法是前期绑定的?。而动态链接发生在运行时,?几乎所有的方法都是运行时绑定的

举个例子来看看两者的区别,一目了解

class Animal{

public void eat(){

System.out.println("动物进食");

}

}

class Cat extends Animal{

@Override

public void eat() {

super.eat();//表现为早期绑定(静态链接)

System.out.println("猫进食");

}

}

public class AnimalTest {

public void showAnimal(Animal animal){

animal.eat();//表现为晚期绑定(动态链接)

}

}

  • 操作数栈(Operand Stack)?:程序主要由指令和操作数组成,指令用来说明这条操作做什么,比如是做加法还是乘法,操作数就是指令要执行的数据,那么指令怎么获取数据呢,指令集的架构模型分为?基于栈的指令集架构?和?基于寄存器的指令集架构?两种,JVM 中的指令集属于前者,也就是说任何操作都是用栈来管理,基于栈指令可以更好地实现跨平台,栈都是是在内存中分配的,而寄存器往往和硬件挂钩,不同的硬件架构是不一样的,不利于跨平台,当然基于栈的指令集架构缺点也很明显,基于栈的实现需要更多指令才能完成(因为栈只是一个FILO结构,需要频繁压栈出栈),而寄存器是在CPU的高速缓存区,相较而言,?基于栈的速度要慢不少?,这也是为了跨平台而做出的一点性能牺牲,毕竟鱼和熊掌不可兼得。

Java 字节码技术简介

注意线程中还有一个「PC 程序计数器」,是每个线程独有的,记录着当前线程所执行的字节码的行号指示器,也就是指向下一条指令的地址,也就是将执行的指令代码。由执行引擎读取下一条指令。我们先来看下看一下字节码长啥样。假设我们有以下 Java 代码

package com.mahai;

public class Demo {

private int a = 1;

public static void foo() {

int a = 1;

int b = 2;

int c = (a + b) * 5;

}

}

执行 javac Demo.java 后可以看到其字节码如下

Java 进阶之字节码剖析

字节码是给 JVM 看的,所以我们需要将其翻译成人能看懂的代码,好在 JDK 提供了反解析工具 javap ,可以根据字节码反解析出 code 区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。我们执行以下命令来看下根据字节码反解析的文件长啥样(更详细的信息可以执行 javap -verbose 命令,在本例中我们重点关注 Code 区是如何执行的,所以使用了 javap -c 来执行

javap -c Demo.class

Java 进阶之字节码剖析

转换成这种形式可读性强了很多,那么aload_0,invokespecial 这些表示什么含义呢, javap 是怎么根据字节码来解析出这些指令出来的呢

首先我们需要明白什么是指令,?指令=操作码+操作数?,操作码表示这条指令要做什么,比如加减乘除,操作数即操作码操作的数,比如 1+ 2 这条指令,操作码其实是加法,1,2 为操作数,在 Java 中每个操作码都由一个字节表示,每个操作码都有对应类似 aload_0,invokespecial,iconst_1 这样的助记符,有些操作码本来就包含着操作数,

Java 进阶之字节码剖析

比如字节码 0x04 对应的助记符为 iconst_1, 表示 将 int 型 1 推送至栈顶,这些操作码就相当于指令,而有些操作码需要配合操作数才能形成指令,如字节码 0x10 表示 bipush,后面需要跟着一个操作数,表示?将单字节的常量值(-128~127)推送至栈顶?。以下为列出的几个字节码与助记符示例

| 字节码 | 助记符 | 表示含义 |

| --- | --- | --- |

| 0x04 | iconst_1 | 将int型1推送至栈顶 |

| 0xb7 | invokespecial | 调用超类构建方法, 实例初始化方法, 私有方法 |

| 0x1a | iload_0 | 将第一个int型本地变量推送至栈顶 |

| 0x10 | bipush | 将单字节的常量值(-128~127)推送至栈顶 |

至此我们不难明白 javap ?的作用了,它主要就是找到字节码对应的的助记符然后再展示在我们面前的,我们简单看下上述的默认构造方法是如何根据字节码映射成助记符并最终呈现在我们面前的:

Java 进阶之字节码剖析

最左边的数字是 Code 区中每个字节的偏移量,这个是保存在 PC 的程序计数中的,比如如果当前指令指向 1,下一条就指向 4

另外大家不难发现,在源码中其实我们并没有定义默认构造函数,但在字节码中却生成了,而且你会发现我们在源码中定义了?private int a = 1;?但这个变量赋值的操作却是在构造方法中执行的(下文会分析到),这就是理解字节码的意义:?它可以反映 JVM 执行程序的真正逻辑?,而源码只是表象,要深入分析还得看字节码!

接下来我们就来瞧一瞧构造方法对应的指令是如何执行的,首先我们来看一下在 JVM 中指令是怎么执行的。

  1. 首先 JVM 会为每个方法分配对应的局部变量表,可以认为它是一个数组,每个坑位(我们称为 slot)为方法中分配的变量,如果是实例方法,这些局部变量可以是 this, 方法参数,方法里分配的局部变量,这些局部变量的类型即我们熟知的 int,long 等八大基本,还有引用,返回地址,每个 slot 为 4 个字节,所以像 Long , Double 这种 8 个字节的要占用 2 个 slot, 如果这个方法为实例方法,则第一个 slot 为 this 指针, 如果是静态方法则没有 this 指针

  2. 分配好局部变量表后,方法里如果涉及到赋值,加减乘除等操作,那么这些指令的运算就需要依赖于操作数栈了,将这些指令对应的操作数通过压栈,弹栈来完成指令的执行

比如有?int i = 69?这样的指令,对应的字码节指令如下

0:bipush 69

2:istore_0

其在内存中的操作过程如下

Java 进阶之字节码剖析

可以看到主要分两步:第一步首先把 69 这个 int 值压栈,然后再弹栈,把 69 弹出放到局部变量表 i 对应的位置,istore_0 表示弹栈,将其从操作数栈中弹出整型数字存储到本地变量中,0 表示本地变量在局部变量表的第 0 个 slot

理解了上面这个操作,我们再来看一下默认构造函数对应的字节码指令是如何执行的

Java 进阶之字节码剖析

首先我们需要先来理解一下上面几个指令

  • aload_0:从局部变量表中加载第 0 个 slot 中的对象引用到操作数栈的栈顶,这里的 0 表示第 0 个位置,也就是 this

  • invokespecial:用来调用构造函数,但也可以用于调用同一个类中的 private 方法, 以及 可见的超类方法,在此例中表示调用父类的构造器(因为 #1 符号引用指向对应的 init 方法)

  • iconst_1:将 int 型 1推送至栈顶

  • putfield:它接受一个操作数,这个操作数引用的是运行时常量池里的一个字段,在这里这个字段是 a。赋给这个字段的值,以及包含这个字段的对象引用,在执行这条指令的时候,都会从操作数栈顶上 pop 出来。前面的 aload_0 指令已经把包含这个字段的对象(this)压到操作数栈上了,而后面的 iconst_1 又把 1 压到栈里。最后 putfield 指令会将这两个值从栈顶弹出。执行完的结果就是这个对象的 a 这个字段的值更新成了 1。

接下来我们来详细解释以上以上助记符代表的含义

  • 第一条命令 aload_0,表示从局部变量表中加载第 0 个 slot 中的对象引用到操作数栈的栈顶,也就是将 this 加载到栈顶,如下

Java 进阶之字节码剖析

  • 第二步 invokespecial #1,表示弹栈并且执行 #1 对应的方法,#1 代表的含义可以从旁边的解释(?# Method java/lang/Object."?":()V?)看出,即调用父类的初始化方法,这也印证了那句话:?子类初始化时会从初始化父类

  • 之后的命令?aload_0?,?iconst_1?,?putfied #2?图解如下

Java 进阶之字节码剖析

可能有人有些奇怪,上述?6: putfield #2?命令中的 #2 怎么就代表 Demo 的私有成员 a 了,这就涉及到字节码中的常量池概念了,我们执行?javap -verbose path/Demo.class?可以看到这些字面量代表的含义,#1,#2 这种数字形式的表示形式也被称为符号引用,程序运行期会将符号引用转换为直接引用

Java 进阶之字节码剖析

由此可知 #2 代表 Demo 类的 a 属性,如下

Java 进阶之字节码剖析

从最终的叶子节点可以看出?#2?最终代表的是 Demo?类中类型为 int(I 代表 int 代表 int 类型),名称为?a 的变量

我们再来用动图看一下 foo 的执行流程,相信你现在能理解其含义了

举报

相关推荐

0 条评论