一. 概念
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域 有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
二. 程序计数器(PC寄存器)
1. 概述
- 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的 字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器 的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一 个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因 此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
- 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地 址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)
- 此内存区域是唯 一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
2. 作用
PC寄存器用来存储指向下一条指令的地址
3. 执行过程
-
执行引擎通过PC寄存器读取到操作指令
-
执行引擎通过操作局部变量表和操作数栈, 实现对数据的存取
-
执行引擎将操作指令翻译成机器指令, 让对应的CPU进行计算
4. 两个常见问题
1. 使用PC寄存器存储字节码指令地址有什么用?
- 因为CPU需要不停的切换各个线程, 这时候切换回来以后,就得知道接着从哪开始继续执行
- JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码命令
2. PC寄存器为什么会被设置为线程私有?
- 为了能够正确的记录各个线程正在执行的当前字节码指令地址, 最好的办法自然是为每一个线程都分配一个PC寄存器
三. Java虚拟机栈
1. 虚拟机栈的概述
1. 虚拟机栈出现的背景
- 由于跨平台性的设计, Java的指令都是根据栈来设计的. 不同平台CPU架构不同, 所以不能设计为基于寄存器的.
- 优点是跨平台, 指令集小, 编译器容易实现,
- 缺点是性能下降, 实现同样的功能需要更多的指令
2. Java虚拟机是什么
-
Java虚拟机栈(Java Virtual Machine Stack), 早期也叫java栈.
-
每隔线程在创建是都会创建一个虚拟机栈, 它的生命周期与线程相同.
-
Java虚拟机栈的内部保存一个个的栈帧, 一个栈帧对应着一个方法
-
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
“栈”通常就是指这里讲的虚拟机栈,或 者更多的情况下只是指虚拟机栈中局部变量表部分。
-
局部变量表存放了编译期可知的各种Java虚拟机8种基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用地址
-
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和 double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编 译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
-
如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出StackOverflowError异常;
如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
3. 栈的特点
-
栈式一种快速有效的分配存储方式, 访问速度仅次于程序计数器
-
Java直接对java栈的操作只有两个
-
每个方法执行, 伴随着入栈
-
执行结束后, 进行出栈
-
-
对于栈来说不存在垃圾回收问题(GC)
4. 栈中可能出现的异常
Java虚拟机规范允许java栈的大小式动态的或者是固定不变的.
-
如果采用固定大小的java虚拟机栈, 那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定. 如果线程请求分配id栈容量超过java虚拟机栈允许的最大容量, java虚拟机将会抛出一个StackOverflowError异常
-
如果java虚拟机可以动态扩展, 且在尝试扩展的时候无法申请到足够的内存, 或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈, 那么java虚拟机将会抛出一个OutOfMemoryError异常.
-
设置栈内存大小
我们可以使用
-Xss
选项来设置线程的最大栈空间, 栈的大小直接决定了函数调用的最大可达深度.
2. 栈的存储单位
1. 栈中存储什么
-
每个线程都有自己的栈, 栈种的数据都是以栈帧(Stack Frame)的格式存在.
-
在这个线程上正在执行的每个方法都各自对应一个栈帧(一一对应)
-
栈帧是一个内存区块, 是一个数据集, 维系着方法执行过程中的各种数据信息.
2. 栈运行原理
- 不同线程中所包含的栈帧是不允许存在相互引用的, 即不可能在一个栈帧之中引用另外一个线程的栈帧.
- 如果当前方法调用了其他方法, 方法返回之际, 当前栈帧会传回此方法的执行结果给前一个栈帧, 接着, 虚拟机会丢弃当前栈帧, 使得前一个栈帧重新成为当前栈帧.
- java方法有两种返回函数的方式, 一种是正常的函数返回, 使用return指令; 另一种是抛出异常. 不管使用哪种方式, 都会导致栈帧被弹出.
3. 栈帧的内部结构
每个栈帧中存储着:
-
局部变量类(Local Variables)
-
操作数栈(Operand Stack) (或表达式栈)
-
动态链接(Dynamic Linking) (或指向运行时常量池的方法引用0)
-
方法返回地址(Return Address) (或方法正常退出或者异常退出的定义)
-
一些附加信息
3. 局部变量表
1. 说明
- 局部变量表也被称为局部变量数组或本地变量表
- 定义为一个数值数组, 主要用于存储方法参数和定义在方法体内部的局部变量, 这些数据类型包括各类基本数据类型, 对象引用(reference), 以及returnAddress类型
- 由于局部变量表是建立在线程的栈上, 是线程的私有数据, 因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译期确定下来的, 并保存在方法的Core属性的
maximum local variables
数据项中. 在方法运行期间不会改变局部变量表的大小 - 方法嵌套调用的次数由栈的大小决定. 一般来说, 栈越大, 方法嵌套调用次数越多.
- 局部变量表中的变量只在当前方法调用中有效. 在方法执行时, 虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程. 当方法调用结束后, 随着方法栈帧的销毁, 局部变量也会随之销毁.
2. 关于Slot的理解
-
参考值的存放总是在局部变量数组的index0开始, 到数组长度-1的索引结束
-
局部变量表, 最基本的存储单元是Slot(变量槽)
-
局部变量表中存放编译器可知的各种基本数据类型(8种), 引用类型(reference), returnAddress类型的变量.
-
在局部变量表里, 32位以内的类型只占用一个slot(包括returnAddress类型), 64位的类型(long 和 double)占用两个slot.
byte, short, char在存储前被转换为int, boolean也被转换为int, 0表示false, 非0表示true
-
JVM会为局部变量表中的每一个slot都分配一个访问索引, 通过这个索引即可成功访问到局部变量表中指定的局部变量值.
-
当一个实例方法被调用的时候, 它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
-
如果需要访问局部变量表中一个64bit的局部变量值时, 只需要使用前一个索引即可.
-
如果当前帧是由构造方法或者实例方法创建的, 那么该对象引用this将会存放在index为0的slot处, 其余的参数按照参数表顺序继续排列.
3. Slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的, 如果一个局部变量过了其作用域, 那么在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位, 从而达到节省资源的目的
4. 补充说明
- 在栈帧中, 与性能调优最为密切的部分就是前面提到的局部变量表, 在方法执行时, 虚拟机使用局部变量表完成方法的传递.
- 局部变量表中的变量也是重要的垃圾回收根节点, 只要被局部变量表中直接或间接引用的对象都不会被回收.
4. 操作数栈
1. 说明
-
每个独立的栈帧中除了包含局部变量表以外, 还包含一个后进先出(LIFO)的操作数栈, 它可以称之为表达式栈
-
操作数栈, 在方法执行过程中, 根据字节码指令, 往栈中写入数据或提取数据, 即入栈(push)出栈(pop)
-
操作数栈, 主要用于保存计算过程的中间结果, 同时座位计算过程中变量临时的存储空间.
-
操作数栈就是JVM执行引擎的一个工作区, 当一个方法刚开始执行的时候, 一个新的栈帧也会随之被创建出来, 这个方法的操作数栈是空的.
-
每个操作数栈都会拥有一个明确的栈深度用于存储数值, 其所需的最大深度在编译期就定义好了, 保存在方法的Code属性中, 为max_stack的值.
-
栈中的任何一个元素都是可以任意的Java数据类型
32bit的类型占用一个栈单位深度
64bit的类型占用两个栈单位深度
-
操作数栈并非采用访问索引的方式来进行数据访问的, 而是只能通过标准的入栈和出栈操作来完成一次数据访问
-
如果被调用的方法带有返回值的话, 其返回值将会被压入当前栈帧的操作数栈中, 并更新PC寄存器中下一条需要执行的字节码指令.
-
Java虚拟机的解释引擎是基于栈的执行引擎, 其中的栈指的就是操作数栈.
2. 字节码指令分析
-
代码
public void testAddOperation(){ byte i = 15; int j = 8; int k = i + j; }
-
字节码
Code: stack=2, locals=4, args_size=1 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
-
代码追踪
5. 动态链接
1. 说明
- 每个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用. 包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)
- 在java源文件被编译到字节码文件中时, 所有的变量和方法引用都作为符号引用(Sysbolic Reference)保存在class文件的常量池里. 动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用.
2. 为什么需要常量池
常量池的作用, 就是为了提供一些符号和常量, 便于指令的识别
6. 方法的调用
1. 说明
- 在JVM中, 将符号引用切换为调用方法的直接引用与方法的绑定机制相关
2. 静态链接与动态链接
- 静态链接
当一个字节码文件被装载进JVM内部时, 如果被调用的目标方法在编译器可知, 且运行期保持不变时, 这种情况下将调用的符号引用转换为直接引用的过程称之为静态链接. - 动态链接
如果被调用的方法在编译器无法被确认下来, 也就是说, 只能够在程序运行期将调用方法的符号引用转换为直接引用, 由于这种引用转换过程具备动态性, 因此也就被称之为动态链接
3. 早期绑定与晚期绑定
对应的方法的绑定机制为: 早期绑定和晚期绑定. 绑定是一个字段, 方法或者类在符号引用被直接引用的过程, 这仅仅发生一次
- 早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知, 且玉玲期保持不变时, 即可将这个方法与所属的类型进行绑定. 这样一来, 由于明确了被调用的目标方法究竟是哪一个, 因此也就可以使用静态 链接的方式将符号引用转换为直接引用. - 晚期绑定
如果被调用的方法在编译器无法被确定下来, 只能够指程序运行期根据实际的类型绑定相关的方法, 这种绑定方式也就被称之为晚期绑定.
4. 虚方法与非虚方法
- 非虚方法
- 如果方法在编译期就确定了具体的调用版本, 这个版本在运行时是不可变的, 这样的方法称为非虚方法
- 静态方法, 私有方法, final方法, 实例构造器, 父类方法都是非虚方法
- 虚方法
其他方法称之为虚方法
5. 方法调用指令
普通调用指令
- invokestatic: 调用静态方法, 解析阶段确定唯一方法版本
- invokespecial: 调用方法, 私有及父类方法, 解析阶段确定唯一方法版本
- invokevirtual: 调用所有虚方法
- invokeinterface: 调用接口方法
动态调用指令
- invokedynamic: 动态解析出需要调用的方法, 然后执行
说明
invokestatic
和invokespecial
指令调用的方法称为非虚方法- 其余的(final修饰的除外)称为虚方法
6. 方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型, 记为C
- 如果在过程结束, 如果不通过类型C中找到与常量中的描述符合简单名称都相符的方法, 则进行访问权限校验. 如果校验通过则返回这个方法直接引用, 查找过程结束; 如果校验不通过, 则返回
java.lang.IllegalAccessError
异常 - 否则,按照继承关系从下往上一次对C的各个父类进行第2步的搜索与验证过程
- 如果始终没有找到合适的方法, 则抛出
java.lang.AbstractMethodError
异常
7. 虚方法表
- 在面向对象的变成中, 会很频繁的使用到动态分配, 如果在每次动态分配的过程中都要重新在类的方法元数据中搜索合适的目标的话就冷冷影响到执行效率. 因此, 为了提高性能, JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不能出现在表中)来实现, 使用索引表来代替查找.
- 每个类中都有一个虚方法表, 表中存放着各个方法的实际入口
- 虚方法会在类加载的链接阶段被创建并开始初始化, 类的变量初始值准备完成后, JVM会把该类的方法也初始化完毕.
7. 方法返回地址
1. 说明
- 存放调用该方法的pc寄存器的值
- 一个方法的结束, 有两种方式:正常执行完成和出现未处理的异常,非正常退出
- 无论通过那种方式退出, 在方法退出后都返回到该方法被第哦啊用的为欸子. 方法正常退出时, 调用者的pc计数器的值座位返回地址, 即调用该方法的指令的下一条指令地址. 而通过异常退出的, 返回地址是通过异常表来确定, 栈帧中一般不会保留这部分信息.
- 正常完成出口和异常完成出口的区别在于: 通过异常完成出口退出的不会给他的上层调用者产生任何返回值.
8. 一些附加信息
栈帧中还允许携带与java虚拟机实现相关的一些附加信息. 例如: 对程序调试提供支持的信息.
9. 面试题
-
举例栈溢出的情况?(StackOverflowError)
通过-Xss设置栈的大小
-
调整栈大小, 九里保证不出现溢出吗?
不能保证, 例如死循环
-
分配的栈内存越大越好吗?
并不是. 栈空间过大会挤占其他区域的内存空间
-
垃圾回收是否会涉及到虚拟机栈?
不会涉及.
-
方法中定义的局部变量是否线程安全?
具体问题具体分析
如果只有一个线程才可以操作此数据, 着必是线程安全的
如果有多个线程操作此数据, 着此数据是共享数据. 如果不考虑同步机制的话, 会存在线程安全问题.
四. 本地方法栈
1. 说明
-
Java虚拟机用于管理java方法的调用, 而本地方法栈用于管理本地方法的调用
-
本地方法栈也是线程私有的
-
允许被实现成固定或者是可动态扩展的内存大小.
-
本地方法是使用C语言实现的.
-
它的具体做法是Native Method Stack中等级native方法, 在Execution Engine执行时加载本地方法库.
-
当某个线程调用一个本地方法时, 他就进入了一个全新的并且不在受虚拟机控制的世界. 它和虚拟机拥有同样的权限.
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
- 它甚至可以直接使用本地处理器中的寄存器.
- 直接从本地内存的堆中分配任意数量的内存
-
并不是所有的JVM都支持本地方法. 因为JVM虚拟机规范并没有明确要求本地方法栈的使用语言, 具体实现方式, 数据结构等.
-
在Hotspot JVM中, 直接将本地方法栈和虚拟机栈合二为一
-
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失 败时分别抛出StackOverflowError和OutOfMemoryError异常。