前言
- 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 Generation 和 Meta Space 分别是 JDK1.8之前 和 JDK1.8及之后 对 method area 的实现
- Perm Generation:
- < JDK1.7
- 字符串常量存储在Perm Generation中
- 启动时可以指定Perm Generation大小,运行过程中大小不会变,存满即报OOM,FGC不会清理Perm Generation
- Meta Space
- >= JDK1.8
- 字符串常量位于堆
- 若不指定Meta Space大小,则大小受限于物理内存,会触发FGC
关于栈帧
- 目前实际的CPU大都是基于寄存器的指令集,而JVM是基于栈的指令集。
- 栈帧是JVM运行的一个重要机制。
- 每个方法都有一个栈帧,栈帧存储在栈中
- 一个栈帧内包含以下部分:
- 该方法的本地变量 Local Variables
- 该方法的操作数栈 Operand Stacks
- 动态链接 Dynamic Linking
- 返回地址 Retrun Address
接下来通过例子来说明JVM是如何运行的。
举例
Talk is cheap. Show me the code!
一道无聊的面试题
首先来看一道比较无聊的面试题:求下面这段代码的输出结果…
1 | public class Test1 { |
答案是:8….还是9呢?
我们看一下编译后的字节码就了然了。使用之前介绍的jclasslib工具,分析编译出的class文件,找到其中的main方法:
图中左半部分是main()方法编译后的字节码,右半部分是本地变量,可以看到其中0号变量是args,即main函数的输入参数,1号变量是i。
接下来,我们来一条条分析指令:
bipush 8
:将数字8放入操作数栈(即本方法的栈帧的Operand Stacks)中,现在栈中有一个数字8。istore_1
:从栈中取出一个int数,并赋值给1号本地变量。即将栈中的8弹出,并赋值给i。 此时,i=8,栈中为空。iload_1
:将1号本地变量入栈。此时,栈中又有了一个数字8。iinc 1 by 1
:将1号本地变量加1。在JVMS文档中可以看到这条指令是不影响操作数栈的,因此,此时,i变为9,栈中仍有一个数字8。istore_1
: 从栈中取出一个int数,并赋值给1号本地变量。即将栈中的8弹出,并赋值给i。 此时,i=8,栈中为空。getstatic #2
:解析常量池中2号常量的引用,即System.outiload_1
:将1号本地变量入栈。此时,栈中又有了一个数字8。invokevirtual #3
:弹出栈中的数字8,并将其作为参数调用常量池中3号常量的方法引用,即println。于是打印出8。return
:返回。
虽然说看了字节码后,对于结果是了然了,但是,其实还是有一点小疑问的:
- 可以看到
i = i
这句话编译成字节码后就变成了两句:iload_1
和istore_1
,实际上就是先把i的值压栈,再弹出来赋回给i。 - 而
i++
这句编译成字节码是iinc 1 by 1
,也就是把i的值加了1再赋值给i。 - 上面两个都没问题,可以问题是为啥
iinc 1 by 1
要插在iload_1
和istore_1
之间呢?如果是放在后面,那结果就是9了。 - 其实,如果把
i = i++;
改成j = i++
没有什么争议了,iinc 1 by 1
只要放在iload_1
之后就可以,至于要不要放在istore_1
后面,对结果其实没什么影响。可能编译器也没想到有人会写这么无聊的代码吧。
接下来,我们把 i = i++
改为 i = ++i;
再看看结果。
当然,结果是9。可以看到唯一的变化就是 iinc i by 1
和 iload_1
的顺序换了下。这从 i++
和 ++i
的语义上也是能够理解的。
从这个例子,我们应该可以大致感受到JVM是如何运行的了,基本上各个指令都是对 栈帧 Frame 里的 操作数栈 Operand Stacks 和 本地变量 Local Variables 的操作。
上面的例子中只是运行了一个static函数,其中也没有任何方法调用,因此整个过程中,栈中只有一个栈帧,接下来我们再来看看方法调用的情况。
方法调用
调用没有返回值的方法
1 | public class Test3_MethodWithoutReturn { |
下图左边是 main 方法编译后的字节码和本地变量表,右边是 m1 方法的字节码和本地变量表:
下面我们来过一遍执行流程:
首先执行main函数,线程栈中有1个main方法的栈帧:
new #2
:在JVMS文档中可以得知,new指令会从该类的常量池中找到2号常量所代表的类(本例中就是Test3_MethodWithoutReturn
类),会为该类的一个实例在堆中分配空间,对其成员变量赋默认值( 注意:是默认值,不是初始值!默认值是每个类型各自的默认值,如int的默认值就是0,而初始值是指在构造方法中或成员变量声明处的赋值。赋初始值的操作是在构造函数执行时,才进行的)。并且生成的实例的引用会放入操作数栈中。此时main方法的栈中的操作数栈中有了一个Test3_MethodWithoutReturn
实例的引用。dup
:将栈顶的元素复制一份入栈。此时,栈内有了两份同一个Test3_MethodWithoutReturn
实例的引用。invokespecial #3
:将栈顶元素弹出作为参数,调用Test3_MethodWithoutReturn
的构造方法。实际上栈顶元素就是 this,对于非静态方法来说,其本地变量的表第一个元素都是this,调用方法的时候也至少需要传入一个参数this(即实例的引用)。此时main方法的栈中的操作数栈只剩一个Test3_MethodWithoutReturn
实例的引用。astore_1
:将栈顶元素弹出,赋值给1号本地变量,即t。此时t终于指向了初始化完成的Test3_MethodWithoutReturn
实例,并且操作数栈又变为了空。aload_1
:将本地变量1入栈,此时栈中有1个元素:t。invokevirtual #4
:将栈顶元素t弹出作为参数,调用m1方法。正如之前所说,我们可以看到m1方法的本地变量表中,第一个元素就是this。
接下来就开始执行m1方法的字节码,线程栈中新压入了m1方法的栈帧:
sipush 500
:将500放入m1栈帧的操作数栈中。istore_1
:将栈顶元素弹出,并赋值给1号参数。即将i赋值为500return
:m1方法返回
m1的栈帧弹出,线程栈中又只有1个main方法的栈帧,回到之前调用m1方法处,继续向下执行:
return
: main方法返回。
调用有返回值的方法
1 | public class Test4_MethodWithReturn { |
和 Test3_MethodWithoutReturn
相比,只是在m1方法的最后加了一句 return i;
。
下图左边是 main 方法编译后的字节码和本地变量表,右边是 m1 方法的字节码和本地变量表:
和 Test3_MethodWithoutReturn
对比,我们不难发现:
- m1方法的最后,先将i入栈,然后执行了
ireturn
指令。在JVMS文档中可知,和return
相比,除了从当前方法返回,ireturn
还会把本方法栈帧中的栈顶元素弹出,并压入到调用者栈帧的栈顶,也就是说,ireturn
之后,i的值500,被放入到了main方法的栈帧的栈顶。 - 而main方法在调用完m1方法后,多了一个
pop
指令,也就是把m1方法返回时压入的500弹了出来。
调用有返回值的方法,并接收返回值
1 | public class Test5_GetMethodReturn { |
和 Test4_MethodWithReturn
相比,在main方法中,声明了一个变量a来接收m1方法的返回值。
下图左边是 main 方法编译后的字节码和本地变量表,右边是 m1 方法的字节码和本地变量表:
和 Test4_MethodWithReturn
相比,不难发现:
- main方法的本地变量表中多了一个2号变量a
- main方法在调用完m1方法后,调用的
pop
指令变为了istore_2
指令,即把返回的500从栈顶弹出,并赋值给2号本地变量a。
递归调用
最后,我们再来看看递归调用在JVM中是如何运行的。
1 | public class Test6_Recursion { |
下图左边是 main 方法编译后的字节码和本地变量表,右边是 m 方法的字节码和本地变量表:
main方法没啥好说的,我们来看下m方法。
我们直接从main方法调用了t.m(3)处开始分析,对应的字节码是
invokevirtual #4
,执行这条语句后,线程栈中压入m(3)的栈帧,PC指向m方法的字节码开始运行执行前两条指令
iload_1
和iconst_1
,依次将1号本地变量n的值3和常量1压入m(3)栈帧的操作数栈中。执行
if_icmpne 7
指令,将栈中的1和3弹出,比较两数,若相等,向下执行,若不等,跳转到代码段7,即跳过iconst_1
和ireturn
, 从iload_1
处开始执行。向下依次执行
iload_1
mload_0
iload_1
iconst_1
,依次向操作数栈中压入n的值3,this引用,n的值3和常量1执行
isub
指令,将栈顶的两个元素3和1弹出相减,并将结果2压回栈中执行
invokevirtual #4
指令,将栈顶的2和this弹出作为参数调用m方法,即执行m(2)。此时m(3)栈帧的操作数栈中还剩余一个元素3。线程栈中压入m(2)的栈帧,PC指针又指向m方法字节码的开始位置。
执行m(2)的过程和执行m(3)类似,不再赘述,会继续执行m(1)。此时m(2)的栈帧的操作数栈中还剩余一个元素2。线程栈中压入m(1)的栈帧,PC指针又指向m方法字节码的开始位置。
执行m(1)的过程和m(2)、m(3)略有不同。
执行前两条指令
iload_1
和iconst_1
,依次将1号本地变量n的值1和常量1压入m(3)栈帧的操作数栈中。执行
if_icmpne 7
指令,将栈中的1和1弹出,比较两数,相等,继续向下执行iconst_1
,
向m(1)栈帧的操作数栈中压入常量1。执行
ireturn
指令,将本方法,即m(1)栈帧的操作数栈顶元素1弹出,并压入调用方,即m(2)栈帧的操作数栈中,接着返回m(2)的imul
指令处继续执行,m(1)的栈帧从线程栈中弹出。
至此,m(1)方法执行完成,返回到m(2)继续执行。
执行
imul
指令,m(2)栈帧的操作数栈顶的两个元素1和2被弹出,相乘后,将结果2压回栈顶。执行
ireturn
指令,将m(2)栈帧的操作数栈顶的元素2弹出,并压入调用方,即m(3)栈帧的操作数栈顶,接着返回m(2)的imul
指令处继续执行,m(2)的栈帧从线程栈中弹出。
m(2)方法也执行完成,返回到m(3)继续执行,和m(2)的执行类似,栈顶两个元素2和3相乘,得到结果6,返回main方法。
最终在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,调用其中的方法