JVM运行时区域
JVM在执行Java程序的过程中会把他所管理的内存划分成不同的数据区,这些数据区都有各自的的用途,以及创建和销毁的时间,有的区域锁着JVM进程的启动而存在。有些区域则依赖用户线程的启动和结束从而建立和销毁。
JVM的运行时数据区和JVM其他组件的之间的关系如下:
上图是基于《深入理解Java虚拟机》的图画的,说明了运行时数据区在JVM中的作用。基于对JVM运行时数据区的理解,基于上图的修饰,画出了JVM运行时区域的每个组成部分的详细信息及与JVM中执行引擎等组件的关系,如下图:
通过上图可以看到JVM运行时区域包括方法区、堆、栈(线程)、程序计数器和本地方法栈这几个区域,当然Java也提供了可以操作直接内存的API,所以直接内存也可以算入JVM的运行时区域。上面已经提到有些区域是随着JVM进程的创建及终结而建立和销毁,而有的区域则随着线程的创建及终结而建立和销毁,这是因为JVM运行时数据区有的区域是线程共享的,而有些是现场私有的。如上图所示:
- 线程共享区域: 堆、方法区;
- 线程私有区域:栈、本地方法栈、程序计数器;
接下来就来一一了解各个区域的作用。
程序计数器
程序计数器可以看做是当前线程所执行的字节码的行号指示器。字节码执行引擎在执行字节码时,就是通过改变这个计数器从而来选取下一条指令,从而实现分支、循环、跳转等功能的,它是线程私有的。
同时,由于JVM的多线程是通过轮流切换进行线程的替换执行的,通过维护程序计数器记录当前执行的行号,当再次获取到执行权时,从程序计数器指示的行号继续执行。还有就是我们通常看到的异常具有具体的行号信息,就是通过程序计数器的行号记录而实现的。可以通过一个示例来演示。通过使用javap -v XXX.class来查看其中一个方法的反编译指令如下:
示例代码如下:
package com.binga.jvm.runarea;
/**
* @Description: 运行时区域之栈结构示例
* @Author: binga
* @Date: 2020/8/20 22:46
* @Blog: https://blog.csdn.net/pang5356
*/
public class Math {
public static void main(String[] args) {
Math math = new Math();
int compute = math.compute();
System.out.println(compute);
}
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
}
通过反编译的字节码指令如下:
public int compute();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
LineNumberTable:
line 18: 0
line 19: 2
line 20: 4
line 21: 11
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this Lcom/binga/jvm/runarea/Math;
2 11 1 a I
4 9 2 b I
11 2 3 c I
注意LineNunberTable,这里就是存储的字节码行号于代码中行号的对应关系:
而程序计数器是记录的后列的行号,而在抛出异常时打印的堆栈就是通过这个LineNunberTable从而定位到实际的代码中的行号。
栈(线程)
Java虚拟机会为每一个线程在栈空间中分配一块内存,作为线程运行时空间的使用,由于该结构为栈的的结构,所以称之为栈。为每一方法分配一个栈帧,用于存储每一方法中的局部变量等信息。每一个栈帧中包括以下部分:
- 局部变量表:用于存储方法中的局部变量;
- 操作数栈:用于存储操作数的栈,先进后出;
- 动态链接:用于存储方法中引用类型变量的动态引用;
- 方法出口:用于记录当前方法执行完成后,应该从调用方法的何处继续执行,可以理解为存储的是调用方法的程序计数器的值。
我们来用一段代码为例,然后给出一个线程运行时私有的区域结构,首先来看一下实例代码:
public class Math {
public static void main(String[] args) {
Math math = new Math();
int compute = math.compute();
System.out.println(compute);
}
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
}
那么在执行时,这个主线线程main线程的运行时区域如下:
可以看到线程私有的程序计数器和本地方法栈,以及其栈,在程序运行时先执行main方法,所以在栈中为main方法创建一个栈帧,并将栈帧压入栈,当执行到math.conpute()时,进入compute方法,然后为compute方法创建一个栈帧并压入栈,当compute方法执行完毕后然后将compute栈帧出栈,然后继续执行main方法。
那么栈帧中的各个区域的作用都是什么作用呢?
局部变量表和操作数栈
局部变量表就是用于存储方法中声明的每一个变量及其值,其结构我们可以看成一个数组,所以代表局部变量声明的顺序,而其值则是变量指向的值,如下:
需要注意的是,在index为0的位置存储的是对当前对象this的引用,这也可以解释为什么在非静态方法中可以使用this。在局部变量表中对于基本数据类型的局部变量,值会在局部变量表中分配存储(对于long和double需要占两个局部变量空间),而对于引用类型则存储的是对象的引用。局部变量表的大小在编译时就可以确定,在运行时局部变量表的大小不会再进行改变。
操作数栈顾名思义也是一个栈结构,在方法运行期间,通过操作数据栈,对数据进行计算和操作。接下来通过反编译文件查看字节码指令来加深对局部变量表和操作数栈的理解。还是基于上面的Math类为示例,通过javap -c XXX.class的命令反编译一下Math的class文件来查看字节码指令。这里只列出compute方法的字节码指令:
public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
接下来就结合局部变量表和操作栈来分析compute中的字节码含义。
1、 iconst_1 将int类型常量1压入操作数栈
2、 istore_1 将int类型值存入局部变量1
3、 iconst_2 将int类型常量2压入栈
4、 istore_2 将int类型值存入局部变量2
5、 iload_1 从局部变量1中装载int类型值
6、 iload_2 从局部变量2中装载int类型值
7、 iadd 执行int类型的加法
8、 bipush 10 常数到操作数栈
9、 imul 乘
10、 istore_3 将int类型值存入局部变量3
11、 iload_3 从局部变量3中装载int类型值
12、 ireturn 从方法中返回int类型的数据
通过上面的图解流程大致的了解了局部变量表和操作数栈的作用。
方法出口
在局部变量表和操作数栈的流程知道当compute方法执行完成后,会继续执行main方法,那么应该从main方法的何处执行呢?答案就是方法出口,在方法出口存储着main方法的”程序计数器”,记录着main方法是从何处进入compute方法的,那么在compute方法执行结束后,则从方法出口取出的记录数继续从main方法中执行。
动态链接
动态链接就是在运行期间,将调用方法的符号引用转换为直接引用。这里还是通过javap -v XXX.class反编译字节码文件来举例分析。反编译文件如下,这里只罗列出main方法及常量池。
Constant pool:
#1 = Methodref #7.#28 // java/lang/Object."<init>":()V
#2 = Class #29 // com/binga/jvm/runarea/Math
#3 = Methodref #2.#28 // com/binga/jvm/runarea/Math."<init>":()V
#4 = Methodref #2.#30 // com/binga/jvm/runarea/Math.compute:()I
#5 = Fieldref #31.#32 // java/lang/System.out:Ljava/io/PrintStream;
#6 = Methodref #33.#34 // java/io/PrintStream.println:(I)V
#7 = Class #35 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcom/binga/jvm/runarea/Math;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 math
#20 = Utf8 compute
#21 = Utf8 I
#22 = Utf8 ()I
#23 = Utf8 a
#24 = Utf8 b
#25 = Utf8 c
#26 = Utf8 SourceFile
#27 = Utf8 Math.java
#28 = NameAndType #8:#9 // "<init>":()V
#29 = Utf8 com/binga/jvm/runarea/Math
#30 = NameAndType #20:#22 // compute:()I
#31 = Class #36 // java/lang/System
#32 = NameAndType #37:#38 // out:Ljava/io/PrintStream;
#33 = Class #39 // java/io/PrintStream
#34 = NameAndType #40:#41 // println:(I)V
#35 = Utf8 java/lang/Object
#36 = Utf8 java/lang/System
#37 = Utf8 out
#38 = Utf8 Ljava/io/PrintStream;
#39 = Utf8 java/io/PrintStream
#40 = Utf8 println
#41 = Utf8 (I)V
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class com/binga/jvm/runarea/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: istore_2
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
20: return
LineNumberTable:
line 12: 0
line 13: 8
line 14: 13
line 15: 20
LocalVariableTable:
Start Length Slot Name Signature
0 21 0 args [Ljava/lang/String;
8 13 1 math Lcom/binga/jvm/runarea/Math;
13 8 2 compute I
}
这里我们只关注main方法和常量池Constant Pool,分析如下:
-
我们可以通过调用conpute方法这行字节码指令查看
9: invokevirtual #4 // Method compute:()I
证明引用了常量池的#4,我们从常量池中查看#4可以发现
#4 = Methodref #2.#30 //com/binga/jvm/runarea/Math.compute:()I
-
方法引用,又引用了常量池中的#2和#30,我们再分别查看#2和#30
#2 = Class #29 // com/binga/jvm/runarea/Math #30 = NameAndType #20:#22 // compute:()I
其中,#2又引用了常量池中的#29,而#29为
#29 = Utf8 com/binga/jvm/runarea/Math
且#20和#22为
#20 = Utf8 compute #22 = Utf8 ()I
通过上面的查找,就可以确定了是哪个类的哪个方法,即com/binga/jvm/runarea/Math()I,I是返回类型为int,但是这是在编译时放入常量池中的符号,执行代码的话需要具体的字节码,那么通过符号查找到对应的方法区去中的字节码编译执行。流程如下图:
本地方法栈
本地方法栈是为调用本地方法留出的空间,用于本地方法执行时的空间使用。本地方法栈也是线程私有的。由于JVM是使用c/c++实现的,所以就涉及到调用底层的类库,当调用底层类库时,也是需要分配空间的,就如调用Java中的方法,而这一空间则由线程的本地方法栈提供。
方法区
方法区主要存储常量、静态变量及类元信息。使用的是直接内存。
堆内存
堆内存就是用来运行时创建的对象分配的区域,其结构如下:
如上图堆中的空间布局,默认的,老年代占堆总空间的2/3,年轻代占堆总空间的1/3,而在年轻代中,又分为eden区(新生代)和survivor区,默认的他们的比例为8:1:1。
一般情况下,对象先是在eden区创建,当eden区空间不足时会触发minor GC(只会收集年轻代的垃圾对象,包括eden区,from区和to区),将保存的对象存入from区,eden区继续为新生对象提供存储空间,当eden区空间不足时,则再次触发minor GC,将存活的对象放入to区,如此反复,在每次minor GC过程中,对象每存活下来一次,那么他的迭代年龄会增加1,当达到15(默认)时,对象会进入老年代,慢慢的当老年代的空间不足时会触发Full GC,当Full GC后任然空间不足,抛出OOM异常。
当然谈论到堆内存,就有一个特别注意的问题,就是对象一定在对中分配空间吗?结果是不一定的,这是JVM的一些优化手段如逃逸分析和标量替换等,使得对象有时在栈上分配。关于逃逸分析,后续再展开相关的知识。