上一篇:JVM笔记(2)—— 运行时数据区概述及线程 程序计数器、虚拟机栈、本地方法栈都是线程私有的,jvm中每个线程都有一份

一、程序计数器

JVM中的程序计数器是一个与PC寄存器功能类似的逻辑结构,用于记录当前线程要执行的下一条jvm指令的地址,解释器读取到对应的jvm指令后将其翻译成机器指令交给CPU去执行,并在程序计数器中更新下一条jvm指令的地址。

程序计数器是运行时唯一不会发生内存溢出的区域。

二、虚拟机栈

虚拟机栈是运行时的单位,而堆是存储的单位
即:栈是解决程序的运行问题,即程序如何执行。而堆解决的是数据存储的问题,即数据怎么放,放在哪

每个线程在创建时都会创建一个虚拟机栈,线程运行时每调用一个方法都会生成一个对应的栈帧压入栈,方法执行结束(正常return或内部抛出异常)则出栈,并将结果返回给前一个栈帧,使前一个栈帧成为当前活动栈帧,直到所有栈帧全部出栈,则线程执行结束,栈也同时销毁。虚拟机栈是线程私有的,生命周期和线程一致。

windows11 虚拟化性能计数器需要至少一个可正常使用的计数器 虚拟cpu性能计数器_java

1. 栈帧的内部结构

需要注意的是,栈帧中并不存储方法的代码指令,方法的字节码指令是存储在方法区对应类的类信息的对应方法信息中(具体就是在方法Code属性表的code属性值中),是所有线程共享的,栈帧中存储的是执行构成方法的一组指令所需的信息,分为以下五个部分

(1)局部变量表

局部变量表定义为一个数组,基本存储单元称为局部变量槽(Slot),用于存放windows11 虚拟化性能计数器需要至少一个可正常使用的计数器 虚拟cpu性能计数器_java_02,数据类型包括8类基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址),其中64位长度的long和double类型数据占用两个变量槽,其他类型占用一个。如果当前帧是由构造方法或者实例方法创建的,那么其局部变量表索引为0的slot处会存放其对象的this引用,其余参数按顺序继续排列。

局部变量表的大小在编译期就确定下来了,存储在方法Code属性表的max_locals属性中,当进入一个方法时,其栈帧的局部变量表所分配的内存空间是完全确定的,运行过程中也不会改变其大小。

windows11 虚拟化性能计数器需要至少一个可正常使用的计数器 虚拟cpu性能计数器_局部变量_03

因此我们可以很容易从另一个角度理解为什么在static方法中不能使用this指针,因为其局部变量表中就没有对象的this引用。
以及为什么类的静态方法中不可以直接调用类中非静态方法?因为创建一个非静态方法的栈帧需要传入对象的this指针,但是静态方法是直接通过类名去调用的,没有this指针可以传入

注意,并不是在方法中用了多少个局部变量,就把这些局部变量所占变量槽数量之和作为max_locals的值,操作数栈和局部变量表直接决定一个该方法的栈帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。Java虚拟机的做法是将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量槽可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据同时生存的最大局部变量数量和类型计算出max_locals的大小。 比如方法中if代码块或者for代码块中定义的变量,其作用域只在代码块内,代码块执行结束后,其占用的变量槽就可以被后续定义的变量占用 复用示例:

windows11 虚拟化性能计数器需要至少一个可正常使用的计数器 虚拟cpu性能计数器_学习_04


如下为从class文件中解析出的test4()方法的局部变量表模板信息,可以看到,表索引位置0要存储对象引用this,而b和c在局部变量表中的索引位置序号是一样的,因为在程序执行时c会复用已销毁的b占用的slot位置


windows11 虚拟化性能计数器需要至少一个可正常使用的计数器 虚拟cpu性能计数器_学习_05

(2)操作数栈

我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈就是指的操作数栈。操作数栈是数组实现的栈结构,用于解释引擎执行方法字节码指令过程中数据的临时存放和取出。

如下方法的字节码指令,push指令(前面的符号表示数据类型)用于将数据压入栈中,store指令用于出栈一个数据并将其存储到局部变量表指定索引位置(命令后面的数字),load指令用于将局部变量表指定索引位置数据加载到栈中,add命令用于从栈中出栈两个数据相加后将结果压入栈中。如果当前方法有返回值,则方法执行结束后将返回值压入到前一个栈帧的操作数栈中。

windows11 虚拟化性能计数器需要至少一个可正常使用的计数器 虚拟cpu性能计数器_java_06


和局部变量表一样,操作数栈的大小也是在编译期确定的,存储在方法Code属性表的max_stack属性值中,为方法执行过程中栈的最大深度,如上图中方法操作数栈最大深度为2。

注意,局部变量表和操作数栈都是在栈帧创建时就分配了固定的空间,但是方法运行前其中都是没有数据的(this引用和参数除外),而是随着方法指令一步一步执行填充数据的。
此外,可以看到字节码指令的地址编号不是连续的,这是因为不同指令的长度不同,一个字节为一个长度,操作码本身占一个字节(所以Java指令集最多有256个操作码),操作数再占额外的字节,如上带参指令bipush占两个字节,无参指令istore占一个字节。

(3)动态链接信息

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

(4)方法返回地址

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

方法中catch的异常,会存储在方法的异常处理表中,如果方法不是正常退出,即在方法执行过程中遇到了异常,并且这个异常没有在方法中进行处理,即异常处理表中没有匹配的项,则方法会异常退出,将异常抛给上层方法进行处理。

(5)一些附加信息

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

2. 局部变量线程安全问题

m1()方法中sb是线程安全的,因为sb是方法内部的局部变量,且是每个线程的栈中私有的。而m2()和m3()方法中sb都是线程不安全的,变量sb有可能被其他线程修改。

因此看一个变量是否是线程安全的,不仅要看它是否为方法中的局部变量,还要看它是否会逃离方法的作用范围

windows11 虚拟化性能计数器需要至少一个可正常使用的计数器 虚拟cpu性能计数器_学习_07

3. 栈的内存溢出问题

Java虚拟机规范允许虚拟机栈的大小是动态的或者固定不变的。

  • 如果是固定大小的,当线程的栈中申请的内存超出限制时,会抛出一个StackOverflowError错误。例如在程序代码中没有正确的结束递归,导致递归方法栈帧无限压栈超出容量,就会抛出此异常。
  • 如果是可以动态扩展的,当线程无法向系统申请到足够的内存时,就会抛出OutofMemoryError错误

而广泛使用的Hotspot JVM不支持堆栈的动态扩展,栈的一般默认为512k-1024k(取决于操作系统),也可在jvm启动时通过-Xss size参数来指定。

参考:运行时内存篇——虚拟机栈

三、本地方法栈

Java虚拟机栈是用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用,逻辑结构和内存溢出情况都是一样的。

在JVM中有时需要和外界环境或者操作系统直接进行交互,需要调用其它语言(如C/C++)的函数方法,例如创建和使用线程,实际上是调用了操作系统提供的方法实现的。这样的方法称为本地方法,在Java中用native关键字修饰,在Java中并不提供具体的实现,而是运行时由jvm在本地方法库中进行调用。

实际上Hotspot JVM直接就将虚拟机栈和本地方法栈合二为一了。