Перейти к основному содержимому

StackOverflowError в Java

· 6 мин. чтения

1. Обзор

StackOverflowError может раздражать разработчиков Java, так как это одна из самых распространенных ошибок времени выполнения, с которыми мы можем столкнуться.

В этой статье мы увидим, как эта ошибка может возникнуть, рассмотрев различные примеры кода, а также как мы можем с ней справиться.

2. Кадры стека и как возникает StackOverflowError

Начнем с основ. При вызове метода в стеке вызовов создается новый кадр стека . Этот кадр стека содержит параметры вызываемого метода, его локальные переменные и адрес возврата метода, т. е. точку, с которой выполнение метода должно продолжаться после возврата вызванного метода.

Создание кадров стека будет продолжаться до тех пор, пока не будет достигнут конец вызовов методов внутри вложенных методов.

Во время этого процесса, если JVM сталкивается с ситуацией, когда нет места для создания нового кадра стека, он выдает StackOverflowError .

Наиболее распространенной причиной, по которой JVM сталкивается с этой ситуацией, является непрерывная/бесконечная рекурсия — в описании Javadoc для StackOverflowError упоминается, что ошибка возникает в результате слишком глубокой рекурсии в конкретном фрагменте кода.

Однако рекурсия — не единственная причина этой ошибки. Это также может произойти в ситуации, когда приложение продолжает вызывать методы из методов до тех пор, пока стек не будет исчерпан . Это редкий случай, поскольку ни один разработчик не стал бы намеренно следовать плохим методам написания кода. Другой редкой причиной является наличие большого количества локальных переменных внутри метода .

Ошибка 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);
}
}

Однако в следующем примере указано условие завершения, но оно никогда не выполняется, если методу calculateFactorial() передается значение -1 , что вызывает незавершенную/бесконечную рекурсию: ``

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();
}
}

Это заканчивается StackOverflowError , поскольку конструктор ClassOne создает экземпляр ClassTwo, а конструктор ClassTwo снова создает экземпляр ClassOne. И это повторяется неоднократно, пока не переполнится стек.

Далее мы рассмотрим, что происходит, когда экземпляр класса создается в том же классе, что и переменная экземпляра этого класса.

Как видно из следующего примера, AccountHolder инстанцирует себя как переменную экземпляраjoinAccountHolder :

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)

Эта трассировка стека показывает номера строк, которые вызывают проблему в двух классах, находящихся в циклической связи. Строка номер 9 ClassTwo и строка номер 9 ClassOne указывают на место внутри конструктора, где он пытается создать экземпляр другого класса.

После тщательной проверки кода и если ни одно из следующих (или любая другая логическая ошибка кода) не является причиной ошибки:

  • Неправильно реализованная рекурсия (т.е. без условия завершения)
  • Циклическая зависимость между классами
  • Создание экземпляра класса в том же классе, что и переменная экземпляра этого класса

Было бы неплохо попробовать увеличить размер стека. В зависимости от установленной JVM размер стека по умолчанию может различаться.

Флаг -Xss можно использовать для увеличения размера стека либо из конфигурации проекта, либо из командной строки.

5. Вывод

В этой статье мы более подробно рассмотрели StackOverflowError , в том числе то, как код Java может вызвать ее, и как мы можем ее диагностировать и исправить.

Исходный код, относящийся к этой статье, можно найти на GitHub .