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 створює фрейм у стеку і поміщає його зверху. При завершенні методу фрейм вилучається.
Кожен фрейм містить: стек операндів (для байткод-інструкцій), масив локальних змінних (включно з this), посилання на константний пул та адресу повернення. Чим більше локальних змінних має метод, тим більший розмір його фрейму і тим менше методів можна викликати до заповнення стеку. Метод з 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 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також