JVM之运行时数据区(一)程序计数器与虚拟机栈

什么是JVM

我们首先通过一张图来了解下虚拟机

在上图中我们能够清晰的看出,我们锁写的代码“HellloWorld.java”编译为class文件,class文件达到的目的就是“WORE”,一次编译导出运行(”Write Once Run Everywhere”), 单数如何达到在linux,windows,Mac等各类计算机中达到运行的呢?常规机器仅仅识别“010101”,也就是识别数字”1“和“0”,这就需要依赖我们的JVM虚拟机了。我们的代码运行在虚拟机上,虚拟机帮助我们将class文件转化为机器所能识别运行的语言。

功能:
负责翻译功能,让机器能够识别我们写的代码
管理内存,可以帮我们来管理内存。

为什么学习虚拟机呢?

正常情况下我们不需要关心虚拟机的内存情况,由虚拟机进行帮我们进行管理内存,但是如果一旦出现内存泄漏或者溢出,我们就很难排查错误。

运行时数据区

java虚拟机在执行java程序的执行过程中会把他所管理的内存划分为若干个不同的数据区域。这些区域有着各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而建立和销毁。在虚拟机规范里面,java虚拟机将包括一下的运行时数据区。

程序计数器

程序计数器(program Counter Register):指向当前线程正在执行的字节码指令的地址 行号
简单的说就是当前的线程走到哪一步,进行记录下来,因为CPU在整个执行的过程中可能会发生抢占式,也就是这个线程没有执行结束而去执行其他的线程,不那么当前的信息需要保存下来。

在虚拟机的概念模型里,字节码解释器工作时就是通过改变计数器的值来去下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器。在执行Java方法时记录的是正在执行的虚拟机字节码指令的地址,Native方法,则计数器为空。

