第三节:Java内存区域之运行时数据区概述及线程、程序计数器
1、概述
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙” ,对于C/C++程序员来说,在内存管理区域,即拥有每一个对象的“所有权”,又担负着每一个对象生命开始到终结的维护责任。对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。但是,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会成为一项异常艰难的工作。
因此就必须掌握 java虚拟机内存的各个区域、服务对象以及其中可能产生的问题
2、运行时数据区概述
java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据java虚拟机规范的规定,Java虚拟机将管理的内存分为以下几个运行时数据区域,如下图所示,它是在类加载完成后的阶段
当我们通过前面的:类的加载-> 验证 -> 准备 -> 解析 -> 初始化 这几个阶段完成后,类的一些信息就会加载到内存,保存到运行时数据区,然后执行引擎将会使用到我们的运行时数据区,对我们的类进行使用
好比大厨做饭,我们把大厨后面的东西(切好的菜,刀,调料),比作是运行时数据区。而厨师可以类比于执行引擎,将通过准备的东西进行制作成精美的菜品
3、JVM经典内存布局
内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM这种内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局。
我们通过磁盘或者网络IO得到的数据,都需要先加载到内存中,然后CPU从内存中获取数据进行读取,也就是说内存充当了CPU和磁盘之间的桥梁
运行时数据区的完整图
Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
灰色的为单独线程私有的[即一个线程一份],红色的为多个线程共享的[一个进程一份,多个线程共享]。即:
- 每个线程:独立包括程序计数器、虚拟机栈、本地栈。
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存(理解为方法区的落地实现)--图中是方法区)
- 比如一个进程里面有5个线程,则就有5组程序计数器、虚拟机栈、本地栈,并且这5个线程共用1个 方法区[非堆空间] 和堆空间
Runtime类:关于线程间共享的说明
每个JVM只有一个Runtime实例,即为运行时环境[运行时数据区,对于每个虚拟机只有一份],相当于内存结构的中间的那个框。
3、线程
线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。
在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射,线程的调度底层由操作系统实现。
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。
- 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦操作系统的本地线程初始化成功,它就会调用Java线程中的run()方法。
JVM系统线程[守护线程]
如果你使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main(String[])的main线程以及所有这个main线程自己创建的线程。这些主要的后台系统线程在Hotspot JVM里主要是以下几个:
- 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
- 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
- GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
- 编译线程:这种线程在运行时会将字节码编译成到本地代码。
- 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。
4、程序计数器(PC寄存器)
JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。他可以看作是当前线程所执行的字节码的行号指示器。
在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此为了使线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各类线程之间计数器互不影响,因此程序计数器是线程私有内存。
如果线程正在执行的是一个Java方法,也就是所谓的当前方法【当前栈帧里面】,这个计数器记录的是正在执行的Java方法的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器的值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域,也没有GC
5、PC寄存器作用
PC寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
6、代码演示
我们首先写一个简单的代码
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
}
}
然后将代码进行编译[build-compile]成字节码文件,我们再次查看[编译完的字节码文件在out目录下] ,终端进行反编译解析【cd进入该目录,javap -verbose PCRegisterTest.class】:发现在字节码的左边有一个行号标识,它其实就是指令地址(偏移地址,就是PC寄存器里面存储的结构),用于指向当前执行到哪里。后面就是具体的操作指令
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return
具体反编译后的源码如下:
D:\workspace_ideal\java>cd out/production/java
D:\workspace_ideal\java\out\production\java>javap -verbose PCRegisterTest.class
D:\workspace_ideal\java\out\production\java>javap -verbose PCRegisterTest.class
Classfile /D:/workspace_ideal/java/out/production/java/PCRegisterTest.class
Last modified 2021-9-12; size 453 bytes
MD5 checksum bfd695198f1b55632d0272abeb7cf096
Compiled from "PCRegisterTest.java"
public class PCRegisterTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#21 // java/lang/Object."<init>":()V
#2 = Class #22 // PCRegisterTest
#3 = Class #23 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 LPCRegisterTest;
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 args
#14 = Utf8 [Ljava/lang/String;
#15 = Utf8 i
#16 = Utf8 I
#17 = Utf8 j
#18 = Utf8 k
#19 = Utf8 SourceFile
#20 = Utf8 PCRegisterTest.java
#21 = NameAndType #4:#5 // "<init>":()V
#22 = Utf8 PCRegisterTest
#23 = Utf8 java/lang/Object
{
public PCRegisterTest();
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 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LPCRegisterTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return
LineNumberTable:
line 8: 0
line 9: 3
line 10: 6
line 11: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 args [Ljava/lang/String;
3 8 1 i I
6 5 2 j I
10 1 3 k I
}
SourceFile: "PCRegisterTest.java"
通过PC寄存器,我们就可以知道当前程序执行到哪一步了,比如:
- PC寄存器里面存储指令地址5,执行引擎会去寄存器里面取出指令地址5对应的字节码指令
istore_2去运行[istore_2这个指令做了一个保存,存在于局部变量表]- (具体就是要执行引擎运行字节码指令,然后字节码指令执行的时候是内部字节码解释器在工作)
- 程序中一条字节码指令执行完就执行下一条,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
- 执行引擎还能把这些指令翻译成机器指令从而调用CPU执行。
5、PC寄存器两个常见问题
1、使用PC寄存器存储字节码指令地址有什么用呢?
同:为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
2、PC寄存器为什么被设定为线程私有的?
我们都知道java虚拟机所谓的多线程是在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证线程切换后分毫无差的恢复到正确的执行位置呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。具体的线程调用时操作系统的事情
由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
6、CPU时间片
CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。在宏观上:俄们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。