Pre
上一篇文章我们已经分析了Metaspace区域内存溢出的原理和两种情况,这篇文章我们就顺着JVM的运行原理继续分析一下,线程的栈内存是如何内存溢出的。
因为在JVM加载了我们写的类到内存里之后,下一步就是去通过线程执行方法,此时就会有方法的入栈出栈相关的操作,那么我们来分析一下线程的栈内存到底是因为什么原因会导致溢出呢?
一个线程调用多个方法的入栈和出栈
大家先回顾一下之前我们画好的图,那个图是一个相对较为完整的JVM运行原理图,如下所示。
现在我们来看下面的代码:
public static void main(String[] args) { String message = "单独"; System.out.println(message); sayHi("你好"); } private static void sayHi(String message) { System.out.println(message); }
按照我们之前所说的,JVM启动之后,HelloWorld类被加载到了内存里来,然后就会通过main线程执行main()方法
此时在main线程的虚拟机栈里,就会压入main()方法对应的栈桢,里面就会放入main()方法中的局部变量。
大家看看上面的图,在图里是不是有main线程的虚拟机栈和main()方法的栈桢的概念?
而且我们还知道一个概念,就是我们是可以手动设置每个线程的虚拟机栈的内存大小的,一般来说现在默认都是给设置1MB
所以看下图,main线程的虚拟机栈内存大小一般也是固定的。
现在回过头思考一下上面的代码,代码中是不是在main()方法中又继续调用了一个sayHello()方法?
而且sayHello()方法中也会自己的局部变量,所以此时会继续将sayHello()方法的栈桢压入到main线程的虚拟机栈中去,如下图。
接着sayHello()方法如果运行完毕之后,就不需要为这个方法在内存中保存他的一些局部变量之类的东西了,此时就会将sayHello()方法对应的栈桢从main线程的虚拟机栈里出栈,如下图。
再接着,一旦main()方法自己本身也运行完毕,自然会将main()方法对应的栈桢也从main线程的虚拟机栈里出栈,这里我们就不在图里表示出来了。
每次方法调用的栈桢都是要占用内存的
在这里,要给大家明确一个重要的概念,那就是每个线程的虚拟机栈的大小是固定的,比如可能就是1MB,然后每次这个线程调用一个方法,都会将本次方法调用的栈桢压入虚拟机栈里,这个栈桢里是有方法的局部变量的。
虽然说一些变量和其他的一些数据占用不了太大的内存,但是大家要记得,每次方法调用的栈桢实际上也是会占用内存的!
这是非常关键的一点,哪怕一个方法调用的栈桢就占用几百个字节的内存,那也是内存占用!
到底什么情况下会导致JVM中的栈内存溢出?
既然明确了上述前提之后,那么大家思考一下,到底什么情况下JVM中的栈内存会溢出呢?
其实非常简单,既然一个线程的虚拟机栈内存大小是有限的,比如1MB,那么假设你不停的让这个线程去调用各种方法,然后不停的把方法调用的栈桢压入栈中,是不是就会不断的占用这个线程1MB的栈内存?
如下图所示
那么如果不停的让线程调用方法,不停的往栈里放入栈桢,此时终有一个时刻,大量的栈桢会消耗完毕这个1MB的线程栈内存,最终就会导致出现栈内存溢出的情况。
一般什么情况下会发生栈内存溢出?
那么一般什么情况下会发生栈内存溢出呢?
通常而言,哪怕你的线程的虚拟机栈内存就128KB,或者256KB,通常都是足够进行一定深度的方法调用的。
但是如果说你要是走一个递归方法调用,那就不一定了,看下面的代码。
private static void sayHi(String message) { sayHi(message); }
一旦出现上述代码,一个线程就会不停的调用同一个方法,即使是同一个方法,每一次方法调用也会产生一个栈桢压入栈里,比如说对sayHello()进行100次调用,那么就会有100个栈桢压入中。
所以如果疯狂的运行上述代码,就会不停的将sayHello()方法的栈桢压入栈里,最终一定会消耗掉线程的栈内存,引发内存溢出。
所以一般来说,其实引发栈内存溢出,往往都是代码里写了一些bug才会导致的,正常情况下发生的比较少。
Case Demo
重新分析一下JVM中的栈内存
咱们先来简单的回顾一下JVM的整体运行原理,大家不要忘记下面这张图,必须牢牢印刻在自己的脑海里。
既然大家已经对Metaspace这块区域的内存溢出理解的很深刻了,那么接着我们来回顾一下栈内存这块区域的内存溢出。
讲到这里我们先带着大家来思考一下,JVM进程到底会占用机器上多少内存?
先不考虑一些细小的其他内存区域,就仅仅考虑一下最核心的几块就可以了,包括了:Metaspace区域,堆内存区域,各个线程的栈内存区域。
Metaspace区域我们一般会设置为512MB左右的大小,这个大小只要你代码里没有自己胡乱生成类,一般都是绝对足够存放你一个系统运行时需要的类的。
堆内存大小,之前在分析GC的时候给大家大量的分析过,堆内存一般分配在机器内存的一半就差不多了,毕竟还要考虑其他人对内存的使用。
那么最后一块内存区域我们之前一直没怎么给大家说过,就是栈内存区域。
其实大家考虑一个最基本的线上机器配置,比如4核8G的线上机器,其中512MB给了Metaspace,4G给了堆内存(其中包括了年轻代和老年代),剩余就只有3G左右内存了,要考虑到操作系统自己也会用掉一些内存。
那么剩余你就认为有一两个GB的内存可以留给栈内存好了。
通常来说,我们会设置每个线程的栈内存就是1MB,假设你一个JVM进程内包括他自带的后台线程,你依赖的第三方组件的后台线程,加上你的核心工作线程(比如说你部署在Tomcat中,那就是Tomcat的工作线程),还有你自己可能额外创建的一些线程,可能你一共JVM中有1000个线程。
那么1000个线程就需要1GB的栈内存空间,每个线程有1MB的空间。
所以基本上这套内存模型是比较合理的,其实一般来说,4核8G机器上运行的JVM进程,比如一个Tomcat吧,他内部所有的线程数量加起来在几百个是比较合理的,也就占据几百MB的内存,线程太多了,4核CPU负载也会过高,也并不好。
所以Metaspace区域+堆内存+几百个线程的栈内存,就是JVM一共对机器上的内存资源的一个消耗。
所以大家这里也能理解一个道理,你要是给每个线程的栈内存分配过大的空间,那么会导致机器上能创建的线上数量变少,要是给每个线程的栈内存相对较小,能创建的线程就会比较多一些。
当然一般来说,现在都建议给栈内存在1MB就可以了。
Review栈内存溢出的原理
接着我们来回顾一下栈内存溢出的原理,其实特别的简单,大家看下图中画红圈的地方,其实每个线程的栈内存是固定的,要是你一个线程不停的无限制的调用方法,每次方法调用都会有一个栈桢入栈,此时就会导致线程的栈内存被消耗殆尽。
但是通常而言你的线程不至于连续调用几千次甚至几万次方法,对不对?一般发生这种情况,只有一个原因,就是你的代码有bug,出现了死循环调用,或者是无限制的递归调用,最后连续调用几万次之后,栈内存就溢出了,没法放入更多的方法栈桢了。
模拟Code
上面的代码非常简单,就是work()方法调用自己,进入一个无限制的递归调用,陷入死循环,也就是说在main线程的栈中,会不停的压入work()方法调用的栈桢,直到1MB的内存空间耗尽。
另外大家需要设置这个程序的JVM参数如下**:-XX:ThreadStackSize=1m**,通过这个参数设置JVM的栈内存为1MB。
接着大家运行这段代码,会看到如下所示的打印输出:
目前是第5675次调用方法
java.lang.StackOverflowError
也就是说,当这个线程调用了5675次方法之后,他的栈里压入了5675个栈桢,最终把1MB的栈内存给塞满了,引发了栈内存的溢出。大家看到StackOverflowError,就知道是线程栈内存溢出了。
小结
分析了栈内存溢出的根本原理和可能触发的一个场景,就是方法递归调用
但是一般来说,其实只要注意一下代码的编写,避免出现无限制的方法递归,就一般可以避免栈内存的溢出。
可以看到1MB的栈内存可以让你连续调用5000次以上的方法,其实这个数量已经很多了,除了递归方法以外,一般根本不可能出现方法连续调用几千次的。
所以 一般这种栈内存溢出是极少在生产环境出现的,即使有,一般都是代码中的bug导致的