1. 问题引出
今天在编码时,出现了java.lang.StackOverflowError
,就感觉很莫名其妙。
由于源代码涉及到公司业务,暂无法公开到博客上,望读者见谅。
但为了复现StackOverflowError
的错误,我特地编写如下代码来模拟:
/**
* 使用junit注解来调用testDegression方法
*
* @author super先生
* @datetime 2023/2/15 20:41
*/
@Test
public void testStackOverFlow() {
testDegression(100000);
}
/**
* 使用递归来模拟StackOverflowError
* 栈帧不断增加累积,栈的深度直到超出JVM所允许的深度
*
* @param param 整数原始值
* @author super先生
* @datetime 2023/2/15 20:40
*/
private void testDegression(int param) {
param--;
//递归,结束条件
if (param == 0) {
return;
}
testDegression(param);
}
该代码报出的如下图错误:
由上图可以看到,testDegression
一直在调用自己,也就是递归,从而导致栈溢出。
为什么递归调用
会报出这个错误(java.lang.StackOverflowError
)呢?
2. 分析问题
此时,正赶上ChatGPT
,我们不妨让ChatGPT
解释这个错误(java.lang.StackOverflowError
),解释结果如下图所示:
java.lang.StackOverflowError
StackOverflowError is a type of error that occurs when a program performs too many recursive calls, leading to an overflow of the call stack. This type of error is most commonly seen when a programming language does not terminate a recursive call, or when a programmer creates an infinite loop or recursive call. StackOverflowError can also be caused by an array being too large or an application attempting to allocate more memory than is available.
芭比Q了,解释结果是一段英文,不妨使用谷歌将其翻译成中文,如下所示:
java.lang.StackOverflowError
stackoverflowerror
是一种错误,当程序执行太多递归调用时,会导致堆栈的溢出。当编程语言未终止递归调用,或者程序员创建无限循环或递归调用时,这种错误最常见。stackoverflowerror
也可能是由于数组太大或试图分配比可用的更多内存的应用程序引起的。
根据中文翻译可以知道,产生堆栈溢出(StackOverflowError
)的原因有两个:
-
递归调用,这是最常见的错误
-
数组太大或分配内存比可用内存多
2.1 为什么递归调用会导致堆栈溢出
在计算机中,函数调用是通过栈(stack)
这种数据结构实现的,每当程序执行进入一个函数调用,栈就会加一层栈帧,每当函数返回,栈就会减一层栈帧。
由于栈的大小不是无限的,所以,递归调用的次数过多,就会导致栈溢出。
函数的参数是通过stack
栈来传递的,在调用中会占用线程的栈资源。
递归调用在到达最后的结束点后,函数才能依次退栈清栈,如果递归调用层数过多,就可能导致占用的栈资源超过线程的最大值,从而导致栈溢出,程序异常退出。
2.2 数组太大或分配内存多于可用内存导致堆栈异常
存到栈上的主要内容是:
-
局部变量
-
函数调用的函数环境,包括函数参数等。
假设局部变量buff
为1M
,如下代码所示:
char buff[1024*1024]
而你的栈默认也是1M
大小,这就会发生栈溢出。
因为其他东西会占掉少量栈空间,局部变量能用的空间肯定小于1M
,程序在执行到main函数之前,就会跳出stack overflow
异常。
3. 优化避免栈溢出
当栈不够使用时,一种办法是修改程序:
主要还是要注意递归调用引起的栈溢出,多数情况可以通过算法优化来解决:
-
控制递归深度。例如,使用动态规划来代替递归算法等。
-
修改栈的大小。
3.1 尾递归优化
尾递归是指在函数返回的时候,调用函数本身,并且return
语句不能包含表达式。
如果递归调用,都出现在函数的末尾,这个递归函数就是尾递归的函数。
尾递归函数的特点是在回归过程中,不用做操作,这个特性很重要,因为大多数现代编译器会利用这一特点,自动生成优化的代码。
有些语言极力提倡尾递归,因为它们的编译器会对代码进行优化,不会因为递归次数的增加,给函数栈带来巨大的开销。
尾递归优化,使无论调用多少次,都只占用一个栈帧,不会出现栈溢出的情况。
递归的优点是逻辑清晰。
3.2 循环替代递归
所有的递归都可以改写成循环的方式。
斐波那契数列: 1,1,2,3,5,…
求斐波那契数列的第N项的值
尾递归和循环的执行效率都非常高。
但是尾递归的递归层数大到一定程度会出现段错误。
尾递归的函数栈开销比普通递归要小的多,执行效率大很多。
但是尾递归仍然有函数栈开销。
正因为尾递归具有函数栈开销,其调用次数比循环小很多。
使用如下三种方式(一般递归,尾递归,循环取代递归
),来实现实现一个斐波那契数列
,能不用递归就别用递归,能用尾递归就用尾递归。
- 使用一般递归
int fib_normal(int n)
{
if (n <= 2)
return 1;
else
return fib_normal(n-1) + fib_normal(n-2);
}
- 尾递归
int fib_rail(int n, int first, int second)
{
if (n == 1) return first;
if (n == 2) return second;
return fib_rail(n-1, second, second+first);
}
unsigned int fib_rail_rec(unsigned int n)
{
return fib_rail_rec(n, 1, 1);
}
- 循环取代递归
int fib_no(int n)
{
if (n <= 2)
return 1;
int x=1, y=1, y_tmp=0;
for (int i=0; i<n-2; i++)
{
y_tmp = y;
y = x+y;
x = y_tmp;
}
return y;
}