jvm性能调优实战 - 45无限制的调用方法导致栈溢出OOM

简介: jvm性能调优实战 - 45无限制的调用方法导致栈溢出OOM

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导致的


相关文章
|
2月前
|
存储 监控 算法
jvm-性能调优(二)
jvm-性能调优(二)
|
3月前
|
存储 算法 Java
惊!Java程序员必看:JVM调优揭秘,堆溢出、栈溢出如何巧妙化解?
【8月更文挑战第29天】在Java领域,JVM是代码运行的基础,但需适当调优以发挥最佳性能。本文探讨了JVM中常见的堆溢出和栈溢出问题及其解决方法。堆溢出发生在堆空间不足时,可通过增加堆空间、优化代码及释放对象解决;栈溢出则因递归调用过深或线程过多引起,调整栈大小、优化算法和使用线程池可有效应对。通过合理配置和调优JVM,可确保Java应用稳定高效运行。
140 4
|
4月前
|
Arthas 监控 Java
(十一)JVM成神路之性能调优篇:GC调优、Arthas工具详解及各场景下线上最佳配置推荐
“在当前的互联网开发模式下,系统访问量日涨、并发暴增、线上瓶颈等各种性能问题纷涌而至,性能优化成为了现时代开发过程中炙手可热的名词,无论是在开发、面试过程中,性能优化都是一个常谈常新的话题”。
430 3
|
4月前
|
监控 Java 测试技术
JVM 性能调优 及 为什么要减少 Full GC
JVM 性能调优 及 为什么要减少 Full GC
121 4
|
1月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
65 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
1月前
|
Java 应用服务中间件 程序员
JVM知识体系学习八:OOM的案例(承接上篇博文,可以作为面试中的案例)
这篇文章通过多个案例深入探讨了Java虚拟机(JVM)中的内存溢出问题,涵盖了堆内存、方法区、直接内存和栈内存溢出的原因、诊断方法和解决方案,并讨论了不同JDK版本垃圾回收器的变化。
30 4
|
1月前
|
存储 缓存 监控
聊聊JIT是如何影响JVM性能的!
聊聊JIT是如何影响JVM性能的!
|
2月前
|
Kubernetes Java 编译器
解锁极致性能:Quarkus如何让JVM应用调优变得前所未有的简单与高效!
Quarkus是一款专为GraalVM和OpenJDK设计的Kubernetes Native Java框架,采用AOT编译技术将Java应用转化为本地代码,大幅提升启动速度与运行效率。它简化了性能调优流程,如自动优化垃圾回收、类加载、内存管理及线程管理等,使开发者无需深入理解JVM细节即可轻松提升应用性能。与传统JVM应用相比,Quarkus显著降低了性能调优的复杂度。
100 2
|
3月前
|
存储 安全 Java
JVM常见面试题(二):JVM是什么、由哪些部分组成、运行流程,JDK、JRE、JVM关系;程序计数器,堆,虚拟机栈,堆栈的区别是什么,方法区,直接内存
JVM常见面试题(二):JVM是什么、由哪些部分组成、运行流程是什么,JDK、JRE、JVM的联系与区别;什么是程序计数器,堆,虚拟机栈,栈内存溢出,堆栈的区别是什么,方法区,直接内存
JVM常见面试题(二):JVM是什么、由哪些部分组成、运行流程,JDK、JRE、JVM关系;程序计数器,堆,虚拟机栈,堆栈的区别是什么,方法区,直接内存
|
3月前
|
缓存 监控 算法
吃透 JVM 诊断方法与工具使用
【8月更文挑战第4天】深入了解并掌握JVM诊断需把握几大要点:1) 熟悉JVM内存模型,如堆、栈及方法区;2) 掌握垃圾回收机制与算法;3) 运用工具如`jps`(查看Java进程)、`jstat`(监控运行状态)、`jmap`(生成堆快照)、`jhat`(分析堆快照)、`jstack`(检查线程栈); 4) 利用专业工具如Eclipse Memory Analyzer分析堆转储文件查找内存泄漏; 5) 动态监控与调整JVM参数; 6) 结合日志分析性能瓶颈。通过实战案例加深理解,有效应对JVM性能问题。