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


相关文章
|
存储 Java 开发者
浅析JVM方法解析、创建和链接
上一篇文章《你知道Java类是如何被加载的吗?》分析了HotSpot是如何加载Java类的,本文再来分析下Hotspot又是如何解析、创建和链接类方法的。
585 132
|
存储 算法 Java
惊!Java程序员必看:JVM调优揭秘,堆溢出、栈溢出如何巧妙化解?
【8月更文挑战第29天】在Java领域,JVM是代码运行的基础,但需适当调优以发挥最佳性能。本文探讨了JVM中常见的堆溢出和栈溢出问题及其解决方法。堆溢出发生在堆空间不足时,可通过增加堆空间、优化代码及释放对象解决;栈溢出则因递归调用过深或线程过多引起,调整栈大小、优化算法和使用线程池可有效应对。通过合理配置和调优JVM,可确保Java应用稳定高效运行。
496 4
|
NoSQL Java Redis
秒杀抢购场景下实战JVM级别锁与分布式锁
在电商系统中,秒杀抢购活动是一种常见的营销手段。它通过设定极低的价格和有限的商品数量,吸引大量用户在特定时间点抢购,从而迅速增加销量、提升品牌曝光度和用户活跃度。然而,这种活动也对系统的性能和稳定性提出了极高的要求。特别是在秒杀开始的瞬间,系统需要处理海量的并发请求,同时确保数据的准确性和一致性。 为了解决这些问题,系统开发者们引入了锁机制。锁机制是一种用于控制对共享资源的并发访问的技术,它能够确保在同一时间只有一个进程或线程能够操作某个资源,从而避免数据不一致或冲突。在秒杀抢购场景下,锁机制显得尤为重要,它能够保证商品库存的扣减操作是原子性的,避免出现超卖或数据不一致的情况。
377 10
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
1032 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
Java 应用服务中间件 程序员
JVM知识体系学习八:OOM的案例(承接上篇博文,可以作为面试中的案例)
这篇文章通过多个案例深入探讨了Java虚拟机(JVM)中的内存溢出问题,涵盖了堆内存、方法区、直接内存和栈内存溢出的原因、诊断方法和解决方案,并讨论了不同JDK版本垃圾回收器的变化。
386 4
|
监控 架构师 Java
JVM进阶调优系列(6)一文详解JVM参数与大厂实战调优模板推荐
本文详述了JVM参数的分类及使用方法,包括标准参数、非标准参数和不稳定参数的定义及其应用场景。特别介绍了JVM调优中的关键参数,如堆内存、垃圾回收器和GC日志等配置,并提供了大厂生产环境中常用的调优模板,帮助开发者优化Java应用程序的性能。
|
存储 监控 算法
JVM调优深度剖析:内存模型、垃圾收集、工具与实战
【10月更文挑战第9天】在Java开发领域,Java虚拟机(JVM)的性能调优是构建高性能、高并发系统不可或缺的一部分。作为一名资深架构师,深入理解JVM的内存模型、垃圾收集机制、调优工具及其实现原理,对于提升系统的整体性能和稳定性至关重要。本文将深入探讨这些内容,并提供针对单机几十万并发系统的JVM调优策略和Java代码示例。
428 2
|
存储 IDE Java
实战优化公司线上系统JVM:从基础到高级
【11月更文挑战第28天】Java虚拟机(JVM)是Java语言的核心组件,它使得Java程序能够实现“一次编写,到处运行”的跨平台特性。在现代应用程序中,JVM的性能和稳定性直接影响到系统的整体表现。本文将深入探讨JVM的基础知识、基本特点、定义、发展历史、主要概念、调试工具、内存管理、垃圾回收、性能调优等方面,并提供一个实际的问题demo,使用IntelliJ IDEA工具进行调试演示。
274 0
|
存储 安全 Java
JVM常见面试题(二):JVM是什么、由哪些部分组成、运行流程,JDK、JRE、JVM关系;程序计数器,堆,虚拟机栈,堆栈的区别是什么,方法区,直接内存
JVM常见面试题(二):JVM是什么、由哪些部分组成、运行流程是什么,JDK、JRE、JVM的联系与区别;什么是程序计数器,堆,虚拟机栈,栈内存溢出,堆栈的区别是什么,方法区,直接内存
JVM常见面试题(二):JVM是什么、由哪些部分组成、运行流程,JDK、JRE、JVM关系;程序计数器,堆,虚拟机栈,堆栈的区别是什么,方法区,直接内存
|
监控 Java Spring
JVM如何监控某个方法的入参和相应结果?
JVM如何监控某个方法的入参和相应结果?
240 0