java虚拟机栈
书上的描述:
- java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法执行的同时都会创建一个栈帧(StackFrame),用于存储局部变量表、操作数栈、动态链接、方法出口*等信息。每一个方法的执行就对应着栈帧在虚拟机栈中的入栈,出栈过程。
- 局部变量表存放编译期可知的各种基本数据类型、对象引用类型和returnAddress类型。64位长度的long和double类型的数据会占用2个局部变量空间(slot),其余的数据类型只占一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时这个方法需要在帧重分配多大的局部变量空间是完全确定的,方法运行期间不会改变局部变量表的大小。
- 在java虚拟机规范中,对这个区域规定了两种异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
栈帧
栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。
一个线程中的方法调用链路可能会很长,很多方法都处于同时执行的状态。对于执行引擎来说,在活动线程中,只有处于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图所示:
局部变量表
在工作和学习过程中,java程序员会把java内存分为堆内存和栈内存,这里所说的栈就是java虚拟机栈,更准确的说应该是虚拟机栈中的局部变量表。
**局部变量表是一组变量值存储空间,用以存储方法参数与方法内部定义的局部变量。**在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需的局部变量表的最大容量。
局部变量表的容量以变量槽(VariableSlot,下称Slot)为最小单位,虚拟机规范中并没有明确指出一个slot占应用内存的大小,只是很有导向性的指出一个slot都应该可以存放一个byte、short、int、float、char、boolean、对象引用(举个简单的例子我们用到的this,后面会提到)、returnAddress(指向一个字节码指令的地址),这8种类型的数据,都可以使用32位或者更小的空间去存储,但这种描述与明确指出“每个slot占用32位的内存空间”有一些区别,它允许slot的长度可以随着处理器、虚拟机、操作系统的不同而发生变化。
引申:
在JVM规范的第二版中,Java的三种原始数据类型是数值型、boolean类型、返回地址类型(returnAddress),这三种是JVM支持的原始类型。
returnAddress该类型是jsr,ret以及jsr_w指令需要使用到的,它的值是JVM指令的操作码的指针,并且它的值是不能被运行中的程序所修改的。而且因为java中明确的64位的数据类型只有long、double,(reference类型可能是32,也可能是64位的),所以我们returnAddress是占据一个slot空间的。
一个slot可以存放一个32位的数据类型,Java中占用32位以内的数据类型有byte(8)、short(16)、int(32)、float(32)、char(16)、boolean、reference(对象引用,java虚拟机没有规定reference类型的长度,它的实际长度与32位还是64位虚拟机有关,如果是64位虚拟机,他的长度还与是否开启某些对象指针的压缩优化有关)、returnAddress 8种数据类型。第7种refrence类型表示一个对象实例的引用,虚拟机规范中既没有说明长度也没有说明引用应有怎样结构。
**对于64位的数据类型,虚拟机会通过高位补齐的方式为其分配两个连续的slot空间,java中明确的64位的数据类型只有long、double,(reference类型可能是32,也可能是64位的),**如果访问的是32位数据类型,索引n就代表使用了第n个slot;如果访问的是64位数据类型,索引n就代表使用了第n和n+1个slot。对于两个相邻的存放64位数据的slot,不能单独访问其中一个,java虚拟机规范中明确要求了如果遇到了这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。
虚拟机如何调用这个局部变量表?
局部变量表是有索引的,就像数组一样。从0开始,到表的最大索引,也就是Slot的数量-1。
要注意的是,方法参数的个数 + 局部变量的个数 ≠ Slot的数量。因为Slot的空间是可以复用的,当pc计数器的值已经超出了某个变量的作用域时,下一个变量不必使用新的Slot空间,可以去覆盖前面那个空间。
例如:让我们考虑一个具有方法bike()的类示例,然后局部变量数组将如下图所示:
class Example
{
public void bike(int i, long l, float f,
double d, Object o, byte b)
{
return 0;
}
}
特别地,JVMS7:
On instance method invocation, local variable 0 is always used to pass a reference to the object on which the instance method is being invoked (this in the Java programming language)
手动翻译:在一个实例方法的调用时,局部变量表的第0位是一个指向当前对象的引用,也就是Java里的this。
在执行方法的时候,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static),那局部变量表的第0个slot默认用来传递方法所属对象的引用,在方法中通过this关键字可以访问这个隐含的参数。其余参数按照参数表顺序排列,参数表分配完毕,再根据方法内部局部变量的顺序和作用域分配slot。
为了尽可能节省栈帧空间,局部变量表中的slot是可以重用的。方法中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量所在的slot可以交给其他变量使用。不过这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用。例如,在某些情况下,slot的复用会直接影响到系统的gc。
探究——局部变量表Slot复用对垃圾收的影响
首先认识System.gc():
System.gc()用于调用垃圾收集器,在调用时,垃圾收集器将运行以回收未使用的内存空间。它将尝试释放被丢弃对象占用的内存。然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。我们习惯了从现实世界的经验中获得的“条件适用”。一切都附有免责声明!
JVM实现者可以通过System.gc()调用来决定JVM的行为。一般来说,我们在编写Java代码并将其留给JVM时,不要考虑内存管理。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。以下是调用gc有所作为的另一个例子。
了解一下System.gc()机制:
public class Main{
public static void main(String [] args){
byte[] placeholder = new byte[64*1024*1024];
System.gc();
}
}
对于上面dos输出的结果,我是这样理解的:
第一行,Allocation Filure(空间分配失败)引起了Minor GC。因为创建的对象太大,新生代装不下,所以进行了一次GC。
第二行,由于新生代GC完了后,还是装不下,这时就应该把它直接放到老年代,为了老年代又足够的空间来迎接这个大对象,所以老年代进行一次Full GC。
第三行,是代码中的手动gc,发现这次手动gc并没有回收掉这个大对象。因为,placeholder这个对象,还在作用域…就不该回收…
这回System.gc()该回收掉placeholder了吧?
public class Main{
public static void main(String [] args){
{
byte[] placeholder = new byte[64*1024*1024];
}
System.gc();
}
}
dos结果基本与上面一样,明显,还是没有回收掉这个placeholder大对象。为什么呢?
因为虚拟机并不急着让placeholder回收掉,因为,在我这个程序中,对虚拟机来说,回不回收placeholder,对内存没有丝毫影响,剩余的空间一样都是浪费(空闲)着,回收了反倒还浪费时间。
这样做才能成功回收:
public class Main{
public static void main(String [] args){
{
byte[] placeholder = new byte[64*1024*1024];
}
int a = 0;
System.gc();
}
}
其实复用之前,虽然placeholder退出了作用域,但是虚拟机并没有做什么事,只是知道pc指针已经超出了placeholder的作用域,知道placeholder过期了。所以placeholder仍保持者GC Roots之间的关联。
当a=0复用了前面对象的空间时,就打断了GC Roots与局部变量表中的placeholder之间的关联。因为a复用了这片空间(虽然只是用了一小部分)。此时GC Root无法达到placeholder对象,满足回收条件。
然后System.gc()就成功回收了。
也就是说在复用之前并不会判定为‘垃圾’,在复用后才会被判定为‘垃圾’。刚才使用一个int a来复用,这个复用看起来很轻量。
如果使用一个新的大对象来复用,那么GC是如何发生的呢?看下面代码:
public class Main{
public static void main(String [] args)throws InterruptedException{
{
byte[] placeholder = new byte[64*1024*1024];
}
byte[]arr= new byte[20*1024*1024];
System.gc();
}
}
解读dos下的输出:
第一行,因为即将创建的placeholder太大,新生代装不下,所以进行一次GC。
第二行, 因为GC之后还是装不下placeholder,所以把这个大对象直接放进老年代里。迎接这个大对象之前,先清一清自己的空间(Full GC),怕自己装不下。
第三行,因为即将创建的arr太大,新生代装不下,所以进行一次GC。
第四行,因为GC之后还是装不下arr, 所以把这个大对象直接放进老年代里。迎接这个大对象之前,先清一清自己的空间(Full GC),怕自己装不下。
但是,可以看到这一次Full GC并没有把placeholder清理掉,因为还没开始复用呢。
随后创建好了arr, 也就是复用了placeholder的空间。这时才把placeholder判定为垃圾。
第五行,是代码里手写的System.gc()方法。这时把placeholder这个垃圾清理掉。
有没有发现这个Full GC来的不是很恰到好处?因为没有及时清理掉placeholder。
为什么没有清理掉呢?因为局部变量表里的placeholder数据还和GC Root连着,导致没有判定它为垃圾。
能不能及时断开这个连接,让这个Full GC起到它该起的作用呢?
可以巧用null来解决,看下面代码:
public class Main{
public static void main(String [] args)throws InterruptedException{
{
byte[] placeholder = new byte[64*1024*1024];
placeholder = null;
}
byte[]arr= new byte[20*1024*1024];
System.gc();
}
}
解读dos下的输出:
第一行,因为即将创建的placeholder太大,新生代装不下,所以进行一次GC。
第二行, 因为GC之后还是装不下placeholder,所以把这个大对象直接放进老年代里。迎接这个大对象之前,先清一清自己的空间(Full GC),怕自己装不下。
随后placeholder= null;
第三行,因为即将创建的arr太大,新生代装不下,所以进行一次GC。
第四行,因为GC之后还是装不下arr, 所以把这个大对象直接放进老年代里。迎接这个大对象之前,先清一清自己的空间(Full GC),怕自己装不下。
可以看到这一次Full GC把placeholder清理掉了。
随后创建好了arr,复用了placeholder。
第五行,是代码里手写的System.gc()方法。
最后
对于局部变量,如果是基本类型,会把值直接存储在栈;如果是引用类型,比如String s = new String(“william”);会把其对象存储在堆,而把这个对象的引用(指针)存储在栈。
再如
String s1 = new String(“william”);
String s2 = s1;
s1和s2同为这个字符串对象的实例,但是对象只有一个,存储在堆,而这两个引用存储在栈中。
延伸——分代收集算法
分代搜集算法是针对对象的不同特性,而使用适合的算法,这里面并没有实际上的新算法产生。与其说分代搜集算法是第四个算法,不如说它是对前三个算法(标记-清除算法、复制算法、标记-整理算法)的实际应用。分代搜集算法根据对象的存活周期的不同而将内存分为几块,分别为新生代、老年代和永久代。
对象分类
- 新生代:朝生夕灭的对象(例如:方法的局部变量等)。
- 老年代:存活得比较久,但还是要死的对象(例如:缓存对象、单例对象等)。
- 永久代:对象生成后几乎不灭的对象(例如:加载过的类信息)。
内存区域
新生代和老年代都在java堆,永久代在方法区。
java堆对象的回收
现在,我们来看看分代收集算法是如何针对堆内存进行回收的。
新生代:采用复制算法,新生代对象一般存活率较低,因此可以不使用50%的内存作为空闲,一般的,使用两块10%的内存
作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的活动区间与另外80%中存
活的对象转移到10%的空闲区间,接下来,将之前90%的内存全部释放,以此类推,下面还是用一张图来说明:
年轻代(Young Generation):对象被创建时,内存的分配首先发生在年轻代(大对象可以直接 被创建在年老代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉(IBM的研究表明,98%的对象都是很快消 亡的),这个GC机制被称为Minor GC或叫Young GC。注意,Minor GC并不代表年轻代内存不足,它事实上只表示在Eden区上的GC。
年轻代上的内存分配是这样的,年轻代可以分为3个区域:Eden区(伊甸园,亚当和夏娃偷吃禁果生娃娃的地方,用来表示内存首次分配的区域,再贴切不过)和两个存活区(Survivor 0 、Survivor 1)。
第一点是使用这样的方式,我们只浪费了10%的内存,这个是可以接受的,因为我们换来了内存的整齐排列与GC速度。第二点是,这个策略的前提是,每次存活的对象占用的内存不能超过这10%的大小,一旦超过,多出的对象将无法复制。
解释下,堆大小=新生代+老年代,新生代与老年代的比例为1:2,新生代细分为一块较大的Eden空间和两块较小的Survivor空间,分别被命名为from和to。
老年代:老年代中使用“标记-清除”或者“标记-整理”算法进行垃圾回收,回收次数相对较少,每次回收时间比较长。
详细过程:
https://blog.csdn.net/hp910315/article/details/50985877
补充:上面只是说了年限过大放入年老代。在新生代存活对象占用的内存超过10%时,则多余的对象会放入年老代。这种时候,年老代就是新生代的“备用仓库”。
一个对象的这一辈子
我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。
方法区对象回收
永久代指的是虚拟机内存中的方法区,永久代垃圾回收比较少,效率也比较低,但也必须进行垃圾回收,否则永久代内存不够用时仍然会抛出OutOfMemoryError异常。永久代也使用“标记-清除”或者“标记-整理”算法进行垃圾回收。
回收的时机
JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC),它们所针对的区域如下。
普通GC(minor GC):只针对新生代区域的GC。
全局GC(major GC or Full GC):针对所有分代区域(新生代、年老代、永久代)的GC。
由于年老代与永久代相对来说GC效果不好,而且二者的内存使用增长速度也慢,因此一般情况下,需要经过好几次普通GC,才会触发一次全局GC。
标记-清除算法
标记-清除(Mark-Sweep)算法是最基础的算法,就如它的名字一样,算法分为”标记”和”清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。它主要有两个缺点:一个是效率问题,标记和清楚过程的效率都不高;另外一个是空间问题,标记清楚后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够连续的内存空间而不得不提前出发另一次垃圾收集动作。
复制算法
为了解决效率问题,一种称为复制(Copying)的收集算法就出现了,它将可用内存按容量划分为大小相等的两块,每次只是用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,没存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将运存缩小为原来的一半,未免太高了一点。
标记-整理算法
复制手机算法在对象存活率较高时就要执行较多的复制操作,效率将会贬低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种”标记-整理”算法,标记过程仍然与标记-清楚算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界意外的内存。
关于局部变量表,还有一点要注意,可能会影响开发的,就是他不存在类变量和实例变量那样的准备阶段,不存在初始值,在使用之前,必须要给值。在使用前,不给值,这段代码其实并不能运行,还好编译器能在编译期间就检查到并提示这一点,即使编译能通过或者手动生成字节码的方法制造出下面代码的效果,字节码校验的时候也会被虚拟机发现而导致类加载失败。
引申:
类变量(静态变量)和实例变量:
类变量也称为静态变量,在类中以static关键字声明,但必须在方法构造方法和语句块之外;实例变量声明在一个类中,但在方法、构造方法和语句块之外。类变量(静态变量)和实例变量都属于成员变量。
空间分配的时间不同
类变量是在类加载后的准备阶段在方法区分配内存的。实例变量是在类实例化为对象的时候在堆中分配内存。
初始化
类变量在准备阶段会进行默认初始化,当某些条件满足时候会触发类的初始化。实例变量在空间分配内存后,虚拟机会将所分配到的内存空间都初始化为零值(不包括对象头)。这一步操作保证了对象的实例字段在java代码中可以不赋初值就可以直接访问,程序能访问到这些字段的数据类型所对应的零值。
对于局部变量,只能显示地进行初始化,否则不能访问该变量的值。
操作数栈
前面我们讲到了局部变量表,局部变量表中的变量不可直接使用,如需使用必须通过相关指令将其加载至操作数栈中作为操作数使用.
什么是操作数栈
每个栈帧都包含一个被叫做操作数栈的后进先出的栈。叫操作栈,或者操作数栈。通常情况下,操作数栈指的就是当前栈桢的操作数栈。
操作数栈有什么用?
1.栈桢刚创建时,里面的操作数栈是空的。
2.Java虚拟机提供指令来让操作数栈对一些数据进行入栈操作,比如可以把局部变量表里的数据、实例的字段等数据入栈。
3.同时也有指令来支持出栈操作。
4.向其他方法传参的参数,也存在操作数栈中。
5.其他方法返回的结果,返回时存在操作数栈中。
例子操作数栈的使用:
下面是JVM如何使用下面的代码,它将减去两个包含两个int的局部变量,并将int结果存储在第三个局部变量中:
因此,前面两条指令iload_0和iload_1将从本地变量数组中推入操作数堆栈中的值。并且指令isub会将这两个值相减并将结果存回操作数堆栈,在istore_2之后,结果将从操作数堆栈中弹出并存储到位置2处的局部变量数组中。
操作数栈本身就是一个普通的栈吗?
其实栈就是栈,再加上数据结构所支持的一些指令和操作。
但是,这里的栈也是有约束的。
操作数栈是区分类型的,操作数栈中严格区分类型,而且指令和类型也好严格匹配。
栈桢和栈桢是完全独立的吗?
本来栈桢作为虚拟机栈的一个单元,应该是栈桢之间完全独立的。
但是,虚拟机进行了一些优化:为了避免过多的 方法间参数的复制传递、方法返回值的复制传递 等一些操作,就让一部分数据进行栈桢间共享。这种在进行方法调用时,可以共用一部分数据,无须进行额外的参数复制传递,java虚拟机的解释执行引擎被称为 基于栈的执行引擎,其中的栈就是操作数栈。
动态链接
什么是动态链接?
一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,总得知道被调用者的名字吧?(你可以不认识它本身,但调用它就需要知道他的名字)。符号引用就相当于名字,这些被调用者的名字就存放在Java字节码文件里。
名字是知道了,但是Java真正运行起来的时候,真的能靠这个名字(符号引用)就能找到相应的类和方法吗?
需要解析成相应的直接引用,利用直接引用来准确地找到。
举个例子,就相当于我在0X0300H这个地址存入了一个数526,为了方便编程,我把这个给这个地址起了个别名叫A, 以后我编程的时候(运行之前)可以用别名A来暗示访问这个空间的数据,但其实程序运行起来后,实质上还是去寻找0X0300H这片空间来获取526这个数据的。
这样的符号引用和直接引用在运行时进行解析和链接的过程,叫动态链接。
延伸——静态链接
上面我们知道了:动态链接的方式,即用到某个类再加载进内存。那静态链接呢,像C++那样使用静态链接:将所有类加载,不论是否使用到.
在Class文件中的常量持中存有大量的符号引用。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分在类的加载阶段(解析)或第一次使用的时候就转化为了直接引用(指向数据所存地址的指针或句柄等),这种转化称为静态链接。而相反的,另一部分在运行期间转化为直接引用,就称为动态链接。
方法调用及分派:有时间后面进行分享,因为要了解类文件结构才能更好的讲解。
方法返回地址
当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
最后这张图给大家理解一下整个过程
两种异常情况
虚拟机栈的StackOverflowError
若单个线程请求的栈深度大于虚拟机允许的深度,则会抛出StackOverflowError(栈溢出错误)。
JVM会为每个线程的虚拟机栈分配一定的内存大小(-Xss参数),因此虚拟机栈能够容纳的栈帧数量是有限的,若栈帧不断进栈而不出栈,最终会导致当前线程虚拟机栈的内存空间耗尽,典型如一个无结束条件的递归函数调用,代码见下:
/**
* java栈溢出StackOverFlowError
* JVM参数:-Xss128k
*/
public class JavaVMStackSOF {
private int stackLength = -1;
//通过递归调用造成StackOverFlowError
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("Stack length:" + oom.stackLength);
e.printStackTrace();
}
}
}
设置单个线程的虚拟机栈内存大小为128K,执行main方法后,抛出了StackOverflow异常
Stack length:19721
java.lang.StackOverflowError
at code/xuesheng.stackLeak(xuesheng.java:10)
虚拟机栈的OutOfMemoryError
不同于StackOverflowError,OutOfMemoryError指的是当整个虚拟机栈内存耗尽,并且无法再申请到新的内存时抛出的异常。
JVM未提供设置整个虚拟机栈占用内存的配置参数。虚拟机栈的最大内存大致上等于“JVM进程能占用的最大内存(依赖于具体操作系统) - 最大堆内存 - 最大方法区内存 - 程序计数器内存(可以忽略不计) - JVM进程本身消耗内存”。当虚拟机栈能够使用的最大内存被耗尽后,便会抛出OutOfMemoryError,可以通过不断开启新的线程来模拟这种异常,代码如下:
/**
* java栈溢出OutOfMemoryError
* JVM参数:-Xss2m
*/
public class JavaVMStackOOM {
private void dontStop() {
while (true) {
}
}
//通过不断的创建新的线程使Stack内存耗尽
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(() -> dontStop());
thread.start();
}
}
public static void main(String[] args) {
JavaVMStackOOM oom = new _03_JavaVMStackOOM();
oom.stackLeakByThread();
}
}
设置单个线程虚拟机栈的占用内存为2m并不断生成新的线程,最终虚拟机栈无法申请到新的内存,抛出异常:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
特别提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作,由于在Windows平台的虚拟机中,Java的线
程是映射到操作系统的内核线程上的,所以上述代码执行时有较大的风险,可能会导致操作系统假死。