Stack Overflow là lỗi tràn ngăn xếp cuộc gọi (java.lang.StackOverflowError) xảy ra khi vượt quá độ sâu tối đa của ngăn xếp của một luồng. Theo Java Virtual Machine Specification, độ sâu ngăn xếp điển hình trong JVM là 1024 khung cho hệ thống 64-bit. Nguyên nhân chính là đệ quy vô hạn không có điều kiện cơ sở.
Những điểm chính
StackOverflowError là lỗi nghiêm trọng của Máy ảo Java (JVM) hoặc Android Runtime (ART) xảy ra khi ngăn xếp cuộc gọi của một luồng đạt đến độ sâu tối đa cho phép. Không giống như OutOfMemoryError (thiếu Heap), StackOverflowError liên quan đến một vùng bộ nhớ khác — ngăn xếp, nơi lưu trữ các khung cuộc gọi phương thức và biến cục bộ.
Mỗi lần gọi phương thức tạo một khung trong ngăn xếp: địa chỉ trả về, tham số và biến cục bộ. Khi trả về từ phương thức, khung bị hủy. Nếu một phương thức tự gọi chính nó (đệ quy) mà không có điều kiện cơ sở, các khung tích tụ cho đến khi ngăn xếp đầy. JVM không thể cấp phát khung mới và ném StackOverflowError với thông báo «null» (trong Java) hoặc với chỉ dẫn về một dòng ngăn xếp lặp lại vô tận.
Kích thước ngăn xếp của một luồng được cố định khi tạo và không thay đổi trong quá trình thực thi. Trong Android, kích thước ngăn xếp điển hình của luồng chính là 32–48 KB, cho độ sâu khoảng 512–1024 khung cho các phương thức không có nhiều biến cục bộ. Đối với luồng nền, kích thước mặc định nhỏ hơn — 16–24 KB.
Ngăn xếp cuộc gọi (Call Stack) là cấu trúc dữ liệu LIFO (Last In, First Out) quản lý thứ tự thực thi phương thức. Mỗi khi chương trình gọi một phương thức, JVM tạo một khung trong ngăn xếp và đặt nó lên trên cùng. Khi phương thức hoàn thành, khung được lấy ra.
Mỗi khung chứa: ngăn xếp toán hạng (cho lệnh bytecode), mảng biến cục bộ (bao gồm this), tham chiếu đến vùng hằng số và địa chỉ trả về. Phương thức càng có nhiều biến cục bộ thì kích thước khung càng lớn và càng ít phương thức có thể được gọi trước khi ngăn xếp đầy. Một phương thức có 10 tham số và 20 biến cục bộ chiếm khoảng gấp 3 lần không gian so với phương thức không có tham số.
Trên Android, ART sử dụng triển khai ngăn xếp riêng, khác với JVM máy tính để bàn. ART có thể tăng ngăn xếp động trong một số giới hạn nhất định, nhưng vẫn có giới hạn cứng cho mỗi luồng. Luồng chính (luồng UI) có ngăn xếp lớn nhất vì nó xử lý toàn bộ vòng đời Activity và xử lý sự kiện.
// Đệ quy dẫn đến StackOverflowError
fun recursiveCall(depth: Int): Int {
return recursiveCall(depth + 1) // không có điều kiện cơ sở
}
// Cuộc gọi sẽ gây StackOverflowError ở độ sâu ~1000
recursiveCall(0)
Năm kịch bản điển hình dẫn đến StackOverflowError trong ứng dụng di động. Hầu hết liên quan đến đệ quy, nhưng cũng có những nguyên nhân ít rõ ràng hơn.
Nguyên nhân phổ biến nhất. Nhà phát triển viết phương thức đệ quy không có điều kiện dừng hoặc với điều kiện không bao giờ thành true. Mỗi lần gọi thêm một khung và ngăn xếp đầy sau 500–2000 lần lặp tùy theo kích thước khung. Ví dụ điển hình: tính giai thừa n! mà không kiểm tra n == 0.
Kiểm tra điều kiện cơ sở ở đầu mỗi phương thức đệ quy. Trong Kotlin, sử dụng require() hoặc check() để xác thực tham số khi bắt đầu. Đối với đệ quy sâu (hơn 100 cấp), hãy xem xét thay thế bằng cách tiếp cận vòng lặp.
Lớp A tạo thể hiện của B, lớp B tạo thể hiện của A — đây là phụ thuộc vòng trong hàm tạo. Khi cố gắng tạo A, hàm tạo của B được gọi, hàm tạo này gọi hàm tạo của A, và cứ thế cho đến StackOverflowError. Các framework DI (Dagger, Hilt) phát hiện các vòng này tại thời điểm biên dịch, nhưng việc tạo đối tượng thủ công không phát hiện được.
Sử dụng Tiêm phụ thuộc với đồ thị phụ thuộc: Dagger hoặc Koin kiểm tra vòng tại thời điểm xây dựng. Nếu không thể tránh vòng, hãy thay thế phụ thuộc trực tiếp bằng giao diện với khởi tạo lazy hoặc nhà máy Provider.
// Phụ thuộc vòng — StackOverflowError
class A(private val b: B)
class B(private val a: A)
// Giải pháp lazy
class A(private val bProvider: Provider<B>)
Duyệt cây View (ViewGroup.getChildAt()), hệ thống tệp hoặc cấu trúc JSON qua đệ quy có thể vượt quá giới hạn ngăn xếp ở độ sâu hơn 500–1000 phần tử. ViewGroup Android với 20 cấp lồng nhau hiếm gặp, nhưng phân tích JSON đệ quy với 2000 đối tượng lồng nhau là kịch bản thực tế.
Thay thế duyệt đệ quy bằng duyệt vòng lặp sử dụng Stack<T> tường minh hoặc ArrayDeque. Điều này loại bỏ hoàn toàn rủi ro tràn ngăn xếp vì đối tượng trên heap không bị giới hạn bởi ngăn xếp. BFS (Tìm kiếm theo chiều rộng) qua Queue cũng giải quyết vấn đề.
Nguyên nhân đặc thù của Android: gọi vòng các phương thức vòng đời khi cấu hình bị xử lý sai. Ví dụ, gọi recreate() bên trong onConfigurationChanged, phương thức này lại gọi onConfigurationChanged, và cứ thế cho đến StackOverflowError. Tương tự: setContentView() bên trong onLayout(), kích hoạt đo lường và bố cục khác.
Không gọi recreate() bên trong các phương thức liên quan đến thay đổi cấu hình. Để cập nhật UI khi thay đổi chủ đề, sử dụng setTheme() mà không cần recreate. Đối với thay đổi hướng động — gọi requestOrientation() một lần, không có cờ trong cấu hình.
Gson, Moshi hoặc Kotlin Serialization khi cố gắng tuần tự hóa đối tượng có tham chiếu vòng (A tham chiếu B, B tham chiếu A) sẽ rơi vào đệ quy vô hạn và thất bại với StackOverflowError. Đây là vấn đề phổ biến khi tuần tự hóa Entity với quan hệ hai chiều (JPA, Room với ForeignKey).
Sử dụng @Transient, @JsonIgnore hoặc @kotlinx.serialization.Transient cho một phía của vòng. Đối với Gson — JsonSerializer với giới hạn độ sâu tường minh. Đối với Room — không bao giờ tuần tự hóa Entity trực tiếp, sử dụng ánh xạ DTO.
Chẩn đoán StackOverflowError dễ hơn các lỗi bộ nhớ khác: dấu vết ngăn xếp trong hầu hết trường hợp hiển thị chuỗi cuộc gọi lặp lại. Điều này ngay lập tức chỉ ra đệ quy.
Dấu vết ngăn xếp của StackOverflowError rất độc đáo: sau 200–500 dòng đầu tiên, cùng một mẫu cuộc gọi bắt đầu lặp lại. JVM cắt bớt các dòng lặp lại ở cuối và hiển thị «... 1234 more». Số dòng không lặp lại trước «...» cho biết độ sâu đệ quy đã gây ra lỗi.
Đọc những dòng đầu tiên của dấu vết ngăn xếp — chúng cho thấy phương thức nào bắt đầu sự lặp lại. Tìm phương thức tự gọi chính nó hoặc tạo chuỗi cuộc gọi quay lại nó. Sửa điều kiện cơ sở hoặc thay thế đệ quy bằng vòng lặp.
Tạm thời, vấn đề có thể được giải quyết bằng cách tăng kích thước ngăn xếp qua cờ JVM -Xss. Đối với Android, kích thước ngăn xếp được đặt qua AndroidManifest: android:largeHeap không ảnh hưởng đến ngăn xếp. Để tăng ngăn xếp luồng trong mã: Thread(ThreadGroup, Runnable, name, stackSize). stackSize là kích thước mong muốn tính bằng byte.
// Tạo luồng với ngăn xếp mở rộng
val thread = Thread(null, runnable, "big-stack-thread", 64 * 1024)
thread.start()
Quan trọng: tăng ngăn xếp không giải quyết vấn đề, chỉ trì hoãn nó. Với đệ quy 10.000 cấp, ngăn xếp 64 KB sẽ được thay thế bằng ngăn xếp 128 KB, cho 20.000 cấp — nhưng lỗi vẫn sẽ xảy ra, chỉ muộn hơn. Giải pháp đúng đắn duy nhất là thay thế đệ quy bằng vòng lặp.
Các thuật toán vòng lặp không sử dụng ngăn xếp cuộc gọi để lưu trạng thái trung gian — chúng lưu trong heap (Stack<T> hoặc ArrayDeque). Duyệt cây nhị phân, tính giai thừa, Fibonacci — bất kỳ đệ quy nào cũng có thể được chuyển đổi thành vòng lặp bằng ngăn xếp tường minh.
// Duyệt cây bằng vòng lặp — không rủi ro 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) }
}
}
Phòng tránh StackOverflowError là tập hợp các quy tắc và công cụ xác định các vòng đệ quy tiềm ẩn trước khi chúng đến môi trường sản xuất.
Thêm bộ đếm độ sâu bảo vệ trong các phương thức đệ quy trong bản gỡ lỗi. Nếu độ sâu vượt quá ngưỡng (ví dụ: 1000), hãy ném ngoại lệ với thông báo rõ ràng. Điều này biến StackOverflowError với dấu vết khó đọc thành ngoại lệ nghiệp vụ dễ hiểu.
fun safeRecursive(n: Int, depth: Int = 0): Int {
if (depth > 1000) {
throw IllegalStateException("Đệ quy vượt quá 1000 cấp")
}
return if (n <= 1) n
else safeRecursive(n - 1, depth + 1)
}
Detekt (Kotlin) và Infer (Facebook) tìm các đệ quy vô hạn tiềm ẩn ở cấp độ phân tích tĩnh. Detekt có quy tắc PotentiallyInfiniteRecursion cảnh báo về tự gọi mà không thay đổi tham số. Kích hoạt nó trong bộ quy tắc CI và đặt mức độ nghiêm trọng thành error.
Trong đánh giá mã, hãy chú ý đến: bất kỳ phương thức tự gọi nào, gọi đệ quy bên trong lambda (hàm inline Kotlin), gọi vòng giữa các lớp khác nhau, đệ quy trong ủy quyền thuộc tính. Đối với mỗi phương thức đệ quy, kiểm tra: có điều kiện cơ sở không, tham số có thay đổi ở mỗi bước không, sự thay đổi tham số có đảm bảo đạt được điều kiện cơ sở không.
Kotlin hỗ trợ bổ từ tailrec: nếu phương thức đệ quy được đánh dấu tailrec và cuộc gọi là đuôi (thao tác cuối cùng), trình biên dịch sẽ chuyển đổi nó thành vòng lặp. Tuy nhiên, tailrec chỉ hoạt động cho tự gọi (phương thức tự gọi trực tiếp), không hoạt động cho đệ quy tương hỗ và không được hỗ trợ trong các phiên bản Kotlin tương thích Android trước 1.5.
tailrec fun factorial(n: Int, acc: Int = 1): Int {
return if (n <= 1) acc
else factorial(n - 1, acc * n) // gọi đuôi
}
Câu hỏi thường gặp
Có, nhưng chỉ ở cấp độ Java. Error, giống như Exception, là Throwable. Tuy nhiên, sau StackOverflowError, ngăn xếp bị hỏng — các khung không vừa không thể hoàn thành chính xác. Cố gắng tạo đối tượng mới trong khối catch có thể gây ra StackOverflowError khác.
Cho luồng chính — 32–48 KB, cho luồng nền — 16–24 KB. Kích thước chính xác phụ thuộc vào phiên bản Android và nhà sản xuất thiết bị. ART sử dụng mở rộng ngăn xếp động nhưng không quá 2× giá trị ban đầu.
Trong Kotlin — có, nếu phương thức được đánh dấu tailrec. Trình biên dịch chuyển đổi đệ quy đuôi thành vòng lặp, loại bỏ hoàn toàn sự tăng trưởng ngăn xếp. Trong Java, đệ quy đuôi không được JVM tối ưu hóa (không giống như các ngôn ngữ hàm như Scala).
Kích thước ngăn xếp trên trình giả lập và thiết bị thực có thể khác nhau. Trình giả lập sử dụng JVM máy tính để bàn với ngăn xếp điển hình 512–1024 KB, trong khi Android ART sử dụng 32–48 KB. Lỗi sẽ xuất hiện trên ART sớm hơn trên JVM máy tính để bàn.
Vùng bộ nhớ: StackOverflowError là lỗi ngăn xếp (khung cuộc gọi), OutOfMemoryError là lỗi heap (đối tượng). StackOverflowError hầu như luôn do đệ quy gây ra, trong khi OutOfMemoryError do rò rỉ bộ nhớ hoặc đối tượng lớn gây ra.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm