常用的jvm虚拟机参数(深入理解JVM虚拟机)

参考文档:

深入理解JVM虚拟机——JVM运行时栈结构和方法调用

深入理解JVM虚拟机——JVM是怎么实现invokedynamic的

深入理解JVM虚拟机——类的加载机制深入理解JVM虚拟机——JIT编译器

开篇

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(1)

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

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

JVM 是怎么执行字节码的

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(2)

JVM 内存结构

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(3)

JVM 栈内存结构

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(4)

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(5)

那为什么要提供动态链接呢,通过上面这种方式绕了好几个弯才定位到具体的执行方法,效率不是低了很多吗,其实主要是为了支持 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的高速缓存区,相较而言,基于栈的速度要慢不少,这也是为了跨平台而做出的一点性能牺牲,毕竟鱼和熊掌不可兼得。
1 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虚拟机参数(深入理解JVM虚拟机)(6)

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

javap -c Demo.class

常用的jvm虚拟机参数(深入理解JVM虚拟机)(7)

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

首先我们需要明白什么是指令,指令=操作码 操作数,操作码表示这条指令要做什么,比如加减乘除,操作数即操作码操作的数,比如 1 2 这条指令,操作码其实是加法,1,2 为操作数,在 Java 中每个操作码都由一个字节表示,每个操作码都有对应类似 aload_0,invokespecial,iconst_1 这样的助记符,有些操作码本来就包含着操作数,比如字节码 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 的作用了,它主要就是找到字节码对应的的助记符然后再展示在我们面前的,我们简单看下上述的默认构造方法是如何根据字节码映射成助记符并最终呈现在我们面前的:

常用的jvm虚拟机参数(深入理解JVM虚拟机)(8)

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(9)

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(10)

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(11)

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(12)

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(13)

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(14)

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

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

常用的jvm虚拟机参数(深入理解JVM虚拟机)(15)

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

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

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

  • 一个是 Hex Fiend,一款很好的十六进制编辑器,可以用来查看编辑字节码
  • 一款是 Intellij Idea 的插件 jclasslib Bytecode viewer,能为你展示 javap -verbose 命令对应的常量池,接口, Code 等数据,非常的直观,对于分析字节码非常有帮忙,如下

常用的jvm虚拟机参数(深入理解JVM虚拟机)(16)

2. 获取字节码指令清单

可以用 javap 工具来获取 class 文件中的指令清单。 javap 是标准 JDK 内置的一款工具, 专门用于反编译 class 文件。

让我们从头开始, 先创建一个简单的类,后面再慢慢扩充。

public class HelloByteCode { public static void main(String[] args) { HelloByteCode obj = new HelloByteCode(); } }

代码很简单, main 方法中 new 了一个对象而已。然后我们编译这个类:

javac demo/jvm0104/HelloByteCode.java

使用 javac 编译 ,或者在 IDEA 或者 Eclipse 等集成开发工具自动编译,基本上是等效的。只要能找到对应的 class 即可。

javac 不指定 -d 参数编译后生成的 .class 文件默认和源代码在同一个目录。

注意: javac 工具默认开启了优化功能, 生成的字节码中没有局部变量表(LocalVariableTable),相当于局部变量名称被擦除。如果需要这些调试信息, 在编译时请加上 -g 选项。有兴趣的同学可以试试两种方式的区别,并对比结果。

JDK 自带工具的详细用法, 请使用: javac -help 或者 javap -help 来查看; 其他类似。

然后使用 javap 工具来执行反编译, 获取字节码清单:

javap -c demo.jvm0104.HelloByteCode # 或者: javap -c demo/jvm0104/HelloByteCode javap -c demo/jvm0104/HelloByteCode.class

javap 还是比较聪明的, 使用包名或者相对路径都可以反编译成功, 反编译后的结果如下所示:

Compiled from "HelloByteCode.java" public class demo.jvm0104.HelloByteCode { public demo.jvm0104.HelloByteCode(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: new #2 // class demo/jvm0104/HelloByteCode 3: dup 4: invokespecial #3 // Method "<init>":()V 7: astore_1 8: return }

OK,我们成功获取到了字节码清单, 下面进行简单的解读。

3 解读字节码清单

可以看到,反编译后的代码清单中, 有一个默认的构造函数 public demo.jvm0104.HelloByteCode(), 以及 main 方法。

刚学 Java 时我们就知道, 如果不定义任何构造函数,就会有一个默认的无参构造函数,这里再次验证了这个知识点。好吧,这比较容易理解!我们通过查看编译后的 class 文件证实了其中存在默认构造函数,所以这是 Java 编译器生成的, 而不是运行时JVM自动生成的。

自动生成的构造函数,其方法体应该是空的,但这里看到里面有一些指令。为什么呢?

再次回顾 Java 知识, 每个构造函数中都会先调用 super 类的构造函数对吧? 但这不是 JVM 自动执行的, 而是由程序指令控制,所以默认构造函数中也就有一些字节码指令来干这个事情。

基本上,这几条指令就是执行 super() 调用;

public demo.jvm0104.HelloByteCode(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return

至于其中解析的 java/lang/Object 不用说, 默认继承了 Object 类。这里再次验证了这个知识点,而且这是在编译期间就确定了的。

继续往下看 c,

public static void main(java.lang.String[]); Code: 0: new #2 // class demo/jvm0104/HelloByteCode 3: dup 4: invokespecial #3 // Method "<init>":()V 7: astore_1 8: return

main 方法中创建了该类的一个实例, 然后就 return 了, 关于里面的几个指令, 稍后讲解。

4 查看 class 文件中的常量池信息

常量池 大家应该都听说过, 英文是 Constant pool。这里做一个强调: 大多数时候指的是 运行时常量池。但运行时常量池里面的常量是从哪里来的呢? 主要就是由 class 文件中的 常量池结构体 组成的。

要查看常量池信息, 我们得加一点魔法参数:

javap -c -verbose demo.jvm0104.HelloByteCode

在反编译 class 时,指定 -verbose 选项, 则会 输出附加信息。

结果如下所示:

ClassFile /XXXXXXX/demo/jvm0104/HelloByteCode.class Last modified 2019-11-28; size 301 bytes MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308 Compiled from "HelloByteCode.java" public class demo.jvm0104.HelloByteCode minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V #2 = Class #14 // demo/jvm0104/HelloByteCode #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V #4 = Class #15 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 main #10 = Utf8 ([Ljava/lang/String;)V #11 = Utf8 SourceFile #12 = Utf8 HelloByteCode.java #13 = NameAndType #5:#6 // "<init>":()V #14 = Utf8 demo/jvm0104/HelloByteCode #15 = Utf8 java/lang/Object { public demo.jvm0104.HelloByteCode(); descriptor: ()V flags: 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 3: 0 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: new #2 // class demo/jvm0104/HelloByteCode 3: dup 4: invokespecial #3 // Method "<init>":()V 7: astore_1 8: return LineNumberTable: line 5: 0 line 6: 8 } SourceFile: "HelloByteCode.java"

其中显示了很多关于 class 文件信息: 编译时间, MD5 校验和, 从哪个 .java 源文件编译得来,符合哪个版本的 Java 语言规范等等。

还可以看到 ACC_PUBLIC 和 ACC_SUPER 访问标志符。 ACC_PUBLIC 标志很容易理解:这个类是 public 类,因此用这个标志来表示。

但 ACC_SUPER 标志是怎么回事呢? 这就是历史原因, JDK 1.0 的 BUG 修正中引入 ACC_SUPER 标志来修正 invokespecial 指令调用 super 类方法的问题,从 Java 1.1 开始, 编译器一般都会自动生成ACC_SUPER 标志。

有些同学可能注意到了, 好多指令后面使用了 #1, #2, #3 这样的编号。

这就是对常量池的引用。 那常量池里面有些什么呢?

Constant pool: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V #2 = Class #14 // demo/jvm0104/HelloByteCode #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V #4 = Class #15 // java/lang/Object #5 = Utf8 <init> ......

这是摘取的一部分内容, 可以看到常量池中的常量定义。还可以进行组合, 一个常量的定义中可以引用其他常量。

比如第一行: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V, 解读如下:

  • #1 常量编号, 该文件中其他地方可以引用。
  • = 等号就是分隔符.
  • Methodref 表明这个常量指向的是一个方法;具体是哪个类的哪个方法呢? 类指向的 #4, 方法签名指向的 #13; 当然双斜线注释后面已经解析出来可读性比较好的说明了。

同学们可以试着解析其他的常量定义。 自己实践加上知识回顾,能有效增加个人的记忆和理解。

总结一下,常量池就是一个常量的大字典,使用编号的方式把程序里用到的各类常量统一管理起来,这样在字节码操作里,只需要引用编号即可。

5 查看方法信息

在 javap 命令中使用 -verbose 选项时, 还显示了其他的一些信息。 例如, 关于 main 方法的更多信息被打印出来:

public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1

可以看到方法描述: ([Ljava/lang/String;)V:

  • 其中小括号内是入参信息/形参信息;
  • 左方括号表述数组;
  • L 表示对象;
  • 后面的java/lang/String就是类名称;
  • 小括号后面的 V 则表示这个方法的返回值是 void;
  • 方法的访问标志也很容易理解 flags: ACC_PUBLIC, ACC_STATIC,表示 public 和 static。

还可以看到执行该方法时需要的栈(stack)深度是多少,需要在局部变量表中保留多少个槽位, 还有方法的参数个数: stack=2, locals=2, args_size=1。把上面这些整合起来其实就是一个方法:

public static void main(java.lang.String[]);

注:实际上我们一般把一个方法的修饰符 名称 参数类型清单 返回值类型,合在一起叫“方法签名”,即这些信息可以完整的表示一个方法。

稍微往回一点点,看编译器自动生成的无参构造函数字节码:

public demo.jvm0104.HelloByteCode(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return

你会发现一个奇怪的地方, 无参构造函数的参数个数居然不是 0: stack=1, locals=1, args_size=1。 这是因为在 Java 中, 如果是静态方法则没有 this 引用。 对于非静态方法, this 将被分配到局部变量表的第 0 号槽位中, 关于局部变量表的细节,下面再进行介绍。

有反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args); 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);

6 线程栈与字节码执行模型

想要深入了解字节码技术,我们需要先对字节码的执行模型有所了解。

JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧(Frame)。每一次方法调用,JVM都会自动创建一个栈帧。栈帧 由 操作数栈, 局部变量数组 以及一个class 引用组成。class 引用 指向当前方法在运行时常量池中对应的 class)。

我们在前面反编译的代码中已经看到过这些内容。

常用的jvm虚拟机参数(深入理解JVM虚拟机)(17)

局部变量数组 也称为 局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量 形参的个数有关,还要看每个变量/参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。

有一些操作码/指令可以将值压入“操作数栈”; 还有一些操作码/指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。

7 方法体中的字节码解读

看过前面的示例,细心的同学可能会猜测,方法体中那些字节码指令前面的数字是什么意思,说是序号吧但又不太像,因为他们之间的间隔不相等。看看 main 方法体对应的字节码:

0: new #2 // class demo/jvm0104/HelloByteCode 3: dup 4: invokespecial #3 // Method "<init>":()V 7: astore_1 8: return

间隔不相等的原因是, 有一部分操作码会附带有操作数, 也会占用字节码数组中的空间。

例如, new 就会占用三个槽位: 一个用于存放操作码指令自身,两个用于存放操作数。

因此,下一条指令 dup 的索引从 3 开始。

常用的jvm虚拟机参数(深入理解JVM虚拟机)(18)

每个操作码/指令都有对应的十六进制(HEX)表示形式, 如果换成十六进制来表示,则方法体可表示为HEX字符串。例如上面的方法体百世成十六进制如下所示:

常用的jvm虚拟机参数(深入理解JVM虚拟机)(19)

甚至我们还可以在支持十六进制的编辑器中打开 class 文件,可以在其中找到对应的字符串:

常用的jvm虚拟机参数(深入理解JVM虚拟机)(20)

(此图由开源文本编辑软件Atom的hex-view插件生成)

粗暴一点,我们可以通过 HEX 编辑器直接修改字节码,尽管这样做会有风险, 但如果只修改一个数值的话应该会很有趣。

其实要使用编程的方式,方便和安全地实现字节码编辑和修改还有更好的办法,那就是使用 ASM 和 Javassist 之类的字节码操作工具,也可以在类加载器和 Agent 上面做文章,下一节课程会讨论 类加载器,其他主题则留待以后探讨。

4.8 对象初始化指令:new 指令, init 以及 clinit 简介

我们都知道 new是 Java 编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码:

0: new #2 // class demo/jvm0104/HelloByteCode 3: dup 4: invokespecial #3 // Method "<init>":()V

当你同时看到 new, dup 和 invokespecial 指令在一起时,那么一定是在创建类的实例对象!

为什么是三条指令而不是一条呢?这是因为:

  • new 指令只是创建对象,但没有调用构造函数。
  • invokespecial 指令用来调用某些特殊方法的, 当然这里调用的是构造函数。
  • dup 指令用于复制栈顶的值。

由于构造函数调用不会返回值,所以如果没有 dup 指令, 在对象上调用方法并初始化之后,操作数栈就会是空的,在初始化之后就会出问题, 接下来的代码就无法对其进行处理。

这就是为什么要事先复制引用的原因,为的是在构造函数返回之后,可以将对象实例赋值给局部变量或某个字段。因此,接下来的那条指令一般是以下几种:

  • astore {N} or astore_{N} – 赋值给局部变量,其中 {N} 是局部变量表中的位置。
  • putfield – 将值赋给实例字段
  • putstatic – 将值赋给静态字段

在调用构造函数的时候,其实还会执行另一个类似的方法 <init> ,甚至在执行构造函数之前就执行了。

还有一个可能执行的方法是该类的静态初始化方法 <clinit>, 但 <clinit> 并不能被直接调用,而是由这些指令触发的: new, getstatic, putstatic or invokestatic。

也就是说,如果创建某个类的新实例, 访问静态字段或者调用静态方法,就会触发该类的静态初始化方法【如果尚未初始化】。

实际上,还有一些情况会触发静态初始化, 详情请参考 JVM 规范: [http://docs.oracle.com/javase/specs/jvms/se8/html/]

4.9 栈内存操作指令

有很多指令可以操作方法栈。 前面也提到过一些基本的栈操作指令: 他们将值压入栈,或者从栈中获取值。 除了这些基础操作之外也还有一些指令可以操作栈内存; 比如 swap 指令用来交换栈顶两个元素的值。下面是一些示例:

最基础的是 dup 和 pop 指令。

  • dup 指令复制栈顶元素的值。
  • pop 指令则从栈中删除最顶部的值。

还有复杂一点的指令:比如,swap, dup_x1 和 dup2_x1。

  • 顾名思义,swap 指令可交换栈顶两个元素的值,例如A和B交换位置(图中示例4);
  • dup_x1 将复制栈顶元素的值,并在栈顶插入两次(图中示例5);
  • dup2_x1 则复制栈顶两个元素的值,并插入第三个值(图中示例6)。

常用的jvm虚拟机参数(深入理解JVM虚拟机)(21)

dup_x1 和 dup2_x1 指令看起来稍微有点复杂。而且为什么要设置这种指令呢? 在栈中复制最顶部的值?

请看一个实际案例:怎样交换 2 个 double 类型的值?

需要注意的是,一个 double 值占两个槽位,也就是说如果栈中有两个 double 值,它们将占用 4 个槽位。

要执行交换,你可能想到了 swap 指令,但问题是 swap 只适用于单字(one-word, 单字一般指 32 位 4 个字节,64 位则是双字),所以不能处理 double 类型,但 Java 中又没有 swap2 指令。

怎么办呢? 解决方法就是使用 dup2_x2 指令,将操作数栈顶部的 double 值,复制到栈底 double 值的下方, 然后再使用 pop2 指令弹出栈顶的 double 值。结果就是交换了两个 double 值。 示意图如下图所示:

常用的jvm虚拟机参数(深入理解JVM虚拟机)(22)

dup、dup_x1、dup2_x1指令补充说明

指令的详细说明可参考 JVM 规范:

dup 指令

官方说明是:复制栈顶的值,并将复制的值压入栈。

操作数栈的值变化情况(方括号标识新插入的值):

..., value → ..., value [,value]

dup_x1 指令

官方说明是:复制栈顶的值,并将复制的值插入到最上面 2 个值的下方。

操作数栈的值变化情况(方括号标识新插入的值):

..., value2, value1 → ..., [value1,] value2, value1

dup2_x1 指令

官方说明是:复制栈顶 1 个 64 位/或 2 个 32 位的值, 并将复制的值按照原始顺序,插入原始值下面一个 32 位值的下方。

操作数栈的值变化情况(方括号标识新插入的值):

# 情景 1: value1, value2, and value3 都是分组 1 的值(32 位元素) ..., value3, value2, value1 → ..., [value2, value1,] value3, value2, value1 # 情景 2: value1 是分组 2 的值(64 位,long 或double), value2 是分组 1 的值(32 位元素) ..., value2, value1 → ..., [value1,] value2, value1

Table 2.11.1-B 实际类型与 JVM 计算类型映射和分组

实际类型

JVM 计算类型

类型分组

boolean

int

1

byte

int

1

char

int

1

short

int

1

int

int

1

float

float

1

reference

reference

1

returnAddress

returnAddress

1

long

long

2

double

double

2

4.10 局部变量表

stack 主要用于执行指令,而局部变量则用来保存中间结果,两者之间可以直接交互。

让我们编写一个复杂点的示例:

第一步,先编写一个计算移动平均数的类:

package demo.jvm0104; //移动平均数 public class MovingAverage { private int count = 0; private double sum = 0.0D; public void submit(double value){ this.count ; this.sum = value; } public double getAvg(){ if(0 == this.count){ return sum;} return this.sum/this.count; } }

第二步,然后写一个类来调用:

package demo.jvm0104; public class LocalVariableTest { public static void main(String[] args) { MovingAverage ma = new MovingAverage(); int num1 = 1; int num2 = 2; ma.submit(num1); ma.submit(num2); double avg = ma.getAvg(); } }

其中 main 方法中向 MovingAverage 类的实例提交了两个数值,并要求其计算当前的平均值。

然后我们需要编译(还记得前面提到, 生成调试信息的 -g 参数吗)。

javac -g demo/jvm0104/*.java

然后使用 javap 反编译:

javap -c -verbose demo/jvm0104/LocalVariableTest

看 main 方法对应的字节码:

public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=6, args_size=1 0: new #2 // class demo/jvm0104/MovingAverage 3: dup 4: invokespecial #3 // Method demo/jvm0104/MovingAverage."<init>":()V 7: astore_1 8: iconst_1 9: istore_2 10: iconst_2 11: istore_3 12: aload_1 13: iload_2 14: i2d 15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V 18: aload_1 19: iload_3 20: i2d 21: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V 24: aload_1 25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D 28: dstore 4 30: return LineNumberTable: line 5: 0 line 6: 8 line 7: 10 line 8: 12 line 9: 18 line 10: 24 line 11: 30 LocalVariableTable: Start Length Slot Name Signature 0 31 0 args [Ljava/lang/String; 8 23 1 ma Ldemo/jvm0104/MovingAverage; 10 21 2 num1 I 12 19 3 num2 I 30 1 4 avg D


  • 编号 0 的字节码 new, 创建 MovingAverage 类的对象;
  • 编号 3 的字节码 dup 复制栈顶引用值。
  • 编号 4 的字节码 invokespecial 执行对象初始化。
  • 编号 7 开始, 使用 astore_1 指令将引用地址值(addr.)存储(store)到编号为1的局部变量中: astore_1 中的 1 指代 LocalVariableTable 中ma对应的槽位编号,
  • 编号8开始的指令: iconst_1 和 iconst_2 用来将常量值1和2加载到栈里面, 并分别由指令 istore_2 和 istore_3 将它们存储到在 LocalVariableTable 的槽位 2 和槽位 3 中。

8: iconst_1 9: istore_2 10: iconst_2 11: istore_3

请注意,store 之类的指令调用实际上从栈顶删除了一个值。 这就是为什么再次使用相同值时,必须再加载(load)一次的原因。

例如在上面的字节码中,调用 submit 方法之前, 必须再次将参数值加载到栈中:

12: aload_1 13: iload_2 14: i2d 15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V

调用 getAvg() 方法后,返回的结果位于栈顶,然后使用 dstore 将 double 值保存到本地变量4号槽位,这里的d表示目标变量的类型为double。

24: aload_1 25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D 28: dstore 4

关于 LocalVariableTable 有个有意思的事情,就是最前面的槽位会被方法参数占用。

在这里,因为 main 是静态方法,所以槽位0中并没有设置为 this 引用的地址。 但是对于非静态方法来说, this 会将分配到第 0 号槽位中。

再次提醒: 有过反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args); 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);

理解这些字节码的诀窍在于:

给局部变量赋值时,需要使用相应的指令来进行 store,如 astore_1。store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。

4.11 流程控制指令

流程控制指令主要是分支和循环在用, 根据检查条件来控制程序的执行流程。

一般是 If-Then-Else 这种三元运算符(ternary operator), Java中的各种循环,甚至异常处的理操作码都可归属于 程序流程控制。

然后,我们再增加一个示例,用循环来提交给 MovingAverage 类一定数量的值:

package demo.jvm0104; public class ForLoopTest { private static int[] numbers = {1, 6, 8}; public static void main(String[] args) { MovingAverage ma = new MovingAverage(); for (int number : numbers) { ma.submit(number); } double avg = ma.getAvg(); } }

同样执行编译和反编译:

javac -g demo/jvm0104/*.java javap -c -verbose demo/jvm0104/ForLoopTest

因为 numbers 是本类中的 static 属性, 所以对应的字节码如下所示:

0: new #2 // class demo/jvm0104/MovingAverage 3: dup 4: invokespecial #3 // Method demo/jvm0104/MovingAverage."<init>":()V 7: astore_1 8: getstatic #4 // Field numbers:[I 11: astore_2 12: aload_2 13: arraylength 14: istore_3 15: iconst_0 16: istore 4 18: iload 4 20: iload_3 21: if_icmpge 43 24: aload_2 25: iload 4 27: iaload 28: istore 5 30: aload_1 31: iload 5 33: i2d 34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V 37: iinc 4, 1 40: goto 18 43: aload_1 44: invokevirtual #6 // Method demo/jvm0104/MovingAverage.getAvg:()D 47: dstore_2 48: return LocalVariableTable: Start Length Slot Name Signature 30 7 5 number I 0 49 0 args [Ljava/lang/String; 8 41 1 ma Ldemo/jvm0104/MovingAverage; 48 1 2 avg D

位置 [8~16] 的指令用于循环控制。 我们从代码的声明从上往下看, 在最后面的LocalVariableTable 中:

  • 0 号槽位被 main 方法的参数 args 占据了。
  • 1 号槽位被 ma 占用了。
  • 5 号槽位被 number 占用了。
  • 2 号槽位是for循环之后才被 avg 占用的。

那么中间的 2,3,4 号槽位是谁霸占了呢? 通过分析字节码指令可以看出,在 2,3,4 槽位有 3 个匿名的局部变量(astore_2, istore_3, istore 4等指令)。

  • 2号槽位的变量保存了 numbers 的引用值,占据了 2号槽位。
  • 3号槽位的变量, 由 arraylength 指令使用, 得出循环的长度。
  • 4号槽位的变量, 是循环计数器, 每次迭代后使用 iinc 指令来递增。

如果我们的 JDK 版本再老一点, 则会在 2,3,4 槽位发现三个源码中没有出现的变量: arr$, len$, i$, 也就是循环变量。

循环体中的第一条指令用于执行 循环计数器与数组长度 的比较:

18: iload 4 20: iload_3 21: if_icmpge 43

这段指令将局部变量表中 4号槽位 和 3号槽位的值加载到栈中,并调用 if_icmpge 指令来比较他们的值。

【if_icmpge 解读: if, integer, compare, great equal】, 如果一个数的值大于或等于另一个值,则程序执行流程跳转到pc=43的地方继续执行。

在这个例子中就是, 如果4号槽位的值 大于或等于 3号槽位的值, 循环就结束了,这里 43 位置对于的是循环后面的代码。如果条件不成立,则循环进行下一次迭代。

在循环体执行完,它的循环计数器加 1,然后循环跳回到起点以再次验证循环条件:

37: iinc 4, 1 // 4号槽位的值加1 40: goto 18 // 跳到循环开始的地方

4.12 算术运算指令与类型转换指令

Java 字节码中有许多指令可以执行算术运算。实际上,指令集中有很大一部分表示都是关于数学运算的。对于所有数值类型(int, long, double, float),都有加,减,乘,除,取反的指令。

那么 byte 和 char, boolean 呢? JVM 是当做 int 来处理的。另外还有部分指令用于数据类型之间的转换。

算术操作码和类型

当我们想将 int 类型的值赋值给 long 类型的变量时,就会发生类型转换。

类型转换操作码

在前面的示例中, 将 int 值作为参数传递给实际上接收 double 的 submit() 方法时,可以看到, 在实际调用该方法之前,使用了类型转换的操作码:

31: iload 5 33: i2d 34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V

也就是说, 将一个 int 类型局部变量的值, 作为整数加载到栈中,然后用 i2d 指令将其转换为 double 值,以便将其作为参数传给submit方法。

唯一不需要将数值load到操作数栈的指令是 iinc,它可以直接对 LocalVariableTable 中的值进行运算。 其他的所有操作均使用栈来执行。

4.13 方法调用指令和参数传递

前面部分稍微提了一下方法调用: 比如构造函数是通过 invokespecial 指令调用的。

这里列举了各种用于方法调用的指令:

  • invokestatic,顾名思义,这个指令用于调用某个类的静态方法,这也是方法调用指令中最快的一个。
  • invokespecial, 我们已经学过了, invokespecial 指令用来调用构造函数,但也可以用于调用同一个类中的 private 方法, 以及可见的超类方法。
  • invokevirtual,如果是具体类型的目标对象,invokevirtual用于调用公共,受保护和打包私有方法。
  • invokeinterface,当要调用的方法属于某个接口时,将使用 invokeinterface 指令。

那么 invokevirtual 和 invokeinterface 有什么区别呢?这确实是个好问题。 为什么需要 invokevirtual 和 invokeinterface 这两种指令呢? 毕竟所有的接口方法都是公共方法, 直接使用 invokevirtual 不就可以了吗?

这么做是源于对方法调用的优化。JVM 必须先解析该方法,然后才能调用它。

  • 使用 invokestatic 指令,JVM 就确切地知道要调用的是哪个方法:因为调用的是静态方法,只能属于一个类。
  • 使用 invokespecial 时, 查找的数量也很少, 解析也更加容易, 那么运行时就能更快地找到所需的方法。

使用 invokevirtual 和 invokeinterface 的区别不是那么明显。想象一下,类定义中包含一个方法定义表, 所有方法都有位置编号。下面的示例中:A 类包含 method1 和 method2 方法; 子类B继承A,继承了 method1,覆写了 method2,并声明了方法 method3。

请注意,method1 和 method2 方法在类 A 和类 B 中处于相同的索引位置。

class A 1: method1 2: method2 class B extends A 1: method1 2: method2 3: method3

那么,在运行时只要调用 method2,一定是在位置 2 处找到它。

现在我们来解释invokevirtual 和 invokeinterface 之间的本质区别。

假设有一个接口 X 声明了 methodX 方法, 让 B 类在上面的基础上实现接口 X:

class B extends A implements X 1: method1 2: method2 3: method3 4: methodX

新方法 methodX 位于索引 4 处,在这种情况下,它看起来与 method3 没什么不同。

但如果还有另一个类 C 也实现了 X 接口,但不继承 A,也不继承 B:

class C implements X 1: methodC 2: methodX

类 C 中的接口方法位置与类 B 的不同,这就是为什么运行时在 invokinterface 方面受到更多限制的原因。 与 invokinterface 相比, invokevirtual 针对具体的类型方法表是固定的,所以每次都可以精确查找,效率更高(具体的分析讨论可以参见参考材料的第一个链接)。

4.14 JDK7 新增的方法调用指令 invokedynamic

Java 虚拟机的字节码指令集在 JDK7 之前一直就只有前面提到的 4 种指令(invokestatic,invokespecial,invokevirtual,invokeinterface)。随着 JDK 7 的发布,字节码指令集新增了invokedynamic指令。这条新增加的指令是实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,同时也是 JDK 8 以后支持的 lambda 表达式的实现基础。

为什么要新增加一个指令呢?

我们知道在不改变字节码的情况下,我们在 Java 语言层面想调用一个类 A 的方法 m,只有两个办法:

  • 使用A a=new A(); a.m(),拿到一个 A 类型的实例,然后直接调用方法;
  • 通过反射,通过 A.class.getMethod 拿到一个 Method,然后再调用这个Method.invoke反射调用;

这两个方法都需要显式的把方法 m 和类型 A 直接关联起来,假设有一个类型 B,也有一个一模一样的方法签名的 m 方法,怎么来用这个方法在运行期指定调用 A 或者 B 的 m 方法呢?这个操作在 JavaScript 这种基于原型的语言里或者是 C# 这种有函数指针/方法委托的语言里非常常见,Java 里是没有直接办法的。Java 里我们一般建议使用一个 A 和 B 公有的接口 IC,然后 IC 里定义方法 m,A 和 B 都实现接口 IC,这样就可以在运行时把 A 和 B 都当做 IC 类型来操作,就同时有了方法 m,这样的“强约束”带来了很多额外的操作。

而新增的 invokedynamic 指令,配合新增的方法句柄(Method Handles,它可以用来描述一个跟类型 A 无关的方法 m 的签名,甚至不包括方法名称,这样就可以做到我们使用方法 m 的签名,但是直接执行的时候调用的是相同签名的另一个方法 b),可以在运行时再决定由哪个类来接收被调用的方法。在此之前,只能使用反射来实现类似的功能。该指令使得可以出现基于 JVM 的动态语言,让 jvm 更加强大。而且在 JVM 上实现动态调用机制,不会破坏原有的调用机制。这样既很好的支持了 Scala、Clojure 这些 JVM 上的动态语言,又可以支持代码里的动态 lambda 表达式。

RednaxelaFX 评论说:

简单来说就是以前设计某些功能的时候把做法写死在了字节码里,后来想改也改不了了。 所以这次给 lambda 语法设计翻译到字节码的策略是就用 invokedynamic 来作个弊,把实际的翻译策略隐藏在 JDK 的库的实现里(metafactory)可以随时改,而在外部的标准上大家只看到一个固定的 invokedynamic。

,

免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com

    分享
    投诉
    首页