JVM 运行时数据区及常用指令

前言

  • JVM 运行时数据区的组成
  • 栈帧
  • 举例说明基于栈帧的运行机制
  • 总结JVM中的常用指令

JVM 运行时数据区(Runtime Data areas)

JVM 运行时数据区的组成

  • PC:Program Counter,程序计数器,保存了下一条指令的位置,每个线程都有一个PC
  • Heap: 堆
  • JVM stacks: JVM的栈,每个线程都有一个栈,每个方法都有一个栈帧,栈帧存储在栈中
  • native method stacks:本地方法(如JNI)的栈,无法调优、管理,一般不用考虑
  • Direct Memory:jdk1.4以后,为提高效率,NIO使用直接内存访问内核空间的内存(零拷贝)
  • method area:
    • 方法区,存放了class数据、Run-time Constant Pool。
    • Permanent GenerationMeta Space 分别是 JDK1.8之前JDK1.8及之后 对 method area 的实现
    • Perm Generation:
      1. < JDK1.7
      2. 字符串常量存储在Perm Generation中
      3. 启动时可以指定Perm Generation大小,运行过程中大小不会变,存满即报OOM,FGC不会清理Perm Generation
    • Meta Space
      1. >= JDK1.8
      2. 字符串常量位于堆
      3. 若不指定Meta Space大小,则大小受限于物理内存,会触发FGC

Runtime Data areas

关于栈帧

  • 目前实际的CPU大都是基于寄存器的指令集,而JVM是基于栈的指令集。
  • 栈帧是JVM运行的一个重要机制。
  • 每个方法都有一个栈帧,栈帧存储在栈中
  • 一个栈帧内包含以下部分:
    • 该方法的本地变量 Local Variables
    • 该方法的操作数栈 Operand Stacks
    • 动态链接 Dynamic Linking
    • 返回地址 Retrun Address

接下来通过例子来说明JVM是如何运行的。

举例

Talk is cheap. Show me the code!

一道无聊的面试题

首先来看一道比较无聊的面试题:求下面这段代码的输出结果…

1
2
3
4
5
6
7
public class Test1 {
public static void main(String[] args) {
int i = 8;
i = i++;
System.out.println(i);
}
}

答案是:8….还是9呢?

我们看一下编译后的字节码就了然了。使用之前介绍的jclasslib工具,分析编译出的class文件,找到其中的main方法:

编译后的字节码

图中左半部分是main()方法编译后的字节码,右半部分是本地变量,可以看到其中0号变量是args,即main函数的输入参数,1号变量是i。

接下来,我们来一条条分析指令:

  1. bipush 8:将数字8放入操作数栈(即本方法的栈帧的Operand Stacks)中,现在栈中有一个数字8。
  2. istore_1:从栈中取出一个int数,并赋值给1号本地变量。即将栈中的8弹出,并赋值给i。 此时,i=8,栈中为空。
  3. iload_1:将1号本地变量入栈。此时,栈中又有了一个数字8。
  4. iinc 1 by 1:将1号本地变量加1。在JVMS文档中可以看到这条指令是不影响操作数栈的,因此,此时,i变为9,栈中仍有一个数字8。
  5. istore_1: 从栈中取出一个int数,并赋值给1号本地变量。即将栈中的8弹出,并赋值给i。 此时,i=8,栈中为空。
  6. getstatic #2:解析常量池中2号常量的引用,即System.out
  7. iload_1:将1号本地变量入栈。此时,栈中又有了一个数字8。
  8. invokevirtual #3:弹出栈中的数字8,并将其作为参数调用常量池中3号常量的方法引用,即println。于是打印出8。
  9. return:返回。

虽然说看了字节码后,对于结果是了然了,但是,其实还是有一点小疑问的:

  • 可以看到 i = i 这句话编译成字节码后就变成了两句: iload_1istore_1,实际上就是先把i的值压栈,再弹出来赋回给i。
  • i++ 这句编译成字节码是 iinc 1 by 1,也就是把i的值加了1再赋值给i。
  • 上面两个都没问题,可以问题是为啥 iinc 1 by 1要插在 iload_1istore_1之间呢?如果是放在后面,那结果就是9了。
  • 其实,如果把 i = i++; 改成 j = i++没有什么争议了,iinc 1 by 1 只要放在 iload_1 之后就可以,至于要不要放在istore_1后面,对结果其实没什么影响。可能编译器也没想到有人会写这么无聊的代码吧。

接下来,我们把 i = i++ 改为 i = ++i;再看看结果。

i++改为++i

当然,结果是9。可以看到唯一的变化就是 iinc i by 1iload_1 的顺序换了下。这从 i++++i 的语义上也是能够理解的。

从这个例子,我们应该可以大致感受到JVM是如何运行的了,基本上各个指令都是对 栈帧 Frame 里的 操作数栈 Operand Stacks本地变量 Local Variables 的操作。

上面的例子中只是运行了一个static函数,其中也没有任何方法调用,因此整个过程中,栈中只有一个栈帧,接下来我们再来看看方法调用的情况。

方法调用

调用没有返回值的方法

1
2
3
4
5
6
7
8
9
10
public class Test3_MethodWithoutReturn {
public static void main(String[] args) {
Test3_MethodWithoutReturn t = new Test3_MethodWithoutReturn();
t.m1();
}

public void m1() {
int i = 500;
}
}

下图左边是 main 方法编译后的字节码和本地变量表,右边是 m1 方法的字节码和本地变量表:
Test3_MethodWithoutReturn字节码

下面我们来过一遍执行流程:

  1. 首先执行main函数,线程栈中有1个main方法的栈帧:

    1. new #2:在JVMS文档中可以得知,new指令会从该类的常量池中找到2号常量所代表的类(本例中就是Test3_MethodWithoutReturn 类),会为该类的一个实例在堆中分配空间,对其成员变量赋默认值( 注意:是默认值,不是初始值!默认值是每个类型各自的默认值,如int的默认值就是0,而初始值是指在构造方法中或成员变量声明处的赋值。赋初始值的操作是在构造函数执行时,才进行的)。并且生成的实例的引用会放入操作数栈中。此时main方法的栈中的操作数栈中有了一个Test3_MethodWithoutReturn实例的引用。

      new #2

    2. dup:将栈顶的元素复制一份入栈。此时,栈内有了两份同一个Test3_MethodWithoutReturn实例的引用。

      dup

    3. invokespecial #3:将栈顶元素弹出作为参数,调用Test3_MethodWithoutReturn的构造方法。实际上栈顶元素就是 this对于非静态方法来说,其本地变量的表第一个元素都是this,调用方法的时候也至少需要传入一个参数this(即实例的引用)。此时main方法的栈中的操作数栈只剩一个Test3_MethodWithoutReturn实例的引用。

      invokespecial #3

    4. astore_1:将栈顶元素弹出,赋值给1号本地变量,即t。此时t终于指向了初始化完成的Test3_MethodWithoutReturn实例,并且操作数栈又变为了空。

      astore_1

    5. aload_1:将本地变量1入栈,此时栈中有1个元素:t。

      aload_1

    6. invokevirtual #4:将栈顶元素t弹出作为参数,调用m1方法。正如之前所说,我们可以看到m1方法的本地变量表中,第一个元素就是this。

      invokevirtual #4

  2. 接下来就开始执行m1方法的字节码,线程栈中新压入了m1方法的栈帧:

    1. sipush 500:将500放入m1栈帧的操作数栈中。

      sipush 500

    2. istore_1:将栈顶元素弹出,并赋值给1号参数。即将i赋值为500

      istore_1

    3. return:m1方法返回

      return

  3. m1的栈帧弹出,线程栈中又只有1个main方法的栈帧,回到之前调用m1方法处,继续向下执行:

    1. return: main方法返回。

调用有返回值的方法

1
2
3
4
5
6
7
8
9
10
11
public class Test4_MethodWithReturn {
public static void main(String[] args) {
Test4_MethodWithReturn t = new Test4_MethodWithReturn();
t.m1();
}

public int m1() {
int i = 500;
return i;
}
}

Test3_MethodWithoutReturn 相比,只是在m1方法的最后加了一句 return i;

下图左边是 main 方法编译后的字节码和本地变量表,右边是 m1 方法的字节码和本地变量表:

Test4_MethodWithReturn字节码

Test3_MethodWithoutReturn 对比,我们不难发现:

  • m1方法的最后,先将i入栈,然后执行了 ireturn 指令。在JVMS文档中可知,和 return 相比,除了从当前方法返回,ireturn还会把本方法栈帧中的栈顶元素弹出,并压入到调用者栈帧的栈顶,也就是说,ireturn之后,i的值500,被放入到了main方法的栈帧的栈顶。
  • 而main方法在调用完m1方法后,多了一个 pop 指令,也就是把m1方法返回时压入的500弹了出来。

调用有返回值的方法,并接收返回值

1
2
3
4
5
6
7
8
9
10
11
public class Test5_GetMethodReturn {
public static void main(String[] args) {
Test5_GetMethodReturn t = new Test5_GetMethodReturn();
int a = t.m1();
}

public int m1() {
int i = 500;
return i;
}
}

Test4_MethodWithReturn 相比,在main方法中,声明了一个变量a来接收m1方法的返回值。

下图左边是 main 方法编译后的字节码和本地变量表,右边是 m1 方法的字节码和本地变量表:

Test5_GetMethodReturn字节码

Test4_MethodWithReturn 相比,不难发现:

  • main方法的本地变量表中多了一个2号变量a
  • main方法在调用完m1方法后,调用的 pop 指令变为了 istore_2 指令,即把返回的500从栈顶弹出,并赋值给2号本地变量a。

递归调用

最后,我们再来看看递归调用在JVM中是如何运行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test6_Recursion {
public static void main(String[] args) {
Test6_Recursion t = new Test6_Recursion();

int x = t.m(3);
System.out.println(x);
}

public int m(int n) {
if (n == 1) return 1;
return n * m(n - 1);
}
}

下图左边是 main 方法编译后的字节码和本地变量表,右边是 m 方法的字节码和本地变量表:

Test6_Recursion字节码

main方法没啥好说的,我们来看下m方法。

  1. 我们直接从main方法调用了t.m(3)处开始分析,对应的字节码是 invokevirtual #4,执行这条语句后,线程栈中压入m(3)的栈帧,PC指向m方法的字节码开始运行

    1. 执行前两条指令 iload_1iconst_1,依次将1号本地变量n的值3和常量1压入m(3)栈帧的操作数栈中。

      step 1

    2. 执行 if_icmpne 7 指令,将栈中的1和3弹出,比较两数,若相等,向下执行,若不等,跳转到代码段7,即跳过 iconst_1ireturn, 从iload_1处开始执行。

    3. 向下依次执行 iload_1 mload_0 iload_1 iconst_1,依次向操作数栈中压入n的值3,this引用,n的值3和常量1

      step 2

    4. 执行 isub 指令,将栈顶的两个元素3和1弹出相减,并将结果2压回栈中

      step 3

    5. 执行 invokevirtual #4指令,将栈顶的2和this弹出作为参数调用m方法,即执行m(2)。此时m(3)栈帧的操作数栈中还剩余一个元素3。线程栈中压入m(2)的栈帧,PC指针又指向m方法字节码的开始位置。

      step 4

  2. 执行m(2)的过程和执行m(3)类似,不再赘述,会继续执行m(1)。此时m(2)的栈帧的操作数栈中还剩余一个元素2。线程栈中压入m(1)的栈帧,PC指针又指向m方法字节码的开始位置。

    step 5

  3. 执行m(1)的过程和m(2)、m(3)略有不同。

    1. 执行前两条指令 iload_1iconst_1,依次将1号本地变量n的值1和常量1压入m(3)栈帧的操作数栈中。

      step 6

    2. 执行 if_icmpne 7 指令,将栈中的1和1弹出,比较两数,相等,继续向下执行iconst_1
      向m(1)栈帧的操作数栈中压入常量1。

      step 7

    3. 执行 ireturn 指令,将本方法,即m(1)栈帧的操作数栈顶元素1弹出,并压入调用方,即m(2)栈帧的操作数栈中,接着返回m(2)的 imul指令处继续执行,m(1)的栈帧从线程栈中弹出。

      step 8

  4. 至此,m(1)方法执行完成,返回到m(2)继续执行。

    1. 执行 imul 指令,m(2)栈帧的操作数栈顶的两个元素1和2被弹出,相乘后,将结果2压回栈顶。

    2. 执行 ireturn 指令,将m(2)栈帧的操作数栈顶的元素2弹出,并压入调用方,即m(3)栈帧的操作数栈顶,接着返回m(2)的 imul指令处继续执行,m(2)的栈帧从线程栈中弹出。

      step 9

  5. m(2)方法也执行完成,返回到m(3)继续执行,和m(2)的执行类似,栈顶两个元素2和3相乘,得到结果6,返回main方法。

    step 10

  6. 最终在main方法中,结果6赋值给main方法的2号本地变量x。

JVM 常用指令

在上面的举例分析中,常用的JVM指令我们基本都接触过了,下面简单汇总下,具体可以查阅JVMS文档

  • 变量读写
    • load:将本地变量的值压入操作数栈中,根据数据类型的不同,具体有aload,aaload,baload,caload,dload,faload,fload,iaload,iload,laload,lload,saload等
    • store:将操作数栈顶元素弹出,并赋值给本地变量,根据数据类型的不同,具体有astore,aastore,bastore,castore,dstore,fastore,fstore,iastore,istore,lastore,llstore,sastore等
  • 栈操作
    • dup:复制栈顶元素
    • push:将一个元素压入栈中
    • pop:从栈顶弹出一个元素
    • iconst:将一个常量压入栈中
  • 运算
    • add:加
    • sub:减
    • mul:乘
    • div:除
    • rem:取余
    • neg:取相反数
    • or:或
    • xor:异或
    • and:与
    • inc:自加操作,注意这条指令不影响操作数栈,直接对本地变量操作
  • 比较跳转
    • if_acmp<cond>:引用比较跳转
    • if_icmp<cond>:int值比较跳转
    • if<cond>:int值和0比较跳转
  • 方法返回
    • return
  • 方法调用
    • invokestatic:调用static方法
    • invokevirtual:调用某个对象的方法,自带多态
    • invokeinterface:调用接口对象的方法
    • invokespecial:调用可以直接定位、没有多态的方法(如private方法和构造方法)
    • invokedynamic:lambda表达式、反射、其他动态语言(如scala kotlin)、CGLib或ASM动态产生的class,调用其中的方法