Stack Overflow — грешка при препълване на стека на повикванията (java.lang.StackOverflowError), която възниква при превишаване на максималната дълбочина на стека на нишката. Според Java Virtual Machine Specification, типичната дълбочина на стека в JVM е 1024 рамки за 64-битови системи. Основната причина е безкрайна рекурсия без базово условие за спиране.
Основни точки
StackOverflowError — е фатална грешка на Java Virtual Machine (JVM) или Android Runtime (ART), която възниква, когато стекът на повикванията на нишката достигне максимално допустимата дълбочина. За разлика от OutOfMemoryError (липса на Heap), StackOverflowError е свързан с друга област на паметта — стека, където се съхраняват рамките на повикванията на методи и локалните променливи.
Всяко повикване на метод създава рамка в стека: адрес за връщане, параметри и локални променливи. При връщане от метода рамката се унищожава. Ако методът извиква себе си (рекурсия) без базово условие, рамките се натрупват до запълване на стека. JVM не може да отдели нова рамка и хвърля StackOverflowError със съобщение „null” (в Java) или с посочване на безкрайно повтарящ се низ на стека.
Размерът на стека на нишката е фиксиран при създаването и не се променя по време на изпълнение. В Android типичният размер на стека на основната нишка е 32–48 KB, което дава дълбочина от приблизително 512–1024 рамки за методи без голям брой локални променливи. За фоновите нишки подразбиращият се размер е по-малък — 16–24 KB.
Стекът на повикванията (Call Stack) — е структура от данни LIFO (Last In, First Out), която управлява реда на изпълнение на методите. Всеки път, когато програмата извиква метод, JVM създава рамка в стека и я поставя отгоре. При завършване на метода рамката се премахва.
Всяка рамка съдържа: operand stack (стек от операнди за инструкциите на байткода), array of local variables (включително this), reference to constant pool и адрес за връщане. Колкото повече локални променливи има методът, толкова по-голям е размерът на неговата рамка и толкова по-малко методи могат да бъдат извикани преди запълване на стека. Метод с 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 проверяват цикли на етапа на изграждане. Ако цикълът е неизбежен, заменете директната зависимост с интерфейс с мързелива инициализация или фабрика Provider.
// Циклична зависимост — StackOverflowError
class A(private val b: B)
class B(private val a: A)
// Мързеливо решение
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 KB ще бъде заменен със стек от 128 KB, което ще даде 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 KB, за фоновата — 16–24 KB. Точният размер зависи от версията на Android и производителя на устройството. ART използва динамично разширяване на стека, но не повече от 2× от началната стойност.
В Kotlin — да, ако методът е маркиран с tailrec. Компилаторът преобразува опашната рекурсия в итерация, напълно елиминирайки растежа на стека. В Java опашната рекурсия не се оптимизира от JVM (за разлика от функционалните езици като Scala).
Размерът на стека на емулатора и реалното устройство може да се различава. Емулаторът използва Desktop JVM с типичен стек 512–1024 KB, а Android ART — 32–48 KB. Грешката ще се прояви в ART по-рано, отколкото в Desktop JVM.
Област на паметта: StackOverflowError — грешка на стека (рамки на повиквания), OutOfMemoryError — грешка на хийпа (обекти). StackOverflowError почти винаги е причинен от рекурсия, а OutOfMemoryError — от изтичане на памет или големи обекти.
Резюме
Ще разработим мобилно приложение под ключ
IT Sectr създава iOS и Android приложения за стартъпи и бизнеси от 2017 г. Ще ви консултираме и ще предложим най-доброто решение.
Прочетете също