jvm系列-03精通运行时数据区私有区域---虚拟机栈程序计数器本地方法栈
Posted huisheng_qaq
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了jvm系列-03精通运行时数据区私有区域---虚拟机栈程序计数器本地方法栈相关的知识,希望对你有一定的参考价值。
JVM系列整体栏目
内容 | 链接地址 |
---|---|
【一】初识虚拟机与java虚拟机 | https://blog.csdn.net/zhenghuishengq/article/details/129544460 |
【二】jvm的类加载子系统以及jclasslib的基本使用 | https://blog.csdn.net/zhenghuishengq/article/details/129610963 |
【三】运行时私有区域之虚拟机栈、程序计数器、本地方法栈 | https://blog.csdn.net/zhenghuishengq/article/details/129684076 |
精通运行时数据区私有区域
深入理解运行时数据区的内容
1,运行时数据区的组成部分
在jvm的整个内存结构中,通过类加载器的子系统,将字节码文件加载到运行时数据区中。
在运行时数据区中,主要包含方法区,堆,虚拟机栈,本地方法栈和程序计数器,同时运行时数据区中还存在与其他区域的交互。在jdk1.8之后,方法区又被称为元空间
在java虚拟机中,定义了若干程序在运行时期间会使用到这个运行时数据区,期中有一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁,即和当前进程的生命周期是一样的。另外也存在一些是与线程一一对应的,这些线程对应的数据区域会随着线程开始和结束而创建和销毁。
通过上图运行时数据区的内容分可知,红色部分是线程共享的,会随着虚拟机的创建而创建,销毁而销毁,灰色部分是线程私有的。
🖐 线程私有:程序计数器,本地方法栈和虚拟机栈
🖐 线程共享:堆,堆外内存(永久代或者元空间、代码缓存等)
2,程序计数器
2.1,程序计数器概述
程序计数器,又被称为PC寄存器,英文名为Program Counter Register,类似于CPU寄存器的一个模拟,用于存储指令相关的现场信息,CPU只有吧数据装在到寄存器才能够运行。
程序计数器主要是用来存储指向下一条指令的地址,也是即将要执行的指令代码,有执行引擎读取下一条指令。每个线程有属于自己的程序计数器,生命周期与当前线程的生命周期一致。
它是持续的控制流的指示器,分支,循环,跳转,异常等基础功能都是通过这个计数器来完成的,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
2.2,程序计数器的作用
主要是在多线程的场景下,如果出现资源抢占,CPU就会出现轮换以及线程的切换,当前线程就会出现挂起的情况,因此可以通过记录这个行号,再次运行该线程时,就不需要从头开始运行,只需要从记录的行数再次运行即可。
每个线程都会记录当前线程运行到哪一行,因此需要给每个线程一个程序计数器,因此程序计数器属于线程私有。
3,虚拟机栈
3.1,虚拟机栈的基本概述
在内存中,栈是运行时的单位,而堆是存储单位。栈解决的是程序的运行问题,即程序如何执行,数据如何处理;而堆解决的是数据的存储问题,即数据应该怎么放,放哪儿。
虚拟机栈是线程私有的,因此每个线程都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法的调用,其生命周期个线程是一致的。
虚拟机栈主管Java程序的运行,用于保存方法的局部变量,部分结果,并参与方法的调用和返回。
3.2,虚拟机栈的特点
🖐 快速有效的分配存储方式,访问速度仅次于程序计数器
🖐 主要操作只有两个,分别是入栈和出栈
🖐 对于栈来说不存在垃圾回收问题,如GC,OOM等
3.3,栈中可能出现的异常
🖐 StackOverflowError:栈溢出
🖐 OutOfMemoryError:没有足够的内存异常
设置栈的大小:-Xss1024k
3.4,栈运行的原理
🐵 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另一个线程的栈帧
🐵 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给当前栈帧,接着虚拟机就会废弃当前栈帧,使得前一个栈帧重新成为当前栈帧
🐵 Java方法有两种返回函数的方式,一种是正常的函数返回,使用的是return指令;另一种是在没有处理异常的时候抛出异常。不管使用那种方式,都会导致栈帧被弹出。
3.5,栈帧的内部结构
在栈帧中主要由五部分组成,分别是局部变量表,操作数栈,方法返回地址,动态链接和一些附加信息等。
这五部分的大小影响着栈帧的大小,而栈帧的大小同时也影响着栈帧个数的多少。
3.6,局部变量表(重点)
Local variables:局部变量表,又被称为局部变量数组或者本地变量表。
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型主要包括各种基本的数据类型,对象引用以及returnAddress类型。
public void test(int i,int j)
String m;
String n;
由于局部变量表是建立在线程的栈上,栈中的线程是私有的数据,因此不存在数据的安全问题。
局部变量表所需要的容量大小是在编译期间就被确认下来,并且在运行期间是不会修改局部变量表的大小的。
局部变量表中的变量只在当前方法中调用有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程,当方法调用结束之后,随着方法栈帧的销毁,局部变量表也会随之销毁。
3.6.1,槽
在讲解这个槽之前,再先了解一下这个jclasslib对实例方法的使用,在对这个类进行编译之后,然后打开查看这个bytecode,在这个SlotTest类中,定义了一个main方法和一个test1的实例方法
然后可以直接分析这个右边Methods下面的test1方法中的Code属性,可以发现右边存在三个字段,分别是ByteCode,Exception和misc。
byteCode指的是反编译的字节码指令,左边白色编号1-19部分代表的是在代码中出现的位置,右边红色的编号0-32代表的是字节码指令的位置;
Exception table指的是出现的异常情况;
misc中第一个字段表示的是版本,第二个字段表示的是出现的变量的个数,第三个字段表示的是字节码之类的长度。
在这个Code下面,存在两个字段,分别是LineNumberTable和LocalVariableTable这两个属性,LineNumberTable中的详细如下,主要指的是字节码指令个代码出现的位置的一一映射
LocalVariableTable的详细信息如下,主要是指的是一些变量的个数以及对应的值。
好了,在了解这个字节码的反编译是如何操作的之后,接下来再详细的了解一下这个重点内容槽。在局部变量表中,其最基本的存储单元是Slot(变量槽),而32位内的类型占一个槽,64位类型占两个槽,其中引用类型也是占32位,但是Long和Double占两个slot。
jvm会为局部变量表中的每一个Slot分配一个访问索引,通过这个索引就可以成功的访问到局部变量表中指定的局部变量值。
当存在一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表的每一个slot上。
如果是当前帧是由构造方法或者是实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的按照参数顺序表继续,这就是为啥我上面要先说明这个jclasslib的实例方法的各个参数了,如下面的这个test1中,这个this是存在这个index下标为0处的
接下来再在这个方法里面加一个构造方法和一个静态的类方法
public SlotTest()
int j = 10;
System.out.println(j);
public static void test2()
System.out.println("hello jvm!");
可以发现这个构造方法是在init中的,其也存在这这个this,并且存放在这个index下标为0的地方
但是这个static的这个test2方法中,是没有这个LocalVariableTable属性的,因此也就没有this这个字段
因此可以说明,在实例方法和构造方法中,其局部变量表示存在这个this字段的,而静态方法中的局部变量表是不存在这个this字段的,因此这就说明了为什么可以在实例方法和构造方法中使用this这个字段,而不能在类方法中使用这个this字段了。
在栈帧中,如果变量出了这个作用域,那么该槽位也能被重复利用。
3.6.2,静态变量与局部变量
在变量的分类中,主要是按两种方式进行分类,一种是按照数据类型分,一种是按照类中声明的位置进行分类。
按照类型:主要分为基本数据类型的变量和引用数据类型的变量
按照声明的位置:又可以分为成员变量和局部变量
🐶 成员变量在使用前,都会经历过默认的初始化赋值,如类变量在准备阶段有一个默认的赋值,在初始化阶段有一个真正的赋值,还有实例变量会随着对象的创建,会在堆空间中分配实例变量空间,并进行默认的赋值。
🐶 而在局部变量中,在使用局部中的变量时,必须给这些局部变量进行显示的赋值,否则会直接出现编译不通过
在栈帧中,与性能调优关系最为密切的部分就是局部变量表,在方法执行的时候,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收的根节点,只要被局部变量表中的直接或者间接引用的对象都不会被回收
3.7,操作数栈
3.7.1,操作数栈基本概念
每一个栈帧中除了包含局部变量表之外,还包含一个先进先出的操作数栈,在方法执行过程中,会根据字节码指令,往栈中写入数据或者提取数据,即入栈(push)和出栈(pop)的操作。
-
这些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用后再把他们的结果压入栈。
-
操作数栈主要用于保存计算中间的结果,同时作为计算过程中变量临时的存储空间。
-
操作数栈是随着方法的执行而创建的,其生命周期和方法的生命周期一致,并在编译期间就被确定其大小
-
操作数栈并不是采用访问索引的方式来访问数据的,而是只能通过标准的入栈和出栈操作一次完成
3.7.2,操作数栈具体分析
如再在这个类中定义一个test的方法,其代码如下,主要有下面三个参数,接下来通过这个字节码指令分析一下
public void test()
int i = 15; //byte,short,char,boolean都以int类型保存在数组中
int j = 8;
int k = i + j;
在这个Bytecode中,可以发现其字节码指令如下,依次加载15、8然后再相加再存储,并且整个流程需要程序计数器来实现代码的下移运行。
0 bipush 15 //将15入栈
2 istore_1 //出栈,将值在存储局部变量表的index为1的slot位置,为0的位置为this
3 bipush 8 //将8入栈
5 istore_2 //出栈,将值在存储在局部变量表的index为2的slot位置
6 iload_1 //取出局部变量表的index为1位置的值,加入到栈中
7 iload_2 //取出局部变量表的index为2位置的值,加入到栈中
8 iadd //8和15出栈,执行相加操作
9 istore_3 //存储到局部变量表中
10 return //返回
而通过这个流程也可以发现这个操作数栈只是一个中间过程,入栈之后还是得出栈将值加入到这个局部变量表中,主要还是因为这个栈可以保证先后顺序性,同时在计算复杂的四则运算的时候,这个栈的优势就被体现出来了。
通过这个字节码中的LocalVariableTable表中的值也可以看到各个参数所分步在slot槽点的位置
如果被调用的方法中带有返回值的话,其返回值将会被压入栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令。
并且操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间再次进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段需要再次验证。
3.7.3,栈顶缓存技术
在基于栈式架构的虚拟机所使用的零地址指令更加的紧凑,但是完成一项操作的时候必然需要更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派次数和内存的读写次数。
由于操作数是存储在内存中的,因此频繁的执行内存读写操作必然会影响执行速度,因此在JVM中引入了栈顶缓存技术,将栈顶元素全部缓存在物理CPU的寄存器中,降低对内存的读写次数,从而提升执行引擎的执行效率。
3.8,动态链接
每一个栈帧内部包含一个指向运行时常量池中该栈帧方法所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)
在Java源文件被编译到字节码文件的时候,所有的变量和方法引用都作为符号引用保存在class文件的常量池中,比如描述一个方法调用了另外一个方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转化为调用方法的直接引用。
大部分的字节码指令,在执行的时候,都需要进行常量池的访问,而这个动态链接,又被称为是指向运行时常量池的方法引用。
如在这个test3方法中,调用了这个test方法,同时也引用了这个全局变量进行一个自增的操作
public void test3()
test();
k++;
接下来再次查看一下这个反编译文件,其字节码指令如下,在加载这个this变量之后,会有一个invokevirtual操作,后面也有一个#7,再后面就是表明改行对应的就是调用的test方法,接下来主要分析这个#7
0 aload_0
1 invokevirtual #7 <com/tky/jvm/neicun/SlotTest.test>
4 pop
5 getstatic #8 <com/tky/jvm/neicun/SlotTest.k>
8 iconst_1
9 iadd
10 putstatic #8 <com/tky/jvm/neicun/SlotTest.k>
13 return
在这个反编译插件的第二个属性中,就有着这个Constant Pool的这个运行时常量池,而上面的#7,就是对应的这个07,其就是一个Methodref,就是一个方法的引用,然后可以依次的通过右边的cp info #9,#46等依次往下找,就可以找到对应的引用。下面的#8也是一样的道理
这说明了啥,之前定义的变量和方法没有显示的加载到常量池中,但是字节码指令是直接去常量池中获取数据的,说明了jvm内部会对每个方法或者变量,都会将他的数据引用作为符号引用加载到运行时常量池中,相当于做一个缓存,后面别的方法要用时,可以直接去常量池里面找。因此叫做指向运行时常量池的方法引用更加贴切。
而字节码文件中的常量池,在运行起来之后,就会保存在方法区中。
其本质也是利用了封装的思维,假设有100个方法都要和test3一样,如果不利用符号引用,而是在每个文件的字节码中都加入有关test方法的字节码指令,那么每个字节码文件都会非常的大,然后就把这个test的字节码指令抽离出去,加到这个运行时常量池中,那么这100个文件要使用这个test方法的字节码指令,直接去运行时常量池中找即可,从而减少文件中字节码指令的数量以及文件的大小。
3.9,方法的调用
3.9.1,静态绑定和动态绑定
在jvm中,将符号引用转化为调用方法的直接引用与方法的绑定机制有关。符号引用就是字节码指令中的#8,直接引用就是这个#8或者通过#8一直找,所找到的对应的内容,符号引用转直接引用的方式主要分为两种,一种是静态链接,一种是动态链接
静态链接
静态链接指的就是在一个字节码文件被加载到jvm内部时,如果被调用的目标方法在编译期间可以确定下来,且运行期间保持不变,那么这种情况下将调用方法的符号引用转化为直接引用的过程就被称为静态链接,同时也可以被称为早期绑定
动态链接
这里的动态链接和3.8的是同一个,如果被调用的方法在编译期间无法被确定下来,也就是说,只能够在程序运行其将调用方法的符号引用转化为直接引用,由于这种引用的转换工程具备动态性,因此也就被称为动态链接,也可以被称为晚期绑定
3.10,方法的返回地址
存储的是该方法的程序计数器的值, 在方法退出之后,都会返回到该方法被调用的位置,方法正常退出时,调用者的程序计数器的值就作为返回地址,如果是异常退出,那么返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息的。
本质上,方法的退出就是当前栈帧出栈的过程。此时需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置程序计数器值等。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何返回值
当一个方法开始执行时,只有两种方式可以退出这个方法
- 执行引擎遇到return,会有返回值传递给上层方法的调用者,简称正常完成出口
- 在方法执行过程中遇到了异常,并且这个异常没有被处理,也会导致方法退出,简称异常完成出口
3.11,虚拟机栈的5道面试题
1,举例栈溢出情况
当往栈空间中不断的加栈帧,当栈空间满的时候,就会出现这个StackOverflowError的情况。可以通过这个-Xss设置栈空间的大小,如果设置的是固定的大小,当栈空间不足就会直接的抛栈溢出的错误;如果是设置的动态的大小时,当栈空间不断扩大,最终会抛出OOM的异常。
2,调整栈大小,就能保证不出现溢出情况吗
不能保证。如果某个方法是死循环,无限的增加栈帧,最终还是会出现这个栈溢出的情况的
3,分配栈内存越大越好吗
理论上越大,出现的这个栈溢出的概率就会变小。但是如果栈变大,会导致其他的资源变少
4,垃圾回收是否会涉及到虚拟机栈
不会。栈不需要GC,只需通过出栈的方式,栈帧就像垃圾一样被清除了。
5,方法中定义局部变量是否为线程安全
有可能存在线程不安全的问题,如果变量的生命周期在方法背部产生并且在内部消亡,那么属于线程安全,否则,都是线程不安全的。
4,本地方法接口
本地方法:该方法由非java语言实现,比如C语言,指的就是一个Java调用非Java代码的接口。
public native void test(int x);
为什么要用native
java使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者对程序的效率很在意时,就可以考虑使用这个native了
4.1,与Java环境外交互
有时java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。如操作系统或者某些硬件交换信息时的情况。本地方法就是这样的一种交流机制:提供一个简洁的接口,而且无需去了解Java应用之外的繁琐的细节
4.2,与操作系统交互
操作系统的底层都是使用这个c或者c++编写的,有时为了解决效率上的问题,可以直接使用一些本地方法,从而实现这个jre和操作系统底层的交互,并且在jvm中,有一些接口就是直接使用这个C来编写的。如果要使用一些java语言本身就没有提供封装的操作系统的特性时,我们也需要使用这个本地方法
4.3,Sun`s Java
Sun的解释器是由C实现的,这使得他像普通的C一样与外部交互。jre大部分是Java实现的,但是也会通过一些本地方法与外界交互。例如类Java.lang.Thread的 setPriority() 方法就是用Java实现的,但是他的实现调用的是该类的本地方法setPriority()
5,本地方法栈
在运行时数据区中,还存在一个线程私有的区域,就是本地方法栈。Java虚拟机栈是用于管理Java方法的调用,而本地方法栈是用于管理本地方法栈的调用。本地方法也是通过C语言实现
在运行时数据区中,本地方法栈也是允许被实现成固定或者是可动态扩展的内存大小
- 如果线程请求分配到的栈容量超过本地方法栈允许的最大容量的时候,会抛出一个StackOverflowError
- 如果是动态扩展的,并且无法申请到足够的内存,那么会抛出一个OOM的异常
本地方法栈主要是和本地方法接口和本地方法库打交道的,主要是对本地方法接口和本地方法库中的方法进行入栈和出栈的操作
当某个线程调用一个本地方法栈的时候,它就进入了一个全新的并且不受虚拟机限制的世界,它和虚拟机拥有相同的权限
- 本地方法时可以通过本地方法接口来访问虚拟机内部的运行时数据区
- 可以直接使用本地处理器的寄存器
- 可以直接从内存的堆中分配任意数量的内存
当然并不是所有的JVM都支持本地方法,因为Java虚拟机规范中也没有明确的要求本地方法栈所使用的语言等,如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。在HotSpot JVM中,直接将本地方法栈和虚拟机栈给合二为一了。
《JVM系列》 第三章 -- 深入理解JVM运行时数据区
运行时数据区
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存包括以下几个运行时区域:虚拟机栈、本地方法栈、方法区、堆、程序计数器
运行时数据区的完整图:
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各子的用途,以及创建和销毁时间,有的区域随虚拟机进程的启动而存在,有的区域则依赖用户线程的启动而建立和销毁。
绿色运行时数据区在所有线程共享(Runtime Data Areas Among All Threads)
黄色运行时数据区线程私有(Thread Specific Runtime Areas)
总结:
一个Java程序对应一个进程
一个进程对应一个jvm实例
一个jvm实例中只有一个运行时数据区
一个运行时数据区只有一个方法区和堆
一个进程中的多个线程需要共享同一个方法区,堆空间
每个线程拥有独立的一套程序计数器、本地方法栈、虚拟机栈
程序计数器PC
程序计数器(Program Counter Register) 是一块很小的内存空间,同时也是运行速度最快的存储区域。它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。
由于Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的位置,每条线程都需要一个独立的程序计数器,生命周期与线程的生命周期保持一致,各条线程之间计数器互不影响,独立存储。
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则是为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
作用:程序计数器存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
使用PC寄存器存储字节码指令地址有什么用呢?
- 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
- JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
PC寄存器为什么被设定为私有的?
由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令,CPU会不停地进行任务切换,这样必然导致经常中断或恢复。
为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。因此每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
CPU时间片:
CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
在宏观上:俄们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。同程序计数器一样,它也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个线程在创建时都会创建一个虚拟机栈,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。
作用:主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
对于虚拟机栈来说不存在垃圾回收问题(栈存在溢出的情况)。
栈的特点: 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。JVM直接对Java栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
在java虚拟机规范中,对于这个区域规定了两种异常:
- Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。
- 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常。
栈内存大小: 我们可以使用参数 -Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度(-Xss1m,-Xss1k),通过 -Xss设置栈的大小可以解决栈溢出问题
虚拟机栈的运行过程:
-
JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。
-
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
-
执行引擎运行的所有字节码指令只针对当前栈帧进行操作,如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
栈运行原理
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
栈帧的内部结构:
- 局部变量表(Local Variables): 存放编译器可知的各种基本数据类型、对象引用和returnAddress。
- 操作数栈(operand Stack): 作为计算过程中变量临时的存储空间,保存计算过程的中间结果。
- 动态链接(DynamicLinking): 指向运行时常量池中该栈帧所属方法的引用。
- 方法返回地址(Return Address): 方法正常退出或异常退出的定义。
- 一些附加信息:栈帧中携带的与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的
局部变量表 (Local Variables): 被称之为局部变量数组或本地变量表。用来存放编译器可知的各种基本数据类型、对象引用和returnAddress。 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
在局部变量表里,32位以内的类型只占用一个slot(变量槽),64位的类型占用两个slot。因此63位长度的long和double类型数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配, 当进入一个方法时,这个变量需要在帧中分配多大的局部变量空间时完全确定的,并保存在方法的Code属性的maximum local variables数据项中,在方法运行期间不会改变局部变量表的大小。
局部变量(局部变量表中的变量),它是相比于成员变量来说的,它只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
静态变量与局部变量的对比:
- 我们知道类变量表有两次初始化的机会,第一次是在 “准备阶段” ,执行系统初始化,对类变量设置零值,另一次则是在 “初始化”阶段 ,赋予程序员在代码中定义的初始值。
- 和类变量初始化不同,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
- 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
本地方法栈
本地方法栈(Native Method Stack) 与虚拟机所发挥的作用非常相似,同样也是线程私有的。它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。
本地方法栈是使用C语言实现的,它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
- 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存。
在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。 甚至有的虚拟机直接就把本地方法栈和虚拟机栈合二为一了(比如Hotspot JVM)。在Java虚拟机栈和本地方法栈中,规定了两个异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,并且扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
什么是Native方法:
- Native Method是一个Java调用非Java代码的接囗。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “c” 告知c++编译器去调用一个c的函数。
- 在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。
为什么使用Native Method?
Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
-
与Java环境的交互
有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。 你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。 -
与操作系统的交互
JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用c写的。 还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。 -
Sun’s Java
Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。 jre大部分是用Java实现的,它也通过一些本地方法与外界交互。
目前Native方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等。
Java堆
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,并确定空间大小。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。
堆内存的大小是可以调节的在实现时,即可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
Java堆 ,是垃圾收集器(GC,Garbage Collection)执行垃圾回收的重点区域,在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除,也就是触发了GC的时候,才会进行回收。因此很多时候也被称作“GC堆”。
从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代、老年代和永久区(jdk8以后被称为元空间);
新生代与老年区:
- Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen),默认情况下新生代与老年代的占比为1:2,-XX:NewRatio=2。
- 其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也被叫做from区、to区)
在HotSpot中,Eden空间与Survivor空间缺省的占比为8:1:1(-xx:SurvivorRatio=8)。
存储在JVM中的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM是生命周期保持一致
几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象的销毁都在新生代进行了。(有些大的对象在Eden区无法存储时候,将直接进入老年代),IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
注意:在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作。如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代
可以使用选项"-Xmn"设置新生代最大内存大小。
从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内存无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
堆的大小在JVM启动时就已经设定好了,大家可以通过选项"-Xmx"和"-Xms"来进行设置。
-Xms10m:最小堆内存/起始内存,等价于-xx:InitialHeapSize
-Xmx10m:最大堆内存,等价于-XX:MaxHeapSize
通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在ava垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
默认情况下:
初始内存大小:物理电脑内存大小/64
最大内存大小:物理电脑内存大小/4
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时(内存大小超过“-xmx"所指定的最大内存时),将会抛出OutOfMemoryError异常。
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,他用于存储已被虚拟机加载的类信息、常量、静态常量、即时编译器编译后的代码缓存等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是他却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样在JVM启动的时候被创建,它不需要连续的内存和可以喧嚣而固定大小或者可扩展外, 还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类(比如说加载大量第三方jar包),导致方法区溢出,虚拟机会抛出内存溢出错误OOM(OutOfMemoryError)。
方法区内存结构:
类型信息:
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
- 这个类型直接接口的一个有序列表
域信息:
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
方法(Method)信息:
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称
- 方法的返回类型(或void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
- 异常表(abstract和native方法除外)每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final的类变量:
- 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
- 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
全局常量:static final
- 被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
运行时常量池:
运行时常量池(Runtime Constant Pool) 是方法区的一部分。 Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table), 用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中。
对于运行时常量池,Java虚拟机规范没有做任何的细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过一般来说,除保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译期产生, 也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的便是String类的intern() 方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量无法在申请到内存时会抛出OutOfMemoryError异常。
直接内存
直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。
在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于 通道(Channel)与缓冲区(Buffer) 的I/O方式,他可以使用Native函数库直接分配堆外内存,然后同故宫一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一场场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是受到本机总内存大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。
其他
异常分析:
程序计数器: 内存区域中唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
虚拟机栈与本地方法栈: 在Java虚拟机栈和本地方法栈中,规定了两个异常状况:如果线程请求的栈深度大于栈所允许的深度,将抛出StackOverflowError异常;如果栈可以动态扩展,并且扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
堆: 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时(内存大小超过“-xmx"所指定的最大内存时),将会抛出OutOfMemoryError异常。
方法区: 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类(比如说加载大量第三方jar包),导致方法区溢出,虚拟机会抛出 OutOfMemoryError 异常。
栈、堆、方法区的交互关系:
- Person:存放在元空间,也可以说方法区
- person:存放在Java栈的局部变量表中
- new Person():存放在Java堆中
以上是关于jvm系列-03精通运行时数据区私有区域---虚拟机栈程序计数器本地方法栈的主要内容,如果未能解决你的问题,请参考以下文章
JVM -- Java虚拟机自动内存管理机制(运行时数据区域HotSpot虚拟机对象探秘)