本文重点介绍JVM运行时数据区的整体概况,其中堆和方法区等比较复杂的会在GC的部分学习。另外本文还学习了JVM的指令集,涉及到的常用的一些指令,通过查看JVM规范手册,还确定每一个是如何使用,并与运行时数据区进行对应。 笔记系列。 关键字:运行时数据区,自增的字节码指令执行,局部变量表,栈帧,this,iadd,invoke指令
1、引言
一个java类的完整生命周期如下:
class文件 -> (loading,linking,initailizing)-> JVM -> run engine -> 运行时数据区 -> GC
2、运行时数据区
- PC,program counter,程序计数器。存放下一条指令位置,空间很小。虚拟机运行就是一个循环,不断去取PC中的位置,找到对应位置的指令然后执行,PC ,直到结束。
- 每个JVM线程都有它自己的PC寄存器。
- 任何时候,每条JVM线程都在执行一个独立方法的代码,被称作那条线程的当前方法。
- 如果方法不是native的,pc寄存器包含的JVM指令的地址将被执行。
- Heap,堆内存。GC垃圾收集的时候会重点学习。
- JVM拥有一个Heap堆,它是被所有的JVM线程所共享的。
- 堆是在JVM运行时,所有对象实例和被分配的数组所占内存的空间。
- stack
- JVM stacks
- 每个JVM线程都有一个私有的JVM栈,在线程被创建的时候,栈也随即被创建。
- 一个JVM栈里面保存着很多的栈帧。
- stack frames,每个栈帧对应每一个方法。
- Native method stacks,本地方法栈,通过JNI去调用,保存的是C/C 依据JVM规范编写的方法。
- JVM stacks
- Direct memory,直接内存,NIO。原来的IO方式是数据先进入操作系统内存,然后JVM在执行时要从操作系统内存将数据拷贝过来一份再进行处理。NIO的直接内存方式就是省去了拷贝的过程,提高效率。Zero Copy,零拷贝,JVM可以直接去访问操作系统内核储存空间,不需要再拷贝到JVM空间处理。
- Method area,包含Run-time constant pool。
- JVM拥有一个方法区,是被所有线程所共享的。
- 方法区保存着每一个类的结构。
- 方法区是一个规范,包括两个不同版本的方法区的实现:
- Perm Space,JVM<1.8,FGC不会清理。大小在启动的时候指定,不能改变。一旦动态类特多的时候,会溢出。字符串常量位于PermSpace,在<1.8的情况下。而>1.8的时候,String常量位于堆。
- Meta Space,JVM>=1.8,会触发FGC清理。如果不设定默认最大就是物理内存占满,当满了就会FGC清理。
- 运行时常量池是每个类或接口在运行时读取的Class文件中的constant_pool的内容。
总结一下, 线程A: PC、JVMStacks、NMStacks 线程B: PC、JVMStacks、NMStacks 线程C: PC、JVMStacks、NMStacks 共享的内容:Heap,Method Area(Perm/Meta Space)
3、栈帧
栈帧对应一个线程的一个方法的内容,用于方法的执行,包括方法执行过程中的变量的临时状态。同时栈帧也执行动态链接,方法的返回值以及分发异常。栈帧被包含在JVM栈中。每一个栈帧包括:
1、局部变量表,Local Variable Table。
2、操作数栈,Operand Stack。
3、动态链接,Dynamic Linking。指向运行时常量池中的符号连接,如果解析就直接使用,未解析则执行解析再使用。例如A方法调用B方法,B要去常量池去找,找的这个过程就是动态链接。
4、返回地址,return address。a()->b(),方法a调用了方法b,b方法执行完了以后,返回值存放的位置,以及b方法执行完毕,应该接着执行a的哪里,也存放在这个返回地址。
3.1 自增代码的字节码检查
这里面的局部变量表,如上图的选中部分,Class文件中的方法的code中的LocalVariableTable会被JVM读取进入每一个线程中的栈的一个栈帧中的局部变量表,可以理解为这是一对一的。
注意,上图中局部变量表是初始状态,最右侧选中的部分,显示的是int a,其中a是名字,int是描述符I来代替。
当前这个LocalVariableTable读取到JVM是初始状态,接下来在JVM中要执行code的JVM指令,通过这些指令的执行,会改变这个LocalVariableTable。下面先来看对应main方法源码code的JVM指令。
0 sipush 311 3 istore_1 4 iload_1 5 iinc 1 by 1 8 istore_1 9 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;> 12 iload_1 13 invokevirtual #3 <java/io/PrintStream.println : (I)V> 16 return
- sipush 311,Push
short
。添加整数常量(311)到当前线程栈的main方法栈帧的操作数栈的栈顶[进栈]。 - istore_1,Store
int
into local variable。把操作数栈顶的数(311)出栈,设定为局部变量表中下标为1的变量(a)的值(311)。
以上两句就完成了int a = 1;语句。
- iload_1,Load
int
from local variable。将局部变量表中下标为1的变量的int值压入操作数栈栈顶。 - iinc 1 by 1,Increment local variable by constant。将局部变量表中下标为1的变量(a)的值(311)加1。第一个1是index,指的是局部变量表的下标;第二个1是常量,指的是加几。所以执行完这条指令以后,局部变量表中下标为1的变量a的值编变成了312。
以上就完成了i
的代码,局部变量表的状态发生了变化。
- istore_1,把操作数栈栈顶的值出栈,再存入局部变量表下标为1的变量a的值(311),替换掉了当前的312。
以上完成的是i = i ;
的代码,把i在操作数栈中值就赋回给局部变量表了。所以结果打印出来i仍旧等于311。后面的几行指令都是执行system.out.println()的指令的,不再深入介绍。
反过来,改为i= i;
进行查看code字节码。
0 sipush 311 3 istore_1 4 iinc 1 by 1 7 iload_1 8 istore_1 9 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;> 12 iload_1 13 invokevirtual #3 <java/io/PrintStream.println : (I)V> 16 return
这里面与a
的区别是 iinc 1 by 1和 iload_1指令的位置调换了,其他的都一样。
- 先执行iinc 1 by 1,修改局部变量表下标1的变量的值加1,即a = 312。
- 再执行iload_1,将局部变量表下标1的变量的值取出来,即312,压入操作数栈栈顶。
后面的逻辑是一样的,取出操作数栈栈顶的值保存到局部变量表下标1的值的位置中。
HotSpot的LocalVariableTable类似于CPU的寄存器,CPU寄存器的指令集是汇编语言。JVM的指令集是基于JVM规范,例如上面的字节码中code的存在于栈帧中的指令集,但硬件层面都会最终去执行CPU的寄存器,即执行汇编语言。
3.2 this
上面介绍了main方法的内容,那么栈帧与方法是一对一的,如果我们写一个自己的方法,它的字节码应该是怎样的呢?
我们自己写了一个方法getMoney,但是要注意的是这个方法不是static的。可以看到它的字节码中方法的code的局部变量表有3行,其中第一行是this,指向了当前类。如果加上static关键字,局部变量表中就没有this了。
原因是什么呢?因为非static方法是需要对象来执行的,而对象的类被this所指定了。
3.3 iadd
看一下code的JVM指令:
0 iload_1 1 iload_2 2 iadd 3 istore_3 4 iload_3 5 ireturn
- iload_1,将a的值读入栈顶。
- iload_2,将b的值读入栈顶。
- iadd,Add int。从栈顶读出value1(b的值)和value2(a的值),然后相加得到结果result,将result值压入栈顶。
- istore_3,栈顶出栈,将结果值存入局部变量表下标3的变量,即c的值里。
- iload_3,将局部变量表c的值读入栈顶。
- ireturn,Return
int
from method。方法返回int结果。
3.4 类内部构造其他对象
注意:前面提到了Stack Overflow。我们观察一下上图,在字节码方法的区域,有[0,1,2]三个方法,分别是init、main和add,这是Class文件中字节码代表的方法。要注意区分的是Class中的方法和JVM栈帧的区别,前者是静态的,后者是动态的,JVM栈帧在执行的时候会根据调用的层次关系逐个入栈,如果某方法并未被调用,则不会进入JVM栈帧。Class文件中每个方法静态的给出了code,这是对源码的翻译,也是JVM栈帧在执行时所需要解释执行的逐条读入的指令。
main方法中new了一个对象,下面重点看一下它的字节码指令。
0 new #2 <com/evswards/jvm/TestByteCodeJVM> 3 dup 4 invokespecial #3 <com/evswards/jvm/TestByteCodeJVM. : ()V> 7 astore_1 8 getstatic #4 <java/lang/System.out : Ljava/io/PrintStream;> 11 aload_1 12 iconst_1 13 iconst_2 14 invokespecial #5 <com/evswards/jvm/TestByteCodeJVM.add : (II)I> 17 invokevirtual #6 <java/io/PrintStream.println : (I)V> 20 return
- new #2,创建一个对象。#2去找常量池,是一个类信息。这里涉及到对象内存的内容,包括堆的分配,GC详细学习。创建完,对象的引用objectref会压入栈顶。
- dup,Duplicate the top operand stack value。栈顶出栈,拷贝,连续入栈2个值。
- invokespecial,调用实例的方法。包括自动调用构造函数,以及当前类的方法以及它的父类的方法(若有)。该指令会执行操作数栈出栈,将对象引用取走使用。这就是dup要拷贝一份的原因。
后面的指令就不继续研究了,主要想体现的内容是对象引用的复制和调用时取走的机制。
3.5 返回值的接收
main方法中创建当前类对象,然后调用m方法,m方法有返回值,但是main方法并没有派变量来接收。观察右侧code的字节码指令。前面的都学习过了,直接看pop。
- pop,这个指令很简单,就是操作数栈出栈。在执行完m方法以后,m方法的返回值会压入到main方法的栈顶,而这个返回值由于main方法没有安排变量来接收,所以直接pop掉了。
下面看另一种情况:
源码中我们做了调整,安排了int i来接收m的返回值。这时候code字节码的指令中发生了变化,原来pop的位置编程了istore_2,是因为main方法中i是在局部变量表中的下标为2的位置,前面还有0是args,1是h。所以将m的返回值在栈顶的内容直接出栈并写入到局部变量表中i的值里面。
回顾一下,this在局部变量表中什么时候出现,一定是在非static方法的局部变量表中的第一个元素。
3.6 递归
编写了一个阶乘的实现。字节码指令解释一下没见过的:
- if_icmpne 7,比较栈顶俩值(此时栈顶俩值是前面两条指令压入,分别是iload_1压入n的值是3,然后iconst_1压入整数常量1,所以当前栈顶是1,3)是否相等,这两个值都必须是int类型,他们都会被依次出栈,然后进行对比。指令的结构是if代表控制,i代表类型,cmp是compare,ne是不等于,其他的还有:eq等于,lt小于,le小于等于,gt大于,ge大于等于。当比较结果为成功时,跳到第7行指令,即iload_1。
- iload_1,执行前栈是空的,压入3。
- iload_0,压入this
- iload_1,再压入3
- iconst_1,再压入1
- isub,弹出栈顶1和3,用3-1,得到结果2压入栈顶。
- invokevirtual,调用m方法,弹出传入2和this,当前栈顶为3。这里再嵌套进入新的栈帧去执行以上相同的指令。
- 当执行到m(1)的时候,新的栈帧里面当执行到if_icmpne成功,不跳,直接返回1。ireturn返回给上一层栈帧,即m(2),同时m(1)栈帧从当前JVM栈弹出销毁。
- imul,相乘的指令,m(2)拿到m(1)的返回值以后,会入栈,然后imul指令会把栈顶两个值,即m(1)结果和n即2相乘得到2。
- ireturn,m(2)的结果作为返回值给m(3),同时m(2)栈帧从当前JVM栈弹出销毁。m(3)拿到m(2)返回值仍旧去执行imul指令,将返回值给main方法,同时m(3)栈帧从当前JVM栈弹出销毁。 那么,然后会进入到main方法栈帧,从中断位置重新执行,会拿到m(3)返回值在栈顶出栈,并记录为变量i的局部变量表中的值。
3.7 Stack Overflow
栈帧本身位于JVM栈中,针对每一个方法有一个栈帧,上面例子中如果main方法调用了add方法,JVM栈中会先读取main方法栈帧,然后在执行到调用add方法时,会继续入栈,读取add方法的栈帧。此时JVM栈中会有两个栈帧同时存在。当add方法执行完毕,会按照add方法栈帧的Return Address返回到main方法栈帧之前执行中断的位置继续执行,而同时add方法栈帧会出栈销毁(执行完毕没有用了)。
那么当方法嵌套调用太多,会导致JVM栈中的栈帧太多,超过了栈大小的限制,就会报错Stack Overflow。这部分在GC内容中会详细学习。
4、invoke指令
上面有介绍到一些invoke指令,例如invokeVirtual、InvokeStatic等。JVM的invoke指令总共有:
- InvokeStatic,调用静态方法指令。main函数中调用一个静态方法m,这时候在main函数code中就会用到这个指令。
- InvokeVirtual,大部分的方法调用都是这个指令,main函数中调用普通方法,需要先创建当前类的对象。InvokeVirtual指令自带多态,调用哪个对象的方法就会去到哪个对象里面,因此会创建新的方法栈帧,然后转去新的栈帧执行。
- InvokeSpecial,目前只有构造方法和private方法使用到。因为private方法没有重写。InvokeSpecial,调用可以直接定位的,不需要多态的方法。(注意,final方法是执行InvokeVirtual,而不是InvokeSpecial)
- InvokeInterface,通过interface来调用的方法,就需要这个指令。例如 List<String> list = new ArrayList<String>)();这时候通过list调用add方法时,list是List对象,List是一个接口,这时候调用add方法就是InvokeInterface指令。
- InvokeDynamic,JVM最难的指令,lambda表达式或者反射或者其他动态语言,动态产生的class会用到该指令。
4.1 InvokeDynamic
lambda表达式的动态产生的类,会用到InvokeDynamic。输出的情况,看到都是Lambda的动态产生的类的情况。看一下字节码的内容:
可以验证到,字节码的指令都是用到了InvokeDynamic。
JVM规范中还有很多没有涉及到的指令,可以在用到的时候去手动查询,重点关注操作数栈的变化以及执行的描述。