Stack Overflow — ошибка переполнения стека вызовов (java.lang.StackOverflowError), возникающая при превышении максимальной глубины стека потока. По данным Java Virtual Machine Specification, типичная глубина стека в JVM составляет 1024 фрейма для 64-битных систем. Основная причина — бесконечная рекурсия без базового условия остановки.
Главное
StackOverflowError — это фатальная ошибка виртуальной машины Java (JVM) или Android Runtime (ART), которая возникает, когда стек вызовов потока достигает максимально допустимой глубины. В отличие от OutOfMemoryError (нехватка Heap), StackOverflowError связан с другой областью памяти — стеком, где хранятся фреймы вызовов методов и локальные переменные.
Каждый вызов метода создаёт фрейм в стеке: адрес возврата, параметры и локальные переменные. При возврате из метода фрейм разрушается. Если метод вызывает сам себя (рекурсия) без базового условия, фреймы накапливаются до заполнения стека. JVM не может выделить новый фрейм и выбрасывает StackOverflowError с сообщением «null» (в Java) или с указанием бесконечно повторяющейся строки стека.
Размер стека потока фиксирован при создании и не меняется во время выполнения. В Android типичный размер стека главного потока — 32–48 КБ, что даёт глубину примерно 512–1024 фрейма для методов без большого количества локальных переменных. Для фоновых потоков размер по умолчанию меньше — 16–24 КБ.
Стек вызовов (Call Stack) — это структура данных LIFO (Last In, First Out), которая управляет порядком выполнения методов. Каждый раз, когда программа вызывает метод, JVM создаёт фрейм в стеке и помещает его сверху. При завершении метода фрейм извлекается.
Каждый фрейм содержит: operand stack (стек операндов для байткод-инструкций), array of local variables (включая this), reference to constant pool и return address. Чем больше локальных переменных имеет метод, тем больше размер его фрейма и тем меньше методов можно вызвать до заполнения стека. Метод с 10 параметрами и 20 локальными переменными занимает примерно в 3 раза больше места, чем метод без параметров.
На Android ART использует собственную реализацию стека, отличную от Desktop JVM. ART может увеличивать стек динамически в некоторых пределах, но для каждого потока всё равно существует жёсткий лимит. Главный поток (UI-поток) имеет наибольший стек, так как на нём выполняется весь жизненный цикл Activity и обработка событий.
// Рекурсия, которая приводит к StackOverflowError
fun recursiveCall(depth: Int): Int {
return recursiveCall(depth + 1) // нет базового условия
}
// Вызов приведёт к StackOverflowError на глубине ~1000
recursiveCall(0)
Пять типичных сценариев приводят к StackOverflowError в мобильных приложениях. Большинство из них связаны с рекурсией, но есть и менее очевидные причины.
Самая распространённая причина. Разработчик пишет рекурсивный метод без условия остановки или с условием, которое никогда не становится true. Каждый вызов добавляет фрейм, и стек заполняется за 500–2000 итераций в зависимости от размера фрейма. Типичный пример: вычисление факториала n! без проверки n == 0.
Проверьте базовое условие в начале каждого рекурсивного метода. В Kotlin используйте require() или check() для валидации параметров на старте. Для глубокой рекурсии (более 100 уровней) рассмотрите замену на итеративный подход.
Класс A создаёт экземпляр B, класс B создаёт экземпляр A — это циклическая зависимость в конструкторах. При попытке создать A вызывается конструктор B, который вызывает конструктор A, и так до StackOverflowError. DI-фреймворки (Dagger, Hilt) выявляют такие циклы на этапе компиляции, но ручное создание объектов их не ловит.
Используйте Dependency Injection с графами зависимостей: Dagger или Koin проверяют циклы на этапе сборки. Если цикл неизбежен, замените прямую зависимость на интерфейс с lazy-инициализацией или Provider-фабрику.
// Циклическая зависимость — StackOverflowError
class A(private val b: B)
class B(private val a: A)
// Lazy-решение
class A(private val bProvider: Provider<B>)
Обход дерева View (ViewGroup.getChildAt()), файловой системы или JSON-структуры через рекурсию может превысить лимит стека при глубине более 500–1000 элементов. Android ViewGroup с вложенностью 20 уровней встречается редко, но рекурсивный парсинг JSON с 2000 вложенных объектов — реальный сценарий.
Замените рекурсивный обход на итеративный через явный Stack<T> или ArrayDeque. Это полностью устраняет риск переполнения стека, так как объекты в куче не ограничены лимитом стека. BFS (Breadth-First Search) через Queue также решает проблему.
Android-специфичная причина: циклический вызов методов жизненного цикла при неправильной обработке конфигурации. Например, в onConfigurationChanged вызывается recreate(), который снова вызывает onConfigurationChanged, и так до StackOverflowError. Аналогично: setContentView() внутри onLayout(), вызывающий повторную меру и layout.
Не вызывайте recreate() внутри методов, связанных с изменением конфигурации. Для обновления UI при изменении темы используйте setTheme() без recreate. Для динамической смены ориентации — requestOrientation() однократно, без флага в конфигурации.
Gson, Moshi или Kotlin Serialization при попытке сериализовать объект с циклическими ссылками (A ссылается на B, B ссылается на A) уходят в бесконечную рекурсию и падают с StackOverflowError. Это частая проблема при сериализации Entity с bidirectional Relationship (JPA, Room с ForeignKey).
Используйте @Transient, @JsonIgnore или @kotlinx.serialization.Transient для одной из сторон цикла. Для Gson — JsonSerializer с явным ограничением глубины. Для Room — никогда не сериализуйте Entity напрямую, используйте DTO-мапперы.
Диагностика StackOverflowError проще, чем других ошибок памяти: stack trace в большинстве случаев показывает повторяющуюся последовательность вызовов. Это сразу указывает на рекурсию.
Stack trace StackOverflowError уникален: после первых 200–500 строк начинается повторение одного и того же паттерна вызовов. JVM обрезает повторяющиеся строки в конце и показывает «... 1234 more». Количество неповторяющихся строк перед «...» показывает глубину рекурсии, которая привела к ошибке.
Прочитайте первые строки stack trace — они показывают, с какого метода началось повторение. Найдите метод, который вызывает сам себя или создаёт цепочку вызовов, возвращающуюся к нему. Исправьте базовое условие или замените рекурсию на цикл.
Временно проблему можно решить увеличением размера стека через флаг JVM -Xss. Для Android размер стека задаётся через AndroidManifest: android:largeHeap не влияет на стек. Для увеличения стека потока в коде: Thread(ThreadGroup, Runnable, name, stackSize). stackSize — желаемый размер в байтах.
// Создание потока с увеличенным стеком
val thread = Thread(null, runnable, "big-stack-thread", 64 * 1024)
thread.start()
Важно: увеличение стека не решает проблему, а лишь откладывает её. При рекурсии 10 000 уровней стек в 64 КБ заменится стеком в 128 КБ, что даст 20 000 уровней — но ошибка всё равно произойдёт, только позже. Единственное правильное решение — итеративная замена рекурсии.
Итеративные алгоритмы не используют стек вызовов для хранения промежуточных состояний — они хранят их в куче (Stack<T> или ArrayDeque). Обход бинарного дерева, вычисление факториала, Фибоначчи — любую рекурсию можно преобразовать в итерацию через явный стек.
// Итеративный обход дерева — без риска StackOverflow
fun traverseIterative(root: Node?) {
val stack = ArrayDeque<Node>()
stack.push(root)
while (stack.isNotEmpty()) {
val node = stack.pop() ?: continue
process(node)
node.right?.let { stack.push(it) }
node.left?.let { stack.push(it) }
}
}
Профилактика StackOverflowError — это набор правил и инструментов, которые выявляют потенциальные рекурсивные циклы до того, как они попадут в продакшен.
Добавьте защитный счётчик глубины в рекурсивные методы в debug-сборке. Если глубина превышает порог (например, 1000), выбрасывайте исключение с понятным сообщением. Это превращает StackOverflowError с нечитаемым trace в понятное бизнес-исключение.
fun safeRecursive(n: Int, depth: Int = 0): Int {
if (depth > 1000) {
throw IllegalStateException("Рекурсия превысила 1000 уровней")
}
return if (n <= 1) n
else safeRecursive(n - 1, depth + 1)
}
Detekt (Kotlin) и Infer (Facebook) находят потенциальные бесконечные рекурсии на уровне статического анализа. Detekt имеет правило PotentiallyInfiniteRecursion, которое предупреждает о self-call без изменения параметров. Включите его в набор правил CI и настройте severity на error.
На code review обращайте внимание на: любые self-call методы, рекурсивные вызовы внутри лямбд (inline-функции Kotlin), циклические вызовы между разными классами, рекурсию в property delegates. Для каждого рекурсивного метода проверяйте: есть ли базовое условие, меняется ли параметр на каждом шаге, гарантирует ли изменение параметра достижение базового условия.
Kotlin поддерживает tailrec-модификатор: если рекурсивный метод помечен tailrec и вызов является хвостовым (последняя операция), компилятор преобразует его в итерацию. Однако tailrec работает только для self-call (метод вызывает сам себя напрямую), не работает для взаимной рекурсии и не поддерживается в Android-совместимых версиях Kotlin до 1.5.
tailrec fun factorial(n: Int, acc: Int = 1): Int {
return if (n <= 1) acc
else factorial(n - 1, acc * n) // хвостовой вызов
}
Часто задаваемые вопросы
Можно, но только на Java-уровне. Error, как и Exception, является Throwable. Однако после StackOverflowError стек повреждён — фреймы, которые не поместились, не могут корректно завершиться. Попытка создать новый объект в catch-блоке может вызвать повторный StackOverflowError.
Для главного потока — 32–48 КБ, для фонового — 16–24 КБ. Точный размер зависит от версии Android и производителя устройства. ART использует динамическое расширение стека, но не более 2× от начального значения.
В Kotlin — да, если метод помечен tailrec. Компилятор преобразует хвостовую рекурсию в итерацию, полностью исключая рост стека. В Java хвостовая рекурсия не оптимизируется JVM (в отличие от функциональных языков вроде Scala).
Размер стека на эмуляторе и реальном устройстве может отличаться. Эмулятор использует Desktop JVM с типичным стеком 512–1024 КБ, а Android ART — 32–48 КБ. Ошибка проявится на ART раньше, чем на Desktop JVM.
Область памяти: StackOverflowError — ошибка стека (фреймы вызовов), OutOfMemoryError — ошибка кучи (объекты). StackOverflowError почти всегда вызван рекурсией, а OutOfMemoryError — утечками памяти или крупными объектами.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также