前端开发入门到精通的在线学习网站

网站首页 > 资源文章 正文

Java高级编程基础:详细解读虚拟机底层栈帧线程模型

qiguaw 2024-10-21 07:54:44 资源文章 89 ℃ 0 评论

前言

上一篇Java高级编程基础中,我从一个内存空间管理的角度,简单的说了一下我对JVM的理解,以及JVM各部分结构的关系,没有详细的去解释每一部分功能,这里我想通过Java应用程序在JVM支持下执行过程,来说一下JVM的线程模型和执行细节,希望对想学习Java的小伙伴有所帮助。

线程与JVM

我们都知道线程被定义为一段代码的执行。我们可以将一个简单的程序通过唯一的代码执行完成,对于一个复杂的任务我们可能将其分解成多段并行的代码同时执行,这便是我们常说的单线程和多线程编程。对于Java虚拟机来说,它在运行一个应程序时显然是一个多线程应用。

就拿我们常见的Hotspot VM来说,按照它的规范约定Java的每个线程定义都与本机操作系统线程之间存在着直接的映射关系。

在Java中我们有个Thread类,用来定义一个线程的状态和操作描述,我们用它来抽象一个线程,或者说抽象一段代码的执行。

用它来指定本地存储,分配执行所需缓冲区,同步一些操作的对象,创建栈区,初始化程序计数器等一系列准备工作,这些完成后我们会用一个start()方法来通知操作系统,操作系统接到通知后会在适当的时间在操作系统中创建一个本机线程并开始执行相关代码。

如果我们在Java程序中终止了这个Java线程,那么对应的操作系统会回收本机对应的线程。对于多个线程的执行,Java程序没有什么指定和调度权限而是由操作系统负责调度它们并分派它们具体在哪个CPU内核上执行。

在深入看线程的执行过程,当我们Java程序调用start()方法后,完全交给操作系统来处理了,操作系统在创建完成本机的线程后,便会调用Java线程的run()方法。

在run()方法执行完毕后,操作系统的本机线程会确认JVM是否需要因为本线程的执行完成而终止,也就是它会判断当前执行的线程是不是最后一个非看护线程,如果是则本机线程会终止,Java线程释放所有资源。如果执行过程中出现异常,本机线程将会被终止,然后倾倒异常信息,对应的Java线程进入异常处理状态。这就是一个Java线程执行的过程。

JVM系统线程

接下来我们详细看一下,JVM在运行一个Java应用程序时为什么是一个多线程的应用,也就是说详细看一下JVM运行时涉及到的系统线程。

要看Java代码的执行,我们通常会使用像jconsole或者其他调试器来查看其背后运行的哪些一般不路面的线程。

我们说当一个Java应用程序开始运行时,在后台除了我们熟知的作为调用公共静态void main(String[])方法创建的主线程之外,还会有由主线程创建的执行其它代码的子线程。

我们还拿Hotspot JVM中的主要后台系统线程来举例,对于虚拟机来说会有大概五种线程在运行:

虚拟机线程:

这个线程等待出现需要JVM达到安全点的操作。什么意思呢,就是说这个线程要求操作必须在单个线程上执行,因为它们必须保证JVM处于一个安全的位置,不能对堆内容进行修改。 它执行的操作包括突然停止所有内容进行的垃圾收集,线程堆栈因中断需要进行的转储,线程被挂起以及偏向锁定被撤销等这类操作。

周期性任务线程:

这个线程任务简单就是负责定时器事件,也就是我们常说的中断事件,通过这种范式我们可以调度周期性操作的执行。

GC线程:

大名鼎鼎的垃圾收集器线程,这些线程支持JVM中发生的各种类型的垃圾收集活动。

编译器线程:

这些线程被创建出来主要是在程序运行是将从文件中读取的字节码编译为本机代码,供本机系统调用执行。

信号量分发线程:

这个线程是一个监控线程,它负责接收发送到JVM进程的信号,并通过调用适当的JVM方法在JVM内部处理这些信号。

也就是说JVM在支持Java应用程序运行时,至少有上述五类系统线程在执行。

线程结构

我们说了线程就是一段代码在执行,它有状态,有准备,有开始,有挂起,有激活,有终止等外在表现和行为。那么它的内部是怎么样的呢?

线程在JVM里是最重要的执行单位,它会为每个线程创建一个独立的执行栈空间,将线程要执行代码中的每个方法封装为一个帧。对于它执行的每个地址空间都会通过程序计数器记录。当然,还会为用到的本机方法创建相应的执行栈。

程序计数器(PC)

它记录当前操作指令或者说操作码的地址,当然注意,如果是执行的是本机的原生函数方法,它无法记录这些。通常如果当前方法是本机的,PC一般是显示未定义的。

每个CPU都有一个程序计数器,通常程序计数器在每条指令执行之后都会递增,所以,它永远记录着下一条要执行的指令的地址。

我们的JVM就是使用它来跟踪自己执行指令的位置,实际上这个地址是指向了方法区域中的一个内存地址。

执行栈

我们说过每个线程都有自己的执行栈,它就是一个执行程序的暂存空间,它会把在这个线程上执行的每个方法都保存为一个帧。

执行栈最大的特点就是后进先出(LIFO)数据结构,因此当前执行的方法指针永远指向堆栈的顶部。

它为调用的每一个方法都创建一个新帧并将其添加或者说压入到栈顶部。

如果当前调用的方法正常返回,或者在方法调用期间抛出未捕获异常时,它就会将删除或者说弹出该方法帧。

一定要注意这个执行栈的特点是除了推入要执行的帧和弹出要删除的帧对象外,它不直接操作内容,因此可以在堆中来分配帧对象,即使内存空间不连续都没关系。

本机方法栈

其实,并不是所有JVM都支持本机方法执行,凡是那些获得支持的本机方法,JVM通常会为每个线程都创建一个本机方法执行栈。

如果JVM是使用C连接模型实现的Java本机调用(JNI),那么这个本机执行栈将是一个C栈。

在这种情况下,参数和返回值的顺序在本机执行栈中与典型的C程序相同的,此时本机的方法可以回调JVM的Java方法。

这种本机Java调用将发生在执行栈上,线程也将离开本机执行栈并我们JVM的标准执行栈上创建一个新方法帧。

栈的相关约束

栈的大小可以是动态的,也可以是固定大小的。如果线程需要的栈大小超出了允许的栈空间,则抛出StackOverflowError异常。

如果一个线程需要为调用方法创建一个新帧,但是没有足够的内存来分配它,那么就抛出OutOfMemoryError异常。

方法帧

JVM会为每个方法调用创建一个新帧并将这个帧压入到执行栈的顶部。

当方法正常返回时,或者在方法调用期间抛出未捕获异常时,说明该帧的方法执行完毕,就会将该帧弹出删除。

如果进入每个方法帧内部看看的话,我们会发现它一般由局部变量数组,方法返回值,操作数栈和引用当前方法类的运行时常量池构成。

其中,局部变量数组包含方法执行期间使用的所有变量,包括对该方法的引用、所有方法参数和其他局部定义的变量。对于类方法也就是静态方法来说,方法参数从零开始存储,但是,对于实例方法,则保留零位。

我们知道Java语言定义了基础数据类型中除了long和double之外,所有类型都在局部变量数组中占用一个插槽,而long和double都占用两个连续的插槽,因为这些类型都是64位而不是32位,属于双宽度的。

对于操作数栈来说,它主要在字节码指令执行期间使用,它操作方式类似于在本机CPU中使用通用寄存器。大多数JVM字节码通过推入、弹出、复制、交换或执行产生或使用值的操作来处理操作数堆栈。所以,在局部变量数组和操作数堆栈之间移动值的指令在字节码中出现次数非常频繁。

最后说一下动态链接,因为每个帧是对执行方法的封装,执行方法在哪里?其实每个帧都包含对运行时常量池的引用,而引用指向的正是该帧执行的方法所在类的常量池。这个引用方式支持动态链接。

怎么理解呢?举个例子吧,比如,我们通常把C/C++代码编译成一个目标文件,然后把多个目标文件链接在一起,生成一个可用的工件,可以是可执行文件或一个dll。

在代码执行前,需要经过一个链接阶段,就是将每个对象文件中的符号引用替换为最终可执行文件相关的实际内存地址。

而在我们Java语言中,这个链接阶段是在运行时动态完成的。我们编译Java类时,会将变量和方法的所有引用都存储在类的常量池中,作为符号引用。

符号引用是一种逻辑引用,不是实际指向物理内存位置的引用。JVM实现可以选择何时解析符号引用,它可以在类文件被验证时发生,在加载之后,调用预处理或静态解析时都可以执行。也可以在第一次使用符号引用时调用延迟解析处理。

虽然是延迟处理,但是JVM还必须表现得好像每个引用都是第一次使用而进行解析一样,并且可以在此时抛出任何解析错误。

而其绑定过程是由符号引用所标识的字段、方法或类被直接引用替换的过程,这种情况只发生一次,因为符号引用被完全替换。

总结一下

由于前一篇文章我只是提供了一个理解JVM的思路,通过一个整体上的空间比喻来理解JVM到底是什么,这里我具体的将JVM当成一个应用程序,从其结构和线程执行模型上深入说了一下内部细节,包括线程和对应的执行栈结构,封装执行方法的帧,及其内部构成等。

希望这样简单的概括能够让想学习Java的小伙伴们有一个从整体到局部的认知和思考路径,然后在从局部细节中跳回到整体大局,如此更好的理解JVM和它在我们Java应用程序执行过程中起到的作用和底层表现,从而深刻理解JVM在高级编程中的重要性。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表