一. 背景
编写本次 JVM 章程, 因没有找到合适及透明的文章讲解JVM,零一 下定决心既然没人做 从零到一的过程, 那我来做,编学边分享本次学习的过程, 先和同学们说一下本次JVM章程几乎全是概念,其实大家背下来理解即可,希望会对各位学习的童鞋们带来帮助
注意 : 本次 HotSpot VM 讲解基于 JDK8 讲解,期间会进行与JDK6版本进行对比
公众号 : 倔强小狮子(最新发布)
文章目录
虚拟机栈
1. 虚拟机栈出现的背景
- 由于跨平台性的设计,Java的指令都是根据栈来设计的,不同平台CPU架构不同,所以不能设计为基于寄存器的
- 有点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令
- 有不少Java开发人员一提到Java内存结构,就会非常粗粒度的将Java中的内存区理解仅有Java堆(heap)和Java栈(stack),为什么呢?
1.1 内存中的栈与堆
- 栈是运行时的单位,而堆是储存的单位, 即: 栈解决程序运行的问题, 即程序如何执行,或者说如何处理数据,堆解决的是数据存储的问题,即数据应该怎么放,放在哪.
2. 虚拟即栈的基本内容
1. Java虚拟机栈是什么
- Java虚拟机栈早期也叫做Java栈,
每个线程
在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法调用 栈是线程私有的
2. 生命周期
- 生命周期和线程一致
3. 作用
主管Java程序的运行,它保存方法的局部变量,部分结果,并参与方法的调用和返回
3. 虚拟机栈的基本内容
1. 栈的特点(优点)
- 栈是一种快速有效的分配储存方式,访问速度仅次于PC程序计数器
- Java直接堆Java栈操作的只有两个:
2.1 每个方法的执行, 伴随着进栈(入栈,压栈)
2.2 执行结束后的出栈工作(弹栈) 对于栈来说不存在垃圾回收问题(弹栈即释放栈内存)
4. 开发中常见栈问题
- 栈中可能出现的异常
- Java 虚拟机规范允许Java栈的大小是动态的或者固定不变的
- 如果
采用固定大小
的Java虚拟机栈,那每一个线程的Java虚拟机栈的容量可以在创建的时候确定,如果线程请求分配的栈容量超过Java虚拟机最大允许每个线程栈最大容量
,Java虚拟机将会抛出一个StackOverflowError 错误异常
(栈是线程私有的) - 如果Java虚拟机可以
动态扩展
,并且尝试扩展的时候无法申请
足够的内存(设置栈内存总大小),或者在创建新线程时没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机会抛出一个OutOfMemoryError(OOM)
错误异常 - 通常 递归调用方法
- 设置栈内存大小, 可以根据
-Xss:128M 虚拟机参数
(栈是线程私有的,所以设置的是线程栈大小), 选项设置线程最大栈的空间
, 栈的大小直接决定了函数调用的最大可达
的深度
5. 栈中储存什么
- 每个线程都有自己的栈, 栈中的数据都是以栈帧(Stack Frame) 的格式存在
- 在这个线程上正在执行的方法都有各自对应的一个栈帧
- 栈帧是内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
6. 栈的运行原理
- JVM直接对Java栈的操作只有两个,就是对
栈帧的压栈和出栈
,遵循" 先进先后出"或者" 后进先出" 的原则 - 在一条活动的线程中,
一个时间点
上,只会有一个活动的栈帧
,即只有当前正在执行方法的栈帧(栈顶的栈帧)是有效的,这个栈帧称为当前栈帧,
与当前栈帧相对应的方法叫做当前方法
,定义这个方法的类叫做当前类
; 执行引擎
运行的所有字节码指令
只针对当前栈帧
进行操作- 如果在该方法中调用了其他方法,对应的
新的栈帧
会被创建出来,方法栈的顶端
,成为新的当前栈
- 不同线程中所包含的栈帧是不允许存在相互引用的(线程中的栈独立),不允许引用其他线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,方法的返回值会返回给前一个栈帧,接着虚拟机会丢弃当前栈(弹栈),使的之前栈即重新成为当前栈
- Java方法有两种返回函数的方式, 一种是正常函数的返回,使用 return 指令, 另一种是抛出异常,不管哪种方式,都会导致栈帧弹出
7. 栈的内部结构(重点)
局部变量表 (Local Variables)
操作数栈 (Operand stack) (或表达式栈)
动态链接( Dynamic Linking ) (或 指向运行时常量池的方法引用)
方法返回地址( Return Address ) (或方法正常退出或者异常退出的定义)
- 一些附加信息(了解即可)
8. 局部变量表(重点)
- 局部变量表成为
局部变量数据或者本地变量表
- 定义为一个数组,主要用于存储方法的参数和定义在方法体内的局部变量,这些数据类型包括
基本数据类型
,引用数据类型
, 以及returnAddress 类型(方法返回地址)
- 由于局部变量表是
建立在线程栈上
, 是线程私有
的数据,因此不存在数据安全问题
- 局部变量表所需的容量
大小是在编译器确定下来的
, 并保存在方法Code 属性的 max_local 数据项中
, 在方法运行期间是不会改边局部变量表的大小的 方法嵌套次数栈的大小决定(
上面演示 StackOverFlowError 错误说明 -Xss128M 参数调节).在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表传递过程
,当方法调用结束后,随着方法栈的销毁,局部变量表也会随之销毁
9. 变量Slot的理解(槽,想没想起 Redis Hash 槽)
- 参数值的存放总是在局部
变量数据的 index0 开始, 到数据长度 -1 的索引结束
- 局部变量表,最
基本的储存单元
是 Slot(变量槽) - 局部变量表中存放
编译期可知
的各种基本数据类型(4类8种),引用类型,returnAddress类型的变量
- 局部变量表里,
32位以内
的类型只占有一个
Slot(returnAddress类型),64位的类型
( long 和 double) 占用两个 Slot
- 注意 : byte, short, char 在
存储前被转为 int
, boolean 也会被转为 int,0 : false, 非 1: true
9.1 关于Slot的理解
- JVM会为局部变量表中的每一个Slot都分配一个
访问索引
,通过这个索引即可成功访问到局部变量表中指定的局部变量 - 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被赋值到局部变量表中的每一个Slot上
- 如果需要访问局部变量表中一个64bit的局部变量是, 只需要使用前一个索引即可,简明说就是 一个64bit占据两个slot,那么根据占据的第一个slot索引访问(比如 : long 或者 double 类型变量)
- 如果当前帧是由构造方法或者实例方法(非static 修饰的方法)创建的, 那么该对象引用
this 将会存放在 index为0的slot处
,其余按照参数表顺序继续排放 - 图片解释,
默认构造,static和非static,slot index 0 值对比
9.2 Solt的重复利用
- 栈帧中的局部变量表中的Solt位是可以重复利用的,如果一个局部变量超过其作用域返回,那么在其作用域值后,新的局部变量就
很有可能
会重复利用过期的局部变量的Slot位,从而达到节约资源的目的
9.3. 静态变量和局部变量的对比
- 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配
- 变量表由两次初始化的机会, 第一次
"准备阶段"
, 执行系统初始化, 对类的变量设置零值, 第二次初始化是在"初始化阶段"
, 赋予程序在代码中赋值的初始值 - 和类型变量不同的是,局部变量表中不存在初始化过程,这意味着一旦定义了局部变量则必须人为初始化,否则无法使用
9.4. 补充说明
- 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表,在方法执行时,虚拟机使用局部变量表完成方法的传递
- 局部变量表中的变量也是重要的垃圾回收根节点,只要局部变量中的直接或间接引用的对象都不会被回收
10.操作数栈(重点)
- 每一个独立的栈帧中除了包含局部变量表以外,还包含着一个
后进后出
( Last-In-First-Out ) 的操作数栈,也可以称为表达式栈
( Expression Stack) - 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或者提取数据,即 入栈(push),出栈(pop)
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用他们时再把结果压入栈
- 比如 : 如图
- 操作数栈, 主要
用于保存计算过程的中间结果,
同时作为计算
过程中变量临时存储空间
- 操作数栈就是JVM执行引擎的一个工作区,当一个方法执行时,新的栈帧会被创建出来随这被创建出来,且这个方法的的操作栈是空(因为刚创建未作操作)
- 每个操作数栈都会拥有一个明确的
栈深度用于存储数值
,其所需的最大深度在编译期就确定好了
,保存再方法的code属性中,为max_stack的值 - 栈中的任何一个元素都是任意的Java数据类型
32bit的类型占一个栈单位深度, 64bit的类型占用两个栈单位的深度
- 操作数栈并非采用访问索引的方法进行数据访问的,而是只通过
标准的栈结构
(压栈,弹栈 )操作访问数据的 - 如果
被调用方法带有返回值,其返回值就会被
压入当前栈帧的操作数栈中,并更新
PC寄存器`的字节码指令 - 操作数栈中元素的数据类型必须和字节码指令序列严格匹配,由编译器再编译期间进行校验的,同时在类加载过程中的类检验阶段的数据流分析也会进一步校验
JVM虚拟机的解释器引擎时基于栈的执行引擎,其中栈指的就是操作数栈
11. 常见面试题目
- i++ 和 ++i 有什么区别,今天咱们就在字节码篇进行分析
- 首先熟悉下 以下 指令 :
iconst : 指令将常量压入栈中
istore : 将栈顶int型数值存入局部变量表中( 对应索引中 )
iload : 将对应局部变量中索引的int型局部变量进栈
iinc : 指定int型变量增加指定值(直接操作局部变量表对应索引出的值)
12. 栈顶缓存技术(了解)
- 前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要
更多的指令分派(instruction dispatch)次数和内存读/写次数。
- 由于操作数是存储在内存中的,因此
频繁地执行
内存读/写操作必然会影响执行速度
。为了解决这个问题, HotSpot JVM的设计者们提出了栈顶缓存(Tos, Top-of-stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中
,以此降低对内存的读/写次数,提升执行引擎的执行效率。
13. 动态链接(指向运行时常量池的方法引用)
- 每个栈帧内部
都包含一个指向运行时常量池中该栈帧所属方法的引用
, 包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接
(Dynamic Linking), 比如 invokeddynaimc 指令 - 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为
符号引用
(未开辟空间,不知道真实地址,使用符号应用),保存在Class常量池中,比如 : 一个方法调用其他方法时,就是通过常量池中的指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用 - 常量池的一个作用就是为了提供一写符号和常量,便于指令的识别
14. 方法的调用
14.1 绑定机制
- 在JVM中将符号引用转换为调用方法的直接引用与方法的绑定机制相关
静态链接
: 当一个字节码文件状态进JVM内部时,如果被调用的目标方法在编译期可知
(A a = new A() )且运行期保持不变时,这种情况下调用方法的符号引用转换直接引用的过程叫做静态引用动态链接
: 如果被调用的方法在编译期无法被确定
下来时,只有在程序运行期间将调用方法的符号引用转换为直接引用( ASuper a = new A()), 由于这种情况转换过程具备动态性,因此是动态链接
14.2 绑定机制分类
- 绑定是一个字段,方法或者类的符号引用直接替换成直接引用的过程,仅发生一次
早期绑定(与静态链接对应)
: 早期绑定就是被调用的目标方法如果在编译器可知,且运行期间保持不变是,即可将这个方法与所属性的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个了,因此也就可以使用静态链接的方式将符号引用转换为直接引用晚期绑定(与动态链接对应)
: 如果被调用的方法在编译期间无法确定下来,只能够在程序运行期间根据实际的类型绑定相关的方法,这种绑定方法就称为晚期绑定
思考 : 静态链接或者动态链接是否和多态特性相关呢?
14.3 虚方法与非虚方法
非虚方法
: 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的这种方法称为非虚方法,静态方法,私有方法,final方法,实例构造器,父类方法
都是非虚方法- 虚方法 : 静态方法,私有方法,final方法,实例构造器,父类方法外全部是虚方法
- 虚拟机中提供了以下几条方法调用指令:
3.1 : 普通调用指令:
3.2 :invokestatic:调用静态方法,解析阶段确定唯一方法版本
3.3 :invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
3.4 : invokevirtual:调用所有虚方法
3.5 : invokeinterface:调用接口方法动态 - 调用指令:
4.1 : invokedynamic:动态解析出需要调用的方法,然后执行 - 前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法类型(多态)。其中
invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(fina1修饰的除外)称为虚方法
。
14.4 动态类语言和静态类语言(重点)
- 动态类语言和静态类语言两者的区别就在于对类型的检查是在编译器和运行期,编译期的则是静态类型语言,运行期间则是动态类型语言
巩固下 : 静态类型语言是判断自身变量的类型信息,动态语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态类型语言的一个重要特性
14.5 Java语言中方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C
- 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对 C 的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
- llegalAccessErrox介绍:程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
15. 虚方法表
- 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能, JVM采用在类的方法区建立一个虚方法表(virtual method table)
(非虚方法不会出现在表中)
来实现。使用索引表来代替查找。 - 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
- 虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后, JVM会把该类的方法表也初始化完毕。
16. 方法返回地址
- 存放调用该方法的PC寄存器的值
- 一个方法的结束存在两种方式 : 正常执行完成, 出现未处理异常,非正常结束
- 无论哪种方式退出,在方法退出后都会返回到该方法被调用的位置,方法正常退出时,
调用这的PC寄存器的值作为返回值,即调用该方法的当前指令地址
,而通过异常退出时,返回地址是要通过异常表来确定的 - 本质上,方法的退出就是当前栈出栈的过程,需要恢复上层方法的局部变量表,操作数栈,就其返回值压入调用者栈帧的操作数栈,设置PC寄存器的值,让调用者的方法继续执行下去
正常完成退出和异常完成退出的区别在于,通过异常退出的不会给它上层调用者产生任何返回信息
- 在方法执行的过程中遇到了异常(Exqeption) ,并且这个异常没有在方法内进行处理,也就是只要在本方法的
异常表
中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。 - 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
.# 17. 一些附加信息
- 栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
18. 栈常见面试题
- 举例栈溢出的情况? (StackoverflowError) :
通过-Xss设置栈的大小: 00M
- 调整栈大小,就能保证不出现溢出吗?
不能
- 分配的栈内存越大越好吗?
不是
- 垃圾回收是否会涉及到虚拟机栈?
不会的
- 方法中定义的局部变量是否线程安全?
具体问题具体分析容
作者:专业于写这些入门到深层知识,提升我们的基本功,期待你的关注,和我一起学习
转载说明:未获得授权,禁止转载