type
status
date
slug
summary
tags
category
icon
password
栈溢出
栈溢出一般就是代码不当导致的,在 JVM的内存结构 中有讲到过,每个方法在调用时都会在本线程的栈中创建一个栈帧,每一个栈帧都会占用栈的一块内存空间,所以栈帧越大或者越多,就会出现栈溢出(StackOverflowError)。但这样说实在是有过浅显了,所以我们还是要问一句,为什么?
首先我要说第一个前提——JVM 的每一个栈帧的大小在方法调用时就确定了,是固定的,不会动态变化。
大家如果了解过 JVM 的内存结构就会知道,栈中放的都是大小已知的东西:局部变量表(全是基本变量)、操作数栈、方法出口(返回地址)、一些额外数据(栈帧指针等),因此栈帧的大小在方法调用前就已经根据
.class 文件(字节码)中的元数据确定了。每个方法都带着两个重要的属性:max_locals—— 局部变量表大小
max_stack—— 操作数栈最大深度
例如:
编译后会确定:
- max_locals = 3(a、b、c)
- max_stack = 2(执行 a + b 时需要两个操作数)
这两个值是编译器编译时算出来的,因此无需在运行时动态扩张。
那么问题来了:既然栈帧的大小不会变,那么线程的栈同样也是栈,它的大小会变吗?
答案:不能,JVM 启动时指定每个线程栈大小(-Xss),每个线程创建时直接一次性申请这个大小的连续内存块,之后就不会动态扩张了。因此,当栈帧不断增多,很可能就会 " 撑爆 " 线程的栈,从而出现栈溢出,最简单的例子就是递归,每一个递归在 return 前它所占用内存空间都不会释放。
虽然说线程的栈的大小是固定的,但栈帧大小已知,那为什么线程不直接按 " 最大栈帧大小 × 最大调用深度 " 顶格分配?
首先有一个最大的问题:无法预先知道方法调用路径。JVM 不知道你在运行时会执行哪些方法、递归多深,甚至这个递归的深度可能会根据用户的输入而改变,这是运行时改变,还记得栈是线程创建时创建的吗,上一个因用户所带来的未知而出现的是 " 堆 "。
其次,即使我们确定了最大的一个栈帧的大小、规定了其最大递归深度,那也太太太浪费资源了,哪怕 Java 是企业级开发,内存相对而言不太紧张,但也正因为企业级的项目一不留神可能就会出现井喷式的 API 调用,以防用户使用不便,每一个线程和每一个 G 的内存都是宝贵的资源,更要细心一点 (说白了还是成本控制的问题)。
堆的垃圾回收
栈溢出的原因大多都是代码写的差,跟程序员交互的比较多,少写点垃圾代码就能解决。
因此内存优化主要还是看堆,首先照例说一下堆里存了哪些东西:创建的实例对象、数组以及字符串常量池。
开始说内存优化之前先来讲一下垃圾回收相关的知识,我们现在常用的 JDK 版本主要是 8、11、17。8 默认的垃圾收集器是 Parallel 和 Parallel Old,而 11 和 17 默认的是 G1,它们都是分代收集器,也就是会分为一个新生代和一个老年代,新生代的回收叫做 Minor GC,而老年代的回收叫做 Major GC,Minor GC 回收的频率非常快,而 Major GC 频率比较低,相比前者慢 10 倍。
我们先讲一下 Java 8 的收集器,分别是新生代的 Paralled 和老年代使用的 Parallel Old,新生代用的是复制算法,老年代用的是标记整理法,这些算法的概念参考 [[GC]] 或者 ChatGPT,详细就不说了。
这两个收集器尤其是新生代的,它的特点就是高吞吐量,什么是高吞吐量?比如说我现在吞吐量是 99,就代表一个虚拟机总共运行了 100 分钟,其中垃圾回收用了 1 分钟,实际干活的时间是 99 分钟,这就是吞吐量 99 的意思。与此相对会有个比较大的缺点——响应时间比较长,用户可能会出现长时间的卡顿,因为为了保证高吞吐量,他会一下子打扫干净,比如在 Eden 区和 Survivor From 区的垃圾会被一下子全部回收,也就是一次打扫的时间会比较久,这样就会导致 STM(所有应用程序暂停)时间会非常的长,垃圾收集器会独占 CPU 的资源,卡顿的频率不会很高,但是卡顿的时间可能会有点久,对于面向客户体验的系统,这个垃圾回收器就不是一个很好的选择。
而且在高并发的场景下,长时间 STW 可能会一下子堆积大量的请求,导致系统崩溃,也就是当我们在垃圾收集的时候,可能会发送大量的请求进来,因为垃圾收集器在工作的时候应用线程是不能执行任务的,所以请求都会卡在这个地方,等到垃圾回收结束,所有的请求会一下子涌入进来,就很容易导致系统崩溃。
当然 Paralle 依旧是热门收集器之一,除了高并发场景之外,发生问题或者影响用户体验的概率还是比较低的。
接着说一下 Java11 和 17 用的 G1,G1 整体上用的是标记整理算法,局部上用的是复制算法,他新生代和老年代的垃圾回收器都是用的 G1,那为什么还说它是分代收集器呢?因为它会把内存划分为 2048 个区域,即 region。每一个 region 可以是 Eden,也可以是 Survivor,也可以是 Old,所以它从概念上还是划分成新生代和老年代的。G1 整体上还是用的标记整理算法,但是它每一个 region,也就是每一个内存区域,用的是复制算法。
我们来简单说一下这个算法的流程:首先,G1 会根据每个 region 的回收价值,优先回收价值最高的 region(Eden),这也就是为什么叫 G1 的原因,one 代表 first。首先标记所有被选中的 region,比如选中了两个 Eden,它就会把这两个 region 中存活的对象先标记下来,将存货的对象复制到空闲的 region 中,这部操作体现了复制和整理。不同 region 中存活的对象集中复制到一个新的 region 中,这便是整理存活对象,最后就是把这两个旧 region 回收掉。
其实 G1 中的标记整理算法和复制算法与传统的还是有点区别的,思想上还是一致的,有兴趣的可以自己去了解一下。
G1 有两个显著的优势:第一个。因为 region 特别多,有 2048 个,如果每次要把所有的 region 都回收,这样造成 STW 时间就会很长,而回收价值最高的,这样就能保证垃圾收集的一个高效性;第二个。可以通过设置期望停顿时间,也就是 STW 时间是可以控制的,这样就能避免 Java8 中会出现的 Parallel 收集器响应时间过长的问题,用户也就能避免出现长时间卡顿的问题。
最后关于垃圾回收器还有一个概念————可达性分析。可达性分析说法有很多,我觉得最好理解的就是一个对象要有与之关联的引用,我在 ThreadLocal 这篇文章中提到过强引用和弱引用,我这里简述一下:当我们在堆中创建一个普通的对象的时候,创建它的线程同时也会在栈帧中的局部变量表中存放该对象的引用值 (这个引用值在 JVM 内部可能是一个句柄(Handle),通过句柄可以访问到堆内存中的对象信息。)。当这个方法结束,或者说栈帧结束的时候,局部变量表被清理,与之对应的引用就会消失,此时堆中的这个对象就可以被回收了,它也就不会被 G1 标记了。
JVM 调优
看了以上的内容,了解了大概,那么优化对你们来说就只是需要想。
优化可以从四个层面考虑:
- 首先是新生代 GC 和老年代 GC 的执行时间,老年代 GC 时间很久,尽可能的减少老年代回收次数,同时还要保证新生代回收的稳定。
- 其次是整个堆内存的大小,内存太小就容易导致 OOM,或者频繁发生 GC。
- 根据业务的实际情况,选择适合程序的垃圾收集器。
- Java 代码层面,控制生成对象的大小和数量。
以下是具体的调优方案:
-Xmn:可以为应用程序分配一个合理的新生代空间,以最大限度避免新对象直接进去老年代。
java -Xmn256m -jar application.jar-XX:+UseAdaptiveSizePolicy:开启 UseAdaptiveSizePolicy 动态调整 Survivor 区域,可以让新生代区动态的挪用部分老年代的内存,减少新生代回收次数;也可以防止新生代太小频繁 Minor GC 或者提前进入老年代,新生代太大,也会增加 Minor GC 的时间。
java -XX:+UseAdaptiveSizePolicy -jar application.jar-XX:PretenureSizeThreshold:设置大对象直接进入老年代的阈值(单位是字节),当对象超过这个阈值时,将直接在老年代中分配。
java -XX:PretenureSizeThreshold=1048576 -jar application.jar-XX:MaxTenuringThreshold:进入老年代的最大年龄值,每次存活下来年龄就会 +1,默认是 15,一些内存比较小的对象很多,可以增加最大年龄值,让这些小登对象尽可能一直留在新生代。
java -XX:MaxTenuringThreshold=16 -jar application.jar-Xmx -Xms -XX:minheaoFreeRatio -XX:MaxHeapFreeRatio:设置堆的初始化大小、最大大小、堆最小空闲比例、堆最大空闲比例。
-XX:+UseParallelGC:根据项目不同情况,选择合适的垃圾收集器,比如响应速度优先的 G1。
- 当我们知道哪些对象会在堆内存时,引用类型、数组、字符串,针对实际情况,减少这些对象的创建,或者减少这个对象占据内存的大小;当然还可以声明一些非强引用对象,这样也能避免 OOM。
- 作者:林明菁
- 链接:https://blog.lxuan.fun/article/jvmncyh
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章





