Stack Overflow — błąd przepełnienia stosu wywołań (java.lang.StackOverflowError), występujący przy przekroczeniu maksymalnej głębokości stosu wątku. Według Java Virtual Machine Specification, typowa głębokość stosu w JVM wynosi 1024 ramki dla systemów 64-bitowych. Główną przyczyną jest nieskończona rekurencja bez warunku bazowego.
Najważniejsze
StackOverflowError — to fatalny błąd maszyny wirtualnej Java (JVM) lub Android Runtime (ART), który występuje, gdy stos wywołań wątku osiąga maksymalną dopuszczalną głębokość. W przeciwieństwie do OutOfMemoryError (brak pamięci Heap), StackOverflowError jest związany z innym obszarem pamięci — stosem, gdzie przechowywane są ramki wywołań metod i zmienne lokalne.
Każde wywołanie metody tworzy ramkę na stosie: adres powrotu, parametry i zmienne lokalne. Po powrocie z metody ramka jest niszczona. Jeśli metoda wywołuje samą siebie (rekurencja) bez warunku bazowego, ramki kumulują się aż do wypełnienia stosu. JVM nie może przydzielić nowej ramki i zgłasza StackOverflowError z komunikatem «null» (w Javie) lub ze wskazaniem nieskończenie powtarzającego się łańcucha stosu.
Rozmiar stosu wątku jest ustalany przy tworzeniu i nie zmienia się podczas wykonywania. W Androidzie typowy rozmiar stosu głównego wątku to 32–48 KB, co daje głębokość około 512–1024 ramek dla metod bez dużej liczby zmiennych lokalnych. Dla wątków tła domyślny rozmiar jest mniejszy — 16–24 KB.
Stos wywołań (Call Stack) — to struktura danych LIFO (Last In, First Out), która zarządza kolejnością wykonywania metod. Za każdym razem, gdy program wywołuje metodę, JVM tworzy ramkę na stosie i umieszcza ją na górze. Po zakończeniu metody ramka jest usuwana.
Każda ramka zawiera: operand stack (stek operandów dla instrukcji bajtkodu), array of local variables (w tym this), reference to constant pool i adres powrotu. Im więcej zmiennych lokalnych ma metoda, tym większy jest rozmiar jej ramki i tym mniej metod można wywołać przed wypełnieniem stosu. Metoda z 10 parametrami i 20 zmiennymi lokalnymi zajmuje około 3 razy więcej miejsca niż metoda bez parametrów.
W Androidzie ART używa własnej implementacji stosu, różnej od Desktop JVM. ART może dynamicznie zwiększać stos w pewnych granicach, ale dla każdego wątku nadal istnieje sztywny limit. Główny wątek (UI-thread) ma największy stos, ponieważ na nim wykonywany jest cały cykl życia Activity i obsługa zdarzeń.
// Rekurencja, która prowadzi do StackOverflowError
fun recursiveCall(depth: Int): Int {
return recursiveCall(depth + 1) // brak warunku bazowego
}
// Wywołanie doprowadzi do StackOverflowError na głębokości ~1000
recursiveCall(0)
Pięć typowych scenariuszy prowadzi do StackOverflowError w aplikacjach mobilnych. Większość z nich jest związana z rekurencją, ale istnieją też mniej oczywiste przyczyny.
Najczęstsza przyczyna. Programista pisze rekurencyjną metodę bez warunku zatrzymania lub z warunkiem, który nigdy nie staje się true. Każde wywołanie dodaje ramkę, a stos wypełnia się po 500–2000 iteracjach w zależności od rozmiaru ramki. Typowy przykład: obliczanie silni n! bez sprawdzania n == 0.
Sprawdź warunek bazowy na początku każdej rekurencyjnej metody. W Kotlin używaj require() lub check() do walidacji parametrów na starcie. Dla głębokiej rekurencji (ponad 100 poziomów) rozważ zamianę na podejście iteracyjne.
Klasa A tworzy instancję B, klasa B tworzy instancję A — to jest zależność cykliczna w konstruktorach. Przy próbie utworzenia A wywoływany jest konstruktor B, który wywołuje konstruktor A i tak aż do StackOverflowError. Frameworki DI (Dagger, Hilt) wykrywają takie cykle na etapie kompilacji, ale ręczne tworzenie obiektów ich nie wychwytuje.
Używaj Dependency Injection z grafami zależności: Dagger lub Koin sprawdzają cykle na etapie budowania. Jeśli cykl jest nieunikniony, zastąp bezpośrednią zależność interfejsem z leniwą inicjalizacją lub fabryką Provider.
// Zależność cykliczna — StackOverflowError
class A(private val b: B)
class B(private val a: A)
// Rozwiązanie leniwe
class A(private val bProvider: Provider<B>)
Przechodzenie drzewa View (ViewGroup.getChildAt()), systemu plików lub struktury JSON przez rekurencję może przekroczyć limit stosu przy głębokości ponad 500–1000 elementów. Android ViewGroup z zagnieżdżeniem 20 poziomów zdarza się rzadko, ale rekurencyjne parsowanie JSON z 2000 zagnieżdżonych obiektów to realny scenariusz.
Zastąp przechodzenie rekurencyjne iteracyjnym przez jawny Stack<T> lub ArrayDeque. To całkowicie eliminuje ryzyko przepełnienia stosu, ponieważ obiekty w stercie nie są ograniczone limitem stosu. BFS (Breadth-First Search) przez Queue również rozwiązuje problem.
Android-specyficzna przyczyna: cykliczne wywoływanie metod cyklu życia przy nieprawidłowym przetwarzaniu konfiguracji. Na przykład w onConfigurationChanged wywoływane jest recreate(), które ponownie wywołuje onConfigurationChanged i tak aż do StackOverflowError. Podobnie: setContentView() wewnątrz onLayout(), wywołujący ponowny pomiar i layout.
Nie wywołuj recreate() wewnątrz metod związanych ze zmianą konfiguracji. Do aktualizacji UI przy zmianie motywu używaj setTheme() bez recreate. Do dynamicznej zmiany orientacji — requestOrientation() jednokrotnie, bez flagi w konfiguracji.
Gson, Moshi lub Kotlin Serialization przy próbie serializacji obiektu z cyklicznymi referencjami (A odwołuje się do B, B odwołuje się do A) wpadają w nieskończoną rekurencję i padają z StackOverflowError. To częsty problem przy serializacji Entity z bidirectional Relationship (JPA, Room z ForeignKey).
Używaj @Transient, @JsonIgnore lub @kotlinx.serialization.Transient dla jednej ze stron cyklu. Dla Gson — JsonSerializer z jawnym ograniczeniem głębokości. Dla Room — nigdy nie serializuj Entity bezpośrednio, używaj mapperów DTO.
Diagnostyka StackOverflowError jest prostsza niż innych błędów pamięci: stack trace w większości przypadków pokazuje powtarzającą się sekwencję wywołań. To od razu wskazuje na rekurencję.
Stack trace StackOverflowError jest unikalny: po pierwszych 200–500 wierszach zaczyna się powtarzanie tego samego wzorca wywołań. JVM obcina powtarzające się wiersze na końcu i pokazuje «... 1234 more». Liczba niepowtarzających się wierszy przed «...» pokazuje głębokość rekurencji, która doprowadziła do błędu.
Przeczytaj pierwsze wiersze stack trace — pokazują one, od której metody zaczęło się powtarzanie. Znajdź metodę, która wywołuje samą siebie lub tworzy łańcuch wywołań wracający do niej. Popraw warunek bazowy lub zastąp rekurencję pętlą.
Tymczasowo problem można rozwiązać przez zwiększenie rozmiaru stosu za pomocą flagi JVM -Xss. W Androidzie rozmiar stosu ustawia się przez AndroidManifest: android:largeHeap nie wpływa na stos. Aby zwiększyć stos wątku w kodzie: Thread(ThreadGroup, Runnable, name, stackSize). stackSize — żądany rozmiar w bajtach.
// Tworzenie wątku z powiększonym stosem
val thread = Thread(null, runnable, "big-stack-thread", 64 * 1024)
thread.start()
Ważne: zwiększenie stosu nie rozwiązuje problemu, a jedynie go opóźnia. Przy rekurencji 10 000 poziomów stos 64 KB zostanie zastąpiony stosem 128 KB, co da 20 000 poziomów — ale błąd i tak wystąpi, tylko później. Jedynym prawidłowym rozwiązaniem jest iteracyjna zamiana rekurencji.
Algorytmy iteracyjne nie używają stosu wywołań do przechowywania stanów pośrednich — przechowują je w stercie (Stack<T> lub ArrayDeque). Przechodzenie drzewa binarnego, obliczanie silni, Fibonacciego — dowolną rekurencję można przekształcić w iterację przez jawny stos.
// Iteracyjne przejście drzewa — bez ryzyka 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) }
}
}
Profilaktyka StackOverflowError to zbiór zasad i narzędzi, które wykrywają potencjalne cykle rekurencyjne, zanim trafią do produkcji.
Dodaj ochronny licznik głębokości w rekurencyjnych metodach w wersji debug. Jeśli głębokość przekracza próg (np. 1000), zgłaszaj wyjątek z zrozumiałym komunikatem. To zamienia StackOverflowError z nieczytelnym trace w zrozumiały wyjątek biznesowy.
fun safeRecursive(n: Int, depth: Int = 0): Int {
if (depth > 1000) {
throw IllegalStateException("Rekurencja przekroczyła 1000 poziomów")
}
return if (n <= 1) n
else safeRecursive(n - 1, depth + 1)
}
Detekt (Kotlin) i Infer (Facebook) znajdują potencjalnie nieskończone rekurencje na poziomie analizy statycznej. Detekt ma regułę PotentiallyInfiniteRecursion, która ostrzega o self-call bez zmiany parametrów. Włącz ją do zestawu reguł CI i ustaw severity na error.
Podczas code review zwracaj uwagę na: wszelkie metody self-call, rekurencyjne wywołania wewnątrz lambd (inline-funkcje Kotlin), cykliczne wywołania między różnymi klasami, rekurencję w property delegates. Dla każdej rekurencyjnej metody sprawdzaj: czy istnieje warunek bazowy, czy parametr zmienia się na każdym kroku, czy zmiana parametru gwarantuje osiągnięcie warunku bazowego.
Kotlin obsługuje modyfikator tailrec: jeśli rekurencyjna metoda jest oznaczona tailrec i wywołanie jest ogonowe (ostatnia operacja), kompilator przekształca ją w iterację. Jednak tailrec działa tylko dla self-call (metoda wywołuje samą siebie bezpośrednio), nie działa dla wzajemnej rekurencji i nie jest obsługiwany w wersjach Kotlin zgodnych z Androidem przed 1.5.
tailrec fun factorial(n: Int, acc: Int = 1): Int {
return if (n <= 1) acc
else factorial(n - 1, acc * n) // wywołanie ogonowe
}
Często zadawane pytania
Można, ale tylko na poziomie Javy. Error, podobnie jak Exception, jest Throwable. Jednak po StackOverflowError stos jest uszkodzony — ramki, które się nie zmieściły, nie mogą poprawnie się zakończyć. Próba utworzenia nowego obiektu w bloku catch może wywołać kolejny StackOverflowError.
Dla głównego wątku — 32–48 KB, dla tła — 16–24 KB. Dokładny rozmiar zależy od wersji Androida i producenta urządzenia. ART używa dynamicznego rozszerzania stosu, ale nie więcej niż 2× wartości początkowej.
W Kotlin — tak, jeśli metoda jest oznaczona tailrec. Kompilator przekształca rekurencję ogonową w iterację, całkowicie eliminując wzrost stosu. W Javie rekurencja ogonowa nie jest optymalizowana przez JVM (w przeciwieństwie do języków funkcyjnych takich jak Scala).
Rozmiar stosu na emulatorze i rzeczywistym urządzeniu może się różnić. Emulator używa Desktop JVM z typowym stosem 512–1024 KB, a Android ART — 32–48 KB. Błąd pojawi się w ART wcześniej niż w Desktop JVM.
Obszar pamięci: StackOverflowError — błąd stosu (ramki wywołań), OutOfMemoryError — błąd sterty (obiekty). StackOverflowError jest prawie zawsze spowodowany rekurencją, a OutOfMemoryError — wyciekami pamięci lub dużymi obiektami.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również