1. 개요
StackOverflowError 는 우리가 접할 수 있는 가장 일반적인 런타임 오류 중 하나이기 때문에 Java 개발자에게 성가실 수 있습니다.
이 기사에서는 다양한 코드 예제와 이를 처리할 수 있는 방법을 살펴Spring으로써 이 오류가 어떻게 발생할 수 있는지 알아봅니다.
2. 스택 프레임 및 StackOverflowError 발생 방식
기본부터 시작하겠습니다. 메서드가 호출되면 호출 스택 에 새 스택 프레임이 생성됩니다 . 이 스택 프레임은 호출된 메서드의 매개 변수, 로컬 변수 및 메서드의 반환 주소, 즉 호출된 메서드가 반환된 후 메서드 실행이 계속되어야 하는 지점을 포함합니다.
스택 프레임 생성은 중첩된 메서드 내에서 발견된 메서드 호출의 끝에 도달할 때까지 계속됩니다.
이 과정에서 JVM이 새 스택 프레임을 생성할 공간이 없는 상황이 발생하면 StackOverflowError 가 발생 합니다.
JVM이 이 상황에 직면하는 가장 일반적인 원인은 종료되지 않은/무한 재귀 입니다. StackOverflowError 에 대한 Javadoc 설명 에는 특정 코드 스니펫에서 너무 깊은 재귀의 결과로 오류가 발생한다고 언급되어 있습니다.
그러나 재귀가 이 오류의 유일한 원인은 아닙니다. 응용 프로그램이 스택이 소진될 때까지 메서드 내에서 메서드를 계속 호출하는 상황에서도 발생할 수 있습니다 . 어떤 개발자도 의도적으로 나쁜 코딩 관행을 따르지 않기 때문에 드문 경우입니다. 또 다른 드문 원인은 메서드 내부에 방대한 수의 지역 변수 가 있기 때문입니다 .
StackOverflowError 는 응용 프로그램이 클래스 간에 순환 관계 를 갖도록 설계되었을 때도 발생할 수 있습니다 . 이 상황에서 서로의 생성자가 반복적으로 호출되어 이 오류가 발생합니다. 이것은 또한 재귀의 한 형태로 간주될 수 있습니다.
이 오류를 일으키는 또 다른 흥미로운 시나리오는 클래스가 해당 클래스의 인스턴스 변수와 동일한 클래스 내에서 인스턴스화되는 경우입니다 . 이로 인해 동일한 클래스의 생성자가 반복해서(재귀적으로) 호출되어 결국 StackOverflowError가 발생합니다.
다음 섹션에서는 이러한 시나리오를 보여주는 몇 가지 코드 예제를 살펴보겠습니다.
3. 작동 중인 StackOverflowError
아래에 표시된 예 에서 개발자가 재귀 동작에 대한 종료 조건을 지정하는 것을 잊은 의도하지 않은 재귀로 인해 StackOverflowError 가 발생합니다.
public class UnintendedInfiniteRecursion {
public int calculateFactorial(int number) {
return number * calculateFactorial(number - 1);
}
}
여기에서 메서드에 전달된 모든 값에 대해 모든 경우에 오류가 발생합니다.
public class UnintendedInfiniteRecursionManualTest {
@Test(expected = StackOverflowError.class)
public void givenPositiveIntNoOne_whenCalFact_thenThrowsException() {
int numToCalcFactorial= 1;
UnintendedInfiniteRecursion uir
= new UnintendedInfiniteRecursion();
uir.calculateFactorial(numToCalcFactorial);
}
@Test(expected = StackOverflowError.class)
public void givenPositiveIntGtOne_whenCalcFact_thenThrowsException() {
int numToCalcFactorial= 2;
UnintendedInfiniteRecursion uir
= new UnintendedInfiniteRecursion();
uir.calculateFactorial(numToCalcFactorial);
}
@Test(expected = StackOverflowError.class)
public void givenNegativeInt_whenCalcFact_thenThrowsException() {
int numToCalcFactorial= -1;
UnintendedInfiniteRecursion uir
= new UnintendedInfiniteRecursion();
uir.calculateFactorial(numToCalcFactorial);
}
}
그러나 다음 예제에서는 종료 조건이 지정되었지만 -1 값이 calculateFactorial() 메서드에 전달 되면 종료되지 않은/무한 재귀가 발생 하는 경우 결코 충족되지 않습니다 .
public class InfiniteRecursionWithTerminationCondition {
public int calculateFactorial(int number) {
return number == 1 ? 1 : number * calculateFactorial(number - 1);
}
}
이 테스트 세트는 이 시나리오를 보여줍니다.
public class InfiniteRecursionWithTerminationConditionManualTest {
@Test
public void givenPositiveIntNoOne_whenCalcFact_thenCorrectlyCalc() {
int numToCalcFactorial = 1;
InfiniteRecursionWithTerminationCondition irtc
= new InfiniteRecursionWithTerminationCondition();
assertEquals(1, irtc.calculateFactorial(numToCalcFactorial));
}
@Test
public void givenPositiveIntGtOne_whenCalcFact_thenCorrectlyCalc() {
int numToCalcFactorial = 5;
InfiniteRecursionWithTerminationCondition irtc
= new InfiniteRecursionWithTerminationCondition();
assertEquals(120, irtc.calculateFactorial(numToCalcFactorial));
}
@Test(expected = StackOverflowError.class)
public void givenNegativeInt_whenCalcFact_thenThrowsException() {
int numToCalcFactorial = -1;
InfiniteRecursionWithTerminationCondition irtc
= new InfiniteRecursionWithTerminationCondition();
irtc.calculateFactorial(numToCalcFactorial);
}
}
이 특별한 경우에 종료 조건을 다음과 같이 지정하면 오류를 완전히 방지할 수 있습니다.
public class RecursionWithCorrectTerminationCondition {
public int calculateFactorial(int number) {
return number <= 1 ? 1 : number * calculateFactorial(number - 1);
}
}
실제로 이 시나리오를 보여주는 테스트는 다음과 같습니다.
public class RecursionWithCorrectTerminationConditionManualTest {
@Test
public void givenNegativeInt_whenCalcFact_thenCorrectlyCalc() {
int numToCalcFactorial = -1;
RecursionWithCorrectTerminationCondition rctc
= new RecursionWithCorrectTerminationCondition();
assertEquals(1, rctc.calculateFactorial(numToCalcFactorial));
}
}
이제 클래스 간의 순환 관계의 결과로 StackOverflowError 가 발생 하는 시나리오를 살펴보겠습니다 . 순환 관계를 일으키는 생성자 내에서 서로를 인스턴스화하는 ClassOne 및 ClassTwo 를 고려해 보겠습니다 .
public class ClassOne {
private int oneValue;
private ClassTwo clsTwoInstance = null;
public ClassOne() {
oneValue = 0;
clsTwoInstance = new ClassTwo();
}
public ClassOne(int oneValue, ClassTwo clsTwoInstance) {
this.oneValue = oneValue;
this.clsTwoInstance = clsTwoInstance;
}
}
public class ClassTwo {
private int twoValue;
private ClassOne clsOneInstance = null;
public ClassTwo() {
twoValue = 10;
clsOneInstance = new ClassOne();
}
public ClassTwo(int twoValue, ClassOne clsOneInstance) {
this.twoValue = twoValue;
this.clsOneInstance = clsOneInstance;
}
}
이제 이 테스트에서 볼 수 있듯이 ClassOne 을 인스턴스화하려고 한다고 가정해 보겠습니다 .
public class CyclicDependancyManualTest {
@Test(expected = StackOverflowError.class)
public void whenInstanciatingClassOne_thenThrowsException() {
ClassOne obj = new ClassOne();
}
}
ClassOne의 생성자가 ClassTwo 를 인스턴스화하고 ClassTwo 의 생성자가 다시 ClassOne 을 인스턴스화 하기 때문에 이것은 StackOverflowError 로 끝납니다 . 그리고 이것은 스택을 넘칠 때까지 반복적으로 발생합니다.
다음으로 클래스가 해당 클래스의 인스턴스 변수와 동일한 클래스 내에서 인스턴스화될 때 어떤 일이 발생하는지 살펴보겠습니다.
다음 예제에서 볼 수 있듯이 AccountHolder 는 자신을 인스턴스 변수 jointAccountHolder 로 인스턴스화합니다 .
public class AccountHolder {
private String firstName;
private String lastName;
AccountHolder jointAccountHolder = new AccountHolder();
}
AccountHolder 클래스가 인스턴스화 되면 이 테스트에서 볼 수 있듯이 생성자의 재귀 호출로 인해 StackOverflowError가 발생 합니다 .
public class AccountHolderManualTest {
@Test(expected = StackOverflowError.class)
public void whenInstanciatingAccountHolder_thenThrowsException() {
AccountHolder holder = new AccountHolder();
}
}
4. StackOverflowError 처리
StackOverflowError 가 발생 했을 때 가장 좋은 방법 은 스택 추적을 주의 깊게 검사하여 반복되는 줄 번호 패턴을 식별하는 것입니다. 이렇게 하면 문제가 있는 재귀가 있는 코드를 찾을 수 있습니다.
앞에서 본 코드 예제로 인해 발생한 몇 가지 스택 추적을 살펴보겠습니다.
이 스택 추적은 예상되는 예외 선언 을 생략하는 경우 InfiniteRecursionWithTerminationConditionManualTest 에 의해 생성됩니다.
java.lang.StackOverflowError
at c.b.s.InfiniteRecursionWithTerminationCondition
.calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)
at c.b.s.InfiniteRecursionWithTerminationCondition
.calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)
at c.b.s.InfiniteRecursionWithTerminationCondition
.calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)
at c.b.s.InfiniteRecursionWithTerminationCondition
.calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)
여기에서 라인 번호 5가 반복되는 것을 볼 수 있습니다. 재귀 호출이 수행되는 곳입니다. 이제 재귀가 올바른 방식으로 수행되는지 확인하기 위해 코드를 검사하는 문제입니다.
다음은 CyclicDependancyManualTest 를 실행하여 얻은 스택 추적입니다 (역시 예상되는 예외 없이).
java.lang.StackOverflowError
at c.b.s.ClassTwo.<init>(ClassTwo.java:9)
at c.b.s.ClassOne.<init>(ClassOne.java:9)
at c.b.s.ClassTwo.<init>(ClassTwo.java:9)
at c.b.s.ClassOne.<init>(ClassOne.java:9)
이 스택 추적은 순환 관계에 있는 두 클래스에서 문제를 일으키는 줄 번호를 보여줍니다. ClassTwo 의 라인 번호 9 와 ClassOne 의 라인 번호 9 는 다른 클래스를 인스턴스화하려는 생성자 내부의 위치를 가리킵니다.
코드를 철저히 검사하고 다음 중 어느 것도(또는 다른 코드 논리 오류) 오류의 원인이 아닌 경우:
- 잘못 구현된 재귀(예: 종료 조건 없음)
- 클래스 간의 순환 의존성
- 해당 클래스의 인스턴스 변수와 동일한 클래스 내에서 클래스 인스턴스화
스택 크기를 늘리는 것이 좋습니다. 설치된 JVM에 따라 기본 스택 크기가 다를 수 있습니다.
-Xss 플래그를 사용하여 프로젝트 구성 또는 명령줄에서 스택 크기를 늘릴 수 있습니다 .
5. 결론
이 기사에서는 Java 코드가 원인이 되는 방법과 이를 진단하고 수정하는 방법을 포함 하여 StackOverflowError 에 대해 자세히 살펴보았습니다 .
이 기사와 관련된 소스 코드 는 GitHub 에서 찾을 수 있습니다 .