Stack Overflow — σφάλμα υπερχείλισης στοίβας κλήσεων (java.lang.StackOverflowError), που προκύπτει όταν ξεπεραστεί το μέγιστο βάθος της στοίβας νήματος. Σύμφωνα με Java Virtual Machine Specification, το τυπικό βάθος στοίβας στο JVM είναι 1024 καρέ για συστήματα 64-bit. Η κύρια αιτία — ατελείωτη αναδρομή χωρίς βασική συνθήκη τερματισμού.
Κύρια σημεία
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 (στοίβα τελεστέων για εντολές bytecode), 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. Αυτό εξαλείφει εντελώς τον κίνδυνο υπερχείλισης στοίβας, καθώς τα αντικείμενα στο heap δεν περιορίζονται από το όριο στοίβας. Το 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 mapper.
Η διάγνωση του 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 — το επιθυμητό μέγεθος σε byte.
// Δημιουργία νήματος με αυξημένη στοίβα
val thread = Thread(null, runnable, "big-stack-thread", 64 * 1024)
thread.start()
Σημαντικό: η αύξηση της στοίβας δεν λύνει το πρόβλημα, απλώς το αναβάλλει. Με αναδρομή 10.000 επιπέδων, η στοίβα 64 KB θα αντικατασταθεί με στοίβα 128 KB, που θα δώσει 20.000 επίπεδα — αλλά το σφάλμα θα συμβεί και πάλι, απλώς αργότερα. Η μόνη σωστή λύση — η επαναληπτική αντικατάσταση της αναδρομής.
Οι επαναληπτικοί αλγόριθμοι δεν χρησιμοποιούν τη στοίβα κλήσεων για αποθήκευση ενδιάμεσων καταστάσεων — τις αποθηκεύουν στο heap (Stack<T> ή ArrayDeque). Διέλευση δυαδικού δέντρου, υπολογισμός παραγοντικού, Fibonacci — οποιαδήποτε αναδρομή μπορεί να μετατραπεί σε επανάληψη μέσω ρητής στοίβας.
// Επαναληπτική διέλευση δέντρου — χωρίς κίνδυνο 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 build. Αν το βάθος υπερβεί το όριο (π.χ. 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 (η μέθοδος καλεί άμεσα τον εαυτό της), δεν λειτουργεί για αμοιβαία αναδρομή και δεν υποστηρίζεται σε εκδόσεις Kotlin συμβατές με Android πριν από την 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 — σφάλμα heap (αντικείμενα). Το StackOverflowError σχεδόν πάντα προκαλείται από αναδρομή, ενώ το OutOfMemoryError — από διαρροές μνήμης ή μεγάλα αντικείμενα.
Σύνοψη
Θα αναπτύξουμε μια εφαρμογή για κινητά έτοιμη για χρήση
Η IT Sectr δημιουργεί εφαρμογές iOS και Android για νεοφυείς επιχειρήσεις και επιχειρήσεις από το 2017. Θα σας συμβουλεύσουμε και θα προτείνουμε την καλύτερη λύση.
Διαβάστε επίσης