我们来看一下面方法的指令,其中冒号左边的数字就是代表着程序计数器的值,也就是指令偏移地址,程序计数器通过这个地址来进行执行,当CPU进行执行其他线程时,程序计数器就会将当前的偏移地址进行保存起来,这样在下次执行时就知道执行到哪一步了,因此程序计数器是私有的(如果只有一个的话,执行其他线程时存放的偏移地址可能会重复,这样程序计数器就不知道执行到哪一步了)。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static int methodOne(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 8
2: istore_1
3: iload_0
4: iload_1
5: iadd
6: istore_2
7: iload_2
8: ireturn

虚拟机栈

虚拟机栈(JAVA VIrtual Machine Statcks):存储当前线程运行方法所需要的数据、指令、返回地址;
既然是虚拟机栈,那么也就是栈(后进先出),想到了栈就会有压栈和出栈,但是我们呀栈栈是什么呢?这里将压栈出栈的最小单位定位栈帧。
栈帧里存放的又是什么呢?在Java虚拟机中,栈帧用来存储局部变量表、操作数栈、动态链接、方法出口等信息;每个方法在执行的时候都会创建一个栈帧。

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量表。

上面代码中“locals=3,args_size=1”代表该方法局部局部变量表数量为3,方法参数数目为1。

那么局部变量表的单位是什么呢?

大家可能见到过一个单词”Slot”,也就是Variable Slot,中文的意思也就是变量槽,变量槽为局部变量的基本单位,方法中的局部变量信息也就存放在变量槽中。

我们这里取32为的虚拟机,在32位的虚拟机中,一个Slot可以存放一个32位以内的数据类型,存放8种数据类型,byte、short 、int 、float 、boolean、char、reference(引用类型,表示对一个对象的引用,在虚拟机中规范中没有说明它的长度,也没有明确指出这种引用应该有怎样的结构,一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在 Java 堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现 Java 语言规范中定义的语法约束约束)、returnAddress (返回地址,已经很少见到了,它是为字节码指令 jsr、jsr_w 和 ret 服务的,指向了一条字节码指令的地址,很古老的 Java 虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替)。针对64位的数据类型,虚拟机会以高对齐的方式分配两个连续的Slot,数据类型为long,double。

还需要注意的是Slot是可以重用的。复用的目的当然是为了节省栈帧空间。那么我们就需要考虑如何进行复用的了。简单的说就是方法中定义的变量,其作用域并不一定会覆盖整个方法,如果当前自己码PC计数器的值已经超过了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用,但是会有一些副作用,会影响到JVM的垃圾回收行为。

首先我们先运行下面代码: 运行之前我们需要配置环境变量,这样我们就能看到回收信息,在debug中进行配置

1
2
3
4
5
6
7
8
9
public class SlotTest {


public static void main(String[] args) {
byte[] placeholder = new byte[32*1024*1024];
System.gc();
}

}

打印信息为

1
2
[GC (System.gc())  35389K->33664K(251392K), 0.0168623 secs]
[Full GC (System.gc()) 33664K->33421K(251392K), 0.0081896 secs]

上面并没有进行回收32M的数据,我们将它进行改造为下面的代码

1
2
3
4
5
6
7
8
9
public class SlotTest {

public static void main(String[] args) {
{
byte[] placeholder = new byte[32 * 1024 * 1024];
}
System.gc();
}
}

我们再次观察运行后的信息

1
2
[GC (System.gc())  35389K->33600K(251392K), 0.0178175 secs]
[Full GC (System.gc()) 33600K->33421K(251392K), 0.0081758 secs]

从上面的信息中,我们发现虚拟机并没有进行回收32M的数据。

我们再次进行修改代码

1
2
3
4
5
6
7
8
9
10
public class SlotTest {

public static void main(String[] args) {
{
byte[] placeholder = new byte[32 * 1024 * 1024];
}
int b = 0;
System.gc();
}
}

运行后的记过如下

1
2
[GC (System.gc())  35389K->832K(251392K), 0.0009699 secs]
[Full GC (System.gc()) 832K->653K(251392K), 0.0038388 secs]

从上面的三个代码我们进行分析,首先创建的32M的数据,虽然没有被引用,但是在方法内部,JVM并没有进行回收,第二个代码,虽然已经没有代码的引用,但是变量槽Slot并没有被覆盖,所以第二块代码仍然没有回收,第三处代码我们创建新的变量,这个时候32M的变量槽被复用,导致垃圾回收器进行回收。

操作数栈

操作数栈(Operand Stack)也常被称操作栈,它是一个后入先出栈。

操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问

我们从简单的一个代码为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class HelloWorld {

public static void main(String[] args) {
int a = 10;
methodOne(a);
}

public static int methodOne(int i) {
int b = 8;
int c= i+b;

return c;

}
}

我们进行查看生成的字节码,将class文件进行反汇编
javap是 Java class文件分解器,可以反编译(即对javac编译的文件进行反编译),也可以查看java编译器生成的字节码

1
javap -c -v HelloWorld.class > HelloWorld.txt

将指令输出到HelloWorld.txt中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
Classfile /C:/Users/wsylp/Desktop/study/性能优化/code/HelloWorld.class
Last modified 2019-1-9; size 365 bytes
MD5 checksum b16686878d8381f6497431a1e9932515
Compiled from "HelloWorld.java"
public class HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Methodref #3.#16 // HelloWorld.methodOne:(I)I
#3 = Class #17 // HelloWorld
#4 = Class #18 // 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 methodOne
#12 = Utf8 (I)I
#13 = Utf8 SourceFile
#14 = Utf8 HelloWorld.java
#15 = NameAndType #5:#6 // "<init>":()V
#16 = NameAndType #11:#12 // methodOne:(I)I
#17 = Utf8 HelloWorld
#18 = Utf8 java/lang/Object
{
public HelloWorld();
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 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: bipush 10
2: istore_1
3: iload_1
4: invokestatic #2 // Method methodOne:(I)I
7: pop
8: return
LineNumberTable:
line 4: 0
line 5: 3
line 6: 8

public static int methodOne(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 8
2: istore_1
3: iload_0
4: iload_1
5: iadd
6: istore_2
7: iload_2
8: ireturn
LineNumberTable:
line 9: 0
line 10: 3
line 12: 7
}
SourceFile: "HelloWorld.java"

上面为输出的结果,我们来看mian方法的指令

1
2
3
4
5
6
7
8
9
10
11
12
13
public static int methodOne(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 8
2: istore_1
3: iload_0
4: iload_1
5: iadd
6: istore_2
7: iload_2
8: ireturn

在上述字节码指令示例中

  1. 首先将由 “bipush” 指令将byte类型的10转换为int类型后压入操作数栈(对于byte、short、char类型的值在入栈前,会被转换为int类型)
  2. 成功入栈后,”istore_1”指令便会将栈顶元素出栈并存储到环境变量表中索引为1的Slot上。
  3. “iload_0” 和 “iload_1” 指令会负责将局部变量表中索引为”0”和”1”的slot上的数值10和8重新压入操作数栈的栈顶
  4. “iadd” 指令会将两个数值出栈执行家法运算后再将运算结果重新压入栈顶
  5. “istore_2” 指令会将运算结果出栈并存放在局部变量表中索引为2的slot上。
  6. “iload_2” 指令将局部变量中的索引为2的数值进行压入栈中
  7. “ireturn” 指令时将方法也就是操作数栈中的数据进行返回操作。

在操作栈中,一项运算通常由多个子运算嵌套进行,一个子运算的结果可以被其他外围运算所使用。
注意的时,操作数栈中的数据必须正确的操作,不能压入两个int的数值,却把他当作long类型的数值去操作。

命令意义
iconst_1将一个byte型常量值推送至栈顶
bipush将一个byte型常量值推送至栈顶
iload_1第二个int型局部变量进栈,从0开始计数
istore_1将栈顶int型数值存入第二个局部变量,从0开始计数
iadd栈顶两int型数值相加,并且结果进栈
return当前方法返回void
getstatic获取指定类的静态域,并将其值压入栈顶
putstatic为指定的类的静态域赋值
invokevirtual调用实例方法
invokespecial调用超类构造方法、实例初始化方法、私有方法
invokestatic调用静态方法
invokeinterface调用接口方法
new创建一个对象,并且其引用进栈
newarray创建一个基本类型数组,并且其引用进栈

此时我这边产生了一个疑问?它存在的意义是什么呢?

虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。常量池中存在的大量引用,在运行期间转化为直接引用,这部分就是动态链接。

方法返回地址

当一个方法在执行过程中有两种方式退出该方法,一种是遇到返回的字节码指令,这种叫做正常完成出口,另外一种是执行中出现异常,称为异常完成出口。代码在执行的过程中,执行结束方法后我们需要进行继续往下执行,所以我们需要记录方法开始被调用的地方。方法退出的过程等于把当前栈帧出栈,因此在退出的时候可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(非void)压入点用着栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后的一条指令等。

本文标题:JVM之运行时数据区(一)程序计数器与虚拟机栈

文章作者:wsylp

发布时间:2019年01月13日 - 10:01

最后更新:2020年01月02日 - 10:01

原始链接:http://wsylp.top/2019/01/13/JVM之运行时数据区(一)程序计数器与虚拟机栈/

许可协议: 本文为 wsylp 版权所有 转载请保留原文链接及作者。

-------------本文结束感谢阅读-------------