一、概述
本文主要介绍java程序运行时,运行时数据区中的数据变化过程,借此来帮助我们深刻的理解jvm运行机制。
二、解析
1. 运行时数据区
运行时数据区Run-time data areas,如下图:
主要包含以下六大区域:
(1) Program Counter:程序计数器,用于存放下一条要执行的指令的位置。常用于线程切换时,记录下线程执行到了哪个位置。
(2) JVM Stacks:java代码运行时,每一个线程对应一个栈,每一个方法对应一个Frame(栈帧)。每个栈帧包含3个组成部分:
a. Local Variable Table:局部变量表。
b. Operand Stack:操作数栈。对于long的处理(store and load),多数虚拟机的实现都是原子的。
c. Dynamic Linking:动态链接。指向运行时常量池的符号链接,看这个符号链接是否已经解析,如果解析就直接取来用,没有解析就进行动态解析在使用。
d. return address:返回值地址。如果a()方法调用b()方法,b()方法的返回值放在何处,一般是a()方法的栈帧的栈顶。
(3) Heap:堆,所有线程共享。GC详解时分析。
(4) Method Area:方法区,包含了运行时常量池。也是所有线程共享。在jdk1.8之前和之后是有不同的实现,如下:
a. Perm Space (jdk<1.8):字符串常量位于PermSpace,FGC不会清理这块内容。
b. Meta Space (jdk<=1.8):字符串常量位于堆,会触发FGC清理。
(5) Direct Memory:jdk1.4之后增加的内容,称为直接内存。从JVM内部可以直接方法操作系统的内存。网络数据传输过来时,先存在内核空间的一块内存里,JVM需要用到时,需要将这块内容拷贝到JVM空间 ,中间有一个内存拷贝的过程。NIO可以直接访问内核空间的内存,少了内存拷贝,称为zero copy,提高效率。
(6) Native Method Stacks:本地方法栈,jdk源码里通常有调用native修饰的本地方法,就是存在这里的。
总结起来,多线程情况下的内存分布如下图:
每个线程都有自己的Program Counter,JVM Stacks和Native Method Stacks。而Heap和Method Area以及它的两种实现都是被所有线程所共享的。
2. 从代码到指令深度解析
我们由两个main()方法代码进行比较:
(1)探究 i = i++的指令,代码如下:
输出结果如下:
那么结果为何是8呢,我们根据指令来探究原理:
(1) 通过插件查看解析二进制码
(2) 进入方法区查看main()方法的指令
(3) 要理解这些指令,我们需要先理解局部变量表的结构,Code目录下包含两张表,分别是行号表LineNumberTable和局部变量表LocalVariableTable,如下:
(4) 局部变量表的内容是当前方法里用到的局部变量,值得注意的是局部变量表内包含传入这个方法的参数,如main()方法的参数args,如下图:
此表中Name就是记录这个局部变量在常量池Constant pool中的位置,如args就在Constant pool里面的第15号,如下图:
(5) i=i++指令分析
我们对 i=i++ 的逐条指令进行解读:
a. bipush 8:将byte类型的8扩展成int类型,并且将8压栈到操作数栈。
b. istore_1:将操作数栈的8出栈,保存在局部变量表的第1号位置。到这一步就完成了int i=8这句代码。
c. iload_1:将局部变量表位置1的值也就是i的值,也就是8又进行压栈。
d. iinc 1 by 1:将局部变量表位置1的值进行+1,也就是执行i++。此时局部变量表中的i值是9,但是操作数栈的值还是8。
e. istore_1:将操作数栈的8出栈,保存在局部变量表的第1号位置。相当于执行了i=i++,到这里,i的值由9被覆盖成了8。
(6) i=++i指令分析
源码如下:
结果如下:
指令如下:
我们对 i=++i 的逐条指令进行解读:
a. bipush 8:将byte类型的8扩展成int类型,并且将8压栈到操作数栈。
b. istore_1:将操作数栈的8出栈,保存在局部变量表的第1号位置。到这一步就完成了int i=8这句代码。
c. iinc 1 by 1:将局部变量表位置1的值进行+1,也就是执行++i。此时局部变量表中的i值是9。
d.iload_1:将局部变量表位置1的值也就是i的值,也就是9又进行压栈。
e. istore_1:将操作数栈的9出栈,保存在局部变量表的第1号位置。相当于执行了i=++i。
3. 运行时数据区变化
结合不同的代码我们来观察运行时数据区的变化:
(1) int i = 100
(2) int i= 200
与(1)相比,这里的代码是在一个非静态方法里,那么会在常量池第0的位置内存下一个this。后面顺序是方法参数,再是方法体里的常量。
(3) int c = a + b
当我们调用add()方法,参数a和b分别传入3和4,过程如上图所示,常量池中依次是this(因为是非静态方法)、a、b、c。计算3+4时,会将3和4从操作数栈进行出栈再计算,并将结果7再压栈,所以计算完后,操作数栈里只有7,没有3和4。
(4) 方法里调用别的方法
指令解析:
a. new #2:创建一个Hello_02对象,并将分配的内存的内存地址压栈入操作数栈。
b. dup:将刚压栈的内存地址复制一份,用于后面构造法调用时使用。
c. invokespecial #3:调用Hello_02的构造方法,会用掉一个操作数栈存的对象地址。
d. astore_1:将对象赋值给常量池1号位的h。到这里就完成了Hello_02 h = new Hello_02()这句代码。
e. aload_1:将常量池1号位的常量,也就是h压栈到操作数栈。
f. invokevirtual #4:调用方法m1()
由于方法调方法,会在JVM Stacks中产生很多栈帧,那么一个方法执行完后接着执行哪个方法里的哪个指令呢,这就由Return Address来记录。
(5) 方法调用方法其他基础场景
值得注意的是,当方法有返回值时,多了一个pop指令。这是由于m1()方法返回时,会在main()方法的栈帧的栈顶放一个100。
(6) 递归调用
4. 指令集分类
指令集分两种,分别是基于寄存器的指令集和基于栈的指令集,他们的关系如下:
(1) JVM执行指令时所采取的方式是基于栈的指令集
(2) 基于栈的指令集主要的操作有入栈与出栈两种
(3) 基于栈的指令集的优势在于它可以在不同平台之间进行移植,而基于寄存器的指令集是与硬件架构紧密关联的,无法做到可移植。
(4) 基于栈的指令集的缺点在于完成相同的操作,指令数量通常要比基于寄存器的指令集数量要多;基于栈的指令集是在内存中完成操作的,而基于寄存器的指令集是直接由CPU来执行的,它是在高速缓冲区进行执行的,速度要快很多。虽然虚拟机可以采用一些优化手段,但总体来说,基于栈的指令集的执行速度要慢一些。
5. 常用指令
在上文中我们已经分析了很多代码的指令,现在来总结一下常用的指令:
(1) store:出操作数栈。
(2) load:入操作数栈。
(3) pop:栈帧的栈顶弹出。
(4) mul:乘法。
(5) sub:减法。
(6) invoke:
a. InvokeStatic:调用静态方法。
b. InvokeVirtual:调用虚方法
c. InvokeInterface:调用接口方。
d. InvokeSpecial:调用实例构造器<init>方法, 私有方法和父类方法。可以直接调用,不需要多态的方法。
e. InvokeDynamic:lambda表达式或者反射和其他动态语音动态产生的class会用到的指令。
三、总结
本文结合实际代码深入了解JVM常用指令以及各个指令是如何操作运行时数据区的,接下来我们就将进入GC的学习中。
更多精彩内容,敬请扫描下方二维码,关注我的微信公众号【Java觉浅】,获取第一时间更新哦!