Java 进阶之字节码剖析

互联网全栈架构

共 7538字,需浏览 16分钟

 ·

2021-12-18 20:17

前言

你好,我是坤哥

从今天起我打算整一个 Java 系列的进阶基础文章,万丈高楼平地起,打好基础我们才能走得更好,举个例子,之前我在武哥的 Kafka 文章中看到这样的一句话「除此之外,页缓存(pageCache)还有一个巨大的优势。用过 Java 的人都知道:如果不用页缓存,而是用 JVM 进程中的缓存,对象的内存开销非常大(通常是真实数据大小的几倍甚至更多)」,如果你不了解 Java 对象的表示,看到这样的话会一脸懵逼:对象的开销到底有多巨大,反过来看,如果你掌握了 Java 中的对象布局,GC,NIO 等原理,理解这些框架的原理及其设计思路就不是什么难事

另一个让我下决心写这个系列的原因是经常有一些读者问一些学习路线的事,之前我写过一些大纲,但没有从点的层面展开,所以这次准备从点的思路来将各个知识点细细道来,然后再整理成 pdf,这样之后如果有人再问起,直接把这个 pdf 扔给他们就完事了 ^_^,欢迎大家加我微信:geekoftaste 一起探讨

每个系列都会以图文并茂的方式来讲解,做到深入浅出,举个例子,上面我们说了对象的开销很大,到底有多大呢,我会用图解的方式来带你一步步分析,看完后相信你会明白为什么 int[128][2] ,int[256] 这两个数组看起来一样,但实际上前者比后者多了 246% 的额外开销,再比如我们都知道 Eden 区或 tenured(老年代区)满了会触发 yong gc 或 old gc,不过导致 gc 停顿时间过长的原因其实有挺多的,如果你看完我总结的这些通用的思路,相信你就能根据这套理论来快速地排查问题了,这个系列干货很多,相信对提升大家的 Java 内功有不少帮助,记得得文末点赞支持一下哦 ^_^

Java 系列大纲如下:

本篇我们先来学习下字节码 ,毕竟这是 Java 能跨平台的根本原因,而且通过了解字节码也可以彻底揭开 JVM 运行程序的秘密,整体会用问答的形式来讲解

能否简单介绍一下 Java 的特性

Java 是一门面向对象静态类型的语言,具有跨平台的特点,与 C,C++ 这些需要手动管理内存,编译型的语言不同,它是解释型的,具有跨平台和自动垃圾回收的特点,那么它的跨平台到底是怎么实现的呢?

我们知道计算机只能识别二进制代码表示的机器语言,所以不管用的什么高级语言,最终都得翻译成机器语言才能被 CPU 识别并执行,对于 C++这些编译型语言来说是直接一步到位转为相应平台的可执行文件(即机器语言指令),而对 Java 来说,则首先由编译器将源文件编译成字节码,再在运行时由虚拟机(JVM)解释成机器指令来执行,我们可以看下下图

也就是说 Java 的跨平台其实是通过先生成字节码,再由针对各个平台实现的 JVM 来解释执行实现的,JVM 屏蔽了 OS 的差异,我们知道 Java 工程都是以 Jar 包分发(一堆 class 文件的集合体)部署的,这就意味着 jar 包可以在各个平台上运行(由相应平台的 JVM 解释执行即可),这就是 Java 能实现跨平台的原因所在

这也是为什么 JVM 能运行 Scala、Groovy、Kotlin 这些语言的原因,并不是 JVM 直接来执行这些语言,而是这些语言最终都会生成符合 JVM 规范的字节码再由 JVM 执行,不知你是否注意到,使用字节码也利用了计算机科学中的分层理念,通过加入字节码这样的中间层,有效屏蔽了与上层的交互差异。

JVM 是怎么执行字节码的

在此之前我们先来看下 JVM 的整体内存结构,对其有一个宏观的认识,然后再来看 JVM 是如何执行字节码的

JVM 内存结构

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

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

JVM 栈内存结构

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

  • 返回值

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

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

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

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

那为什么要提供动态链接呢,通过上面这种方式绕了好几个弯才定位到具体的执行方法,效率不是低了很多吗,其实主要是为了支持 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 后可以看到其字节码如下

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

javap -c Demo.class

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

首先我们需要明白什么是指令,指令=操作码+操作数,操作码表示这条指令要做什么,比如加减乘除,操作数即操作码操作的数,比如 1+ 2 这条指令,操作码其实是加法,1,2 为操作数,在 Java 中每个操作码都由一个字节表示,每个操作码都有对应类似 aload_0,invokespecial,iconst_1 这样的助记符,有些操作码本来就包含着操作数,比如字节码 0x04 对应的助记符为 iconst_1, 表示 将 int 型 1 推送至栈顶,这些操作码就相当于指令,而有些操作码需要配合操作数才能形成指令,如字节码 0x10 表示 bipush,后面需要跟着一个操作数,表示 将单字节的常量值(-128~127)推送至栈顶。以下为列出的几个字节码与助记符示例

字节码助记符表示含义
0x04iconst_1将int型1推送至栈顶
0xb7invokespecial调用超类构建方法, 实例初始化方法, 私有方法
0x1aiload_0将第一个int型本地变量推送至栈顶
0x10bipush将单字节的常量值(-128~127)推送至栈顶

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

最左边的数字是 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

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

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

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

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

  • 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 加载到栈顶,如下


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

  • 之后的命令  aload_0iconst_1putfied #2 图解如下

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

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

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

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

唯一需要注意的此例中的 foo 是个静态方法,所以局部变量区是没有 this 的。

相信你不难发现 JVM 执行字节码的流程与 CPU 执行机器码步骤如出一辙,都经历了「取指令」,「译码」,「执行」,「存储计算结果」这四步,首先程序计数器指向下一条要执行的指令,然后 JVM 获取指令,由本地执行引擎将字节码操作数转成机器码(译码)执行,执行后将值存储到局部变量区(存储计算结果)中

最后关于字节码我推荐两款工具

  • 一个是 Hex Fiend,一款很好的十六进制编辑器,可以用来查看编辑字节码

  • 一款是 Intellij Idea 的插件 jclasslib Bytecode viewer,能为你展示 javap -verbose 命令对应的常量池,接口, Code 等数据,非常的直观,对于分析字节码非常有帮忙,如下

下一篇我们继续聊字节码是如何被加载的


推荐阅读:

【硬核】秒杀活动技术方案中的Redis

5 分钟复现 log4J 漏洞,手把手实现

为什么CTO不写代码,还这么牛逼?

面试官:HashMap有几种遍历方法?推荐使用哪种?

如出一辙。。。


互联网全栈架构

浏览 19
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报