【运行时数据区】——程序计数器、虚拟机栈

思思问问

一、运行时数据区

1.1 概述

内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局。

JVM 内存共分为本地方法栈、程序计数器、虚拟机栈、堆、方法区五个部分。这些区域有各自的用途和创建与销毁的时间。有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

在上图中,灰色部分为线程隔离的数据区域,其他部分为线程共享的区域。

运行时数据区是否线程共享是否存在内存溢出是否存在GC
本地方法栈
虚拟机栈
程序计数器
堆区
方法区

1.2 JVM系统线程

JVM允许一个应用有多个线程并行的执行。在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。

  • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。如果使用jconsole或者是其他调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main(String[ ] args)的main线程以及所有main线程创建的线程。

这些后台系统线程在Hotspot JVM里主要是以下几个:

  • 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
  • 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
  • GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
  • 编译线程:这种线程在运行时会将字节码编译成到本地代码。
  • 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。

二、程序计数器

2.1 概述

程序计数器(Program Counter Register)是一块较小的内存空间,是运行速度最快的存储区域。它可以看作是当前线程所执行的字节码的行号指示器。Register的命名源于CPU的寄存器,存储指令相关的现场信息。这里,并非指广义上所指的物理寄存器,或许将其翻译为PC寄存器(或指令计数器)会更加贴切(也称为程序钩子)。JVM中的程序计数器是对物理PC寄存器的一种抽象模拟

2.2 作用

程序计数器用来存放下一条指令的地址(将要执行的字节码指令地址)。在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned)。

它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

程序计数器中不存在内存溢出。

代码演示

public class PCRegisterTest {
    public static void main(String[] args) {
        int i = 10;
        int j = 20;
        int k = i + j;
    }
}

然后将代码进行编译成字节码文件,查看 发现在字节码的左边有一个行号标识,它其实就是指令地址

 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

通过程序计数器,我们就可以知道当前程序执行到哪一步了 。

使用程序计数器存储字节码指令地址有什么用呢?

因为CPU需要不停的切换各个线程,在线程切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变程序计数器的值来明确下一条应该执行什么样的字节码指令。

PC寄存器为什么被设定为线程私有的?

多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

CPU时间片

CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。

在宏观上:可以同时打开多个应用程序,每个程序并行不悖,同时运行。

但在微观上:一个CPU一次只能处理程序要求的一部分。如何处理公平,一种方法就是引入时间片,每个程序轮流执行。

三、虚拟机栈

3.1 概述

由于跨平台性的设计,Java的指令都是根据栈来设计的(由于不同平台CPU架构不同,所以基于寄存器设计)。

  • 优点:跨平台,指令集小,编译器容易实现。
  • 缺点:性能下降,实现同样的功能的指令更多。

常有人把Java内存区域笼统地划分为堆内存(Heap)和栈内存(Stack),这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。“栈”通常就是指这里讲的虚拟机栈,可以明确的是——栈是运行时的单位,而堆是存储的单位。

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
  • 堆解决的是数据存储的问题,即数据怎么放,放哪里。

那么Java虚拟机栈又是什么

每个Java虚拟机线程都有一个私有的Java虚拟机栈(Java Virtual Machine Stack),与该线程同时创建,其内部保存一个个的栈帧(Stack Frame),对应着每一次的Java方法调用。其生命周期和线程保持一致。

作用

主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。

局部变量,它是相比于成员变量来说的(或属性)

基本数据类型变量 VS 引用类型变量(类、数组、接口)

栈的特点

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。JVM直接对Java栈的操作只有两个:

  • 每个方法执行,伴随着压栈(入栈、进栈)
  • 执行结束后的出栈工作

对于栈来说不存在垃圾回收的问题(存在内存溢出的问题)。

与Java虚拟机栈相关异常

如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出StackOverflowError异常。

如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常。

public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        System.out.println(count++);
        main(args);// Exception in thread "main" java.lang.StackOverflowError错误
    }
}

设置栈内存大小

我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。

-Xss1m
-Xss1k

3.2 栈的存储单位

(1)栈帧

每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

OOP的基本概念:类和对象

类中基本结构:field(属性、字段、域)、method

JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则。

在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。

执行引擎运行的所有字节码指令只针对当前栈帧进行操作。如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

例子:

public class StackFrameTest {
    public static void main(String[] args) {
        method01();
    }

    private static int method01() {
        System.out.println("方法1的开始");
        int i = method02();
        System.out.println("方法1的结束");
        return i;
    }

    private static int method02() {
        System.out.println("方法2的开始");
        int i = method03();
        System.out.println("方法2的结束");
        return i;
    }

    private static int method03() {
        System.out.println("方法3的开始");
        int i = 30;
        System.out.println("方法3的结束");
        return i;
    }
}

输出结果为

方法1的开始
方法2的开始
方法3的开始
方法3的结束
方法2的结束
方法1的结束

满足栈先进后出的概念,通过DEBUG,也能够看到栈相关信息:

(2)栈运行原理

不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出

(3)栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息

并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表和操作数栈决定的。

3.3 局部变量表

(1)概述

局部变量表(Local Variables),又称局部变量数组或本地变量表,是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括基本数据类型(8种)、对象引用(reference),以及returnAddress(指向了一条字节码指令的地址)类型。

由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的Maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

例如上面案例的方法1:

方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

(2)关于Slot的理解

参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

局部变量表,最基本的存储单元是Slot(变量槽)。局部变量表中存放编译期可知的基本数据类型、引用类型、returnAddress类型的变量。

在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot

byte、short、char 、boolean在存储前被转换为int。0表示false,非0表示true。

JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。

当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上。如果需要访问局部变量表中一个64位的局部变量值时,只需要使用前一个索引即可。(比如:访问long或doub1e类型变量)

如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。

Slot的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

(3)静态变量与局部变量的对比

变量的分类:

  • 按数据类型分:基本数据类型、引用数据类型
  • 按类中声明的位置分:成员变量(类变量,实例变量)、局部变量

    • 类变量:Linking的Prepare阶段,给类变量默认赋值,initial阶段给类变量显示赋值即静态代码块。
    • 实例变量:随着对象创建,会在堆空间中分配实例变量空间,并进行默认赋值。
    • 局部变量:在使用前必须进行显式赋值,不然编译不通过。

参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。

我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。

和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

public void test(){
    int i;
    System.out.println(i);//报错,局部变量没有赋值不能使用。
}

在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。

局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

3.4 操作数栈

(1)概述

每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last - In - First -Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)。

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop)

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈
  • 比如:执行复制、交换、求和等操作

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是的。

这个时候数组是有长度的,数组一旦创建,长度是不可变的。

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。

栈中的任何一个元素都是可以任意的Java数据类型

  • 32bit的类型占用一个栈单位深度
  • 64bit的类型占用两个栈单位深度

操作数栈并非采用访问索引的方式来进行数据访问,而是只能通过标准的入栈和出栈操作来完成一次数据访问。

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。|

另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

(2)代码追踪

以下面代码为例子:

public void testAddOperation() {
    byte i = 15;
    int j = 8;
    int k = i + j;
}

使用javap 命令反编译class文件: javap -v 类名.class

 0 bipush 15
 2 istore_1
 3 bipush 8
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return

从上面的代码我们可以知道,我们都是通过bipush对操作数 15 和 8进行入栈操作,同时使用的是 iadd方法进行相加操作,i -> 代表的是int类型的加法操作。

Tips:byte、short、char、boolean 内部都是使用int型来进行保存的。

执行流程如下所示:

首先执行第一条语句,PC寄存器指向的是0,也就是指令地址为0,然后使用bipush让操作数15入栈。

执行完后,让PC + 1,指向下一行代码,下一行代码就是将操作数栈的元素存储到局部变量表1的位置,我们可以看到局部变量表的已经增加了一个元素。

为什么局部变量表不是从0开始的呢?

局部变量表也是从0开始的,但是因为0号位置存储的是this指针,这里省略书写了。

然后PC+1,指向的是下一行。让操作数8也入栈,同时执行store操作,存入局部变量表中。

然后从局部变量表中,依次将数据放在操作数栈中。

然后将操作数栈中的两个元素执行相加操作,并存储在局部变量表3的位置。

最后PC寄存器的位置指向10,也就是return方法,则直接退出方法。

(3)栈顶缓存技术

栈顶缓存技术:Top Of Stack Cashing

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

3.5 动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如invokedynamic指令。

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

为什么需要运行时常量池?因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,节省了空间。

常量池的作用:就是为了提供一些符号和常量,便于指令的识别。

3.6 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 正常执行完成
  • 出现未处理的异常

执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称“正常调用完成”(Normal Method Invocation Completion):

  • 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
  • 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn。另外还有一个return指令声明为void的方法,实例初始化方法,类和接口的初始化方法使用。

在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成“(Abrupt MethodInvocation Completion)。

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值

3.7 附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。

参考

深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)

运行时数据区Oracle官网介绍

阅读 999

我思
Stay Hungry, Stay Foolish!

Stay Hungry, Stay Foolish!

24 声望
3 粉丝
0 条评论

Stay Hungry, Stay Foolish!

24 声望
3 粉丝
文章目录
宣传栏