モバイル開発におけるStack Overflow — 概要、原因、防止方法

著者: IT Sectr 公開日: 2026-03-29 読了時間: 9 分

Stack Overflowは、スレッドの最大スタック深度を超えたときに発生するコールスタックオーバーフローエラー(java.lang.StackOverflowError)です。Java Virtual Machine Specificationによると、64ビットシステムのJVMにおける標準的なスタック深度は1024フレームです。主な原因は、基本条件のない無限再帰です。

重要なポイント

  • StackOverflowError — コールスタック深度制限を超えた場合のJVMエラー
  • スタック深度は構成に応じて512~2048フレームに制限されています
  • 無限再帰はStackOverflowErrorの最も一般的な原因です
  • 末尾再帰は関数型言語とは異なり、JVMでは最適化されません
  • 反復による置き換えはオーバーフローを防ぐ信頼性の高い方法です

Stack Overflowとは

StackOverflowErrorは、Java仮想マシン(JVM)またはAndroid Runtime(ART)の致命的なエラーで、スレッドのコールスタックが最大許容深度に達したときに発生します。OutOfMemoryError(ヒープ不足)とは異なり、StackOverflowErrorはメモリの別の領域(スタック)に関連しており、メソッド呼び出しフレームとローカル変数が格納されます。

メソッドが呼び出されるたびに、スタックにフレーム(戻りアドレス、パラメータ、ローカル変数)が作成されます。メソッドから戻るとフレームは破棄されます。基本条件なしでメソッドが自分自身を呼び出す(再帰)と、スタックがいっぱいになるまでフレームが蓄積されます。JVMは新しいフレームを割り当てることができず、「null」メッセージ(Javaの場合)または無限に繰り返されるスタック行の表示とともにStackOverflowErrorをスローします。

スレッドのスタックサイズは作成時に固定され、実行中に変更されません。Androidでは、メインスレッドの標準的なスタックサイズは32~48KBで、ローカル変数が少ないメソッドの場合、約512~1024フレームの深度になります。バックグラウンドスレッドの場合、デフォルトサイズは16~24KBと小さくなります。

コールスタックの仕組み

コールスタック(Call Stack)は、メソッド実行の順序を管理するLIFO(Last In, First Out)データ構造です。プログラムがメソッドを呼び出すたびに、JVMはスタックにフレームを作成し、それを最上部に配置します。メソッドが完了すると、フレームはポップされます。

各フレームには、オペランドスタック(バイトコード命令用)、ローカル変数の配列(thisを含む)、定数プールへの参照、戻りアドレスが含まれます。メソッドのローカル変数が多いほど、フレームサイズが大きくなり、スタックがいっぱいになる前に呼び出せるメソッドの数が少なくなります。10個のパラメータと20個のローカル変数を持つメソッドは、パラメータのないメソッドの約3倍のスペースを占有します。

Androidでは、ARTはデスクトップJVMとは異なる独自のスタック実装を使用します。ARTは一定の制限内でスタックを動的に増やすことができますが、各スレッドには依然としてハードリミットが存在します。メインスレッド(UIスレッド)は、Activityのライフサイクル全体とイベント処理を処理するため、最大のスタックを持ちます。

kotlin
// StackOverflowErrorにつながる再帰
fun recursiveCall(depth: Int): Int {
    return recursiveCall(depth + 1) // 基本条件なし
}

// ~1000の深度でStackOverflowErrorが発生します
recursiveCall(0)

スタックオーバーフローの主な原因

5つの典型的なシナリオがモバイルアプリケーションでStackOverflowErrorを引き起こします。そのほとんどは再帰に関連していますが、あまり明白でない原因もあります。

基本条件のない無限再帰

最も一般的な原因です。開発者は、停止条件なし、または決してtrueにならない条件で再帰メソッドを作成します。各呼び出しによってフレームが追加され、フレームサイズに応じて500~2000回の反復でスタックがいっぱいになります。典型的な例:n == 0をチェックせずにn!の階乗を計算する。

各再帰メソッドの先頭で基本条件を確認してください。Kotlinでは、パラメータ検証にrequire()またはcheck()を使用します。深い再帰(100レベル以上)の場合は、反復アプローチへの置き換えを検討してください。

コンストラクタの循環依存

クラスAがBのインスタンスを作成し、クラスBがAのインスタンスを作成する — これはコンストラクタの循環依存です。Aを作成しようとすると、Bのコンストラクタが呼び出され、それがAのコンストラクタを呼び出し、StackOverflowErrorに至るまで繰り返されます。DIフレームワーク(Dagger、Hilt)はコンパイル時にこのような循環を検出しますが、手動でのオブジェクト作成では検出されません。

依存関係グラフを使用した依存性注入を使用してください。DaggerまたはKoinはビルド時に循環をチェックします。循環が避けられない場合は、直接の依存関係を遅延初期化またはProviderファクトリを持つインターフェースに置き換えてください。

kotlin
// 循環依存 — 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要素を超える深さでスタック制限を超える可能性があります。20レベルのネストを持つAndroid ViewGroupはまれですが、2000のネストされたオブジェクトを持つJSONの再帰的パースは現実的なシナリオです。

再帰的探索を、明示的なStack<T>またはArrayDequeを使用した反復的探索に置き換えてください。これにより、ヒープオブジェクトはスタック制限の影響を受けないため、スタックオーバーフローのリスクが完全に排除されます。Queueを使用したBFS(幅優先探索)も問題を解決します。

onConfigurationChangedの不適切な処理

Android固有の原因:構成が誤って処理された場合のライフサイクルメソッドの循環呼び出し。例えば、onConfigurationChanged内でrecreate()を呼び出すと、再度onConfigurationChangedが呼び出され、StackOverflowErrorに至るまで繰り返されます。同様に:onLayout()内のsetContentView()が、別の測定とレイアウトをトリガーします。

構成変更に関連するメソッド内でrecreate()を呼び出さないでください。テーマ変更時にUIを更新するには、recreateなしでsetTheme()を使用します。動的な向き変更には — 構成にフラグを付けずに、requestOrientation()を1回だけ呼び出します。

循環参照を含むシリアライゼーション

GsonMoshi、またはKotlin Serializationが循環参照(AがBを参照、BがAを参照)を含むオブジェクトをシリアライズしようとすると、無限再帰に入り、StackOverflowErrorでクラッシュします。これは双方向関係(JPA、ForeignKey付きRoom)を持つエンティティをシリアライズする際の一般的な問題です。

循環の一方に@Transient@JsonIgnore、または@kotlinx.serialization.Transientを使用してください。Gsonの場合は — 明示的な深度制限付きのJsonSerializer。Roomの場合は — Entityを直接シリアライズせず、DTOマッパーを使用してください。

StackOverflowErrorの診断と修正方法

StackOverflowErrorの診断は他のメモリエラーよりも簡単です:スタックトレースはほとんどの場合、繰り返しの呼び出しシーケンスを示します。これはすぐに再帰を示しています。

スタックトレースの読み方

StackOverflowErrorのスタックトレースは独特です:最初の200~500行の後、同じ呼び出しパターンが繰り返し始まります。JVMは最後に繰り返し行を切り詰め、「... 1234 more」と表示します。「...」の前の非繰り返し行の数が、エラーの原因となった再帰深度を示します。

スタックトレースの最初の行を読んでください — どのメソッドから繰り返しが始まったかを示しています。自分自身を呼び出しているメソッド、または自分に戻る呼び出しチェーンを作成しているメソッドを見つけてください。基本条件を修正するか、再帰をループに置き換えてください。

スタックサイズの増加(一時的な解決策)

一時的に、JVMフラグ-Xssを使用してスタックサイズを増やすことで問題を解決できます。Androidの場合、スタックサイズはAndroidManifestを介して設定されます:android:largeHeapはスタックに影響しません。コードでスレッドスタックを増やすには:Thread(ThreadGroup, Runnable, name, stackSize)。stackSizeはバイト単位の希望サイズです。

kotlin
// 拡張スタックでのスレッド作成
val thread = Thread(null, runnable, "big-stack-thread", 64 * 1024)
thread.start()

重要:スタックを増やしても問題は解決せず、遅延させるだけです。10,000レベルの再帰の場合、64KBのスタックは128KBのスタックに置き換えられ、20,000レベルになりますが、エラーは後で発生するだけで、依然として発生します。唯一の正しい解決策は、再帰の反復による置き換えです。

再帰の反復への置き換え

反復アルゴリズムは、中間状態を格納するためにコールスタックを使用しません — ヒープ(Stack<T>またはArrayDeque)に格納します。二分木探索、階乗計算、フィボナッチ — あらゆる再帰は、明示的なスタックを使用して反復に変換できます。

kotlin
// 反復的な木探索 — 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) }
    }
}

Stack Overflowの防止方法

StackOverflowErrorの予防は、プロダクションに到達する前に潜在的な再帰的循環を特定する一連のルールとツールです。

デバッグビルドでの再帰深度制限

デバッグビルドの再帰メソッドに保護用の深度カウンターを追加してください。深度がしきい値(例:1000)を超えた場合、明確なメッセージとともに例外をスローします。これにより、読み取り不可能なトレースのStackOverflowErrorが、理解しやすいビジネス例外に変わります。

kotlin
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ルールがあります。CIルールセットで有効にし、重大度をerrorに設定してください。

再帰に焦点を当てたコードレビュー

コードレビューでは、以下に注意してください:自己呼び出しメソッド、ラムダ内の再帰呼び出し(Kotlinインライン関数)、異なるクラス間の循環呼び出し、プロパティデリゲートの再帰。各再帰メソッドについて確認:基本条件があるか、パラメータが各ステップで変更されるか、パラメータの変更が基本条件への到達を保証するか。

末尾再帰変換(限定的)

Kotlinはtailrec修飾子をサポートしています:再帰メソッドにtailrecのマークがあり、呼び出しが末尾(最後の操作)の場合、コンパイラはそれを反復に変換します。ただし、tailrecは自己呼び出し(メソッドが直接自分自身を呼び出す)でのみ機能し、相互再帰では機能せず、Android互換のKotlinバージョン1.5より前ではサポートされていません。

kotlin
tailrec fun factorial(n: Int, acc: Int = 1): Int {
    return if (n <= 1) acc
           else factorial(n - 1, acc * n) // 末尾呼び出し
}

よくある質問

StackOverflowErrorはtry-catchでキャッチできますか?

可能ですが、Javaレベルに限ります。ErrorはExceptionと同様にThrowableです。ただし、StackOverflowError後はスタックが破損しているため、収まりきらなかったフレームは正しく完了できません。catchブロックで新しいオブジェクトを作成しようとすると、別のStackOverflowErrorが発生する可能性があります。

Androidのデフォルトのスタックサイズは?

メインスレッドの場合 — 32~48KB、バックグラウンドスレッドの場合 — 16~24KB。正確なサイズはAndroidのバージョンとデバイスメーカーによって異なります。ARTは動的スタック拡張を使用しますが、初期値の2倍を超えることはありません。

末尾再帰はStackOverflowErrorを防止できますか?

Kotlinでは — 可能です。メソッドがtailrecでマークされている場合、コンパイラは末尾再帰を反復に変換し、スタックの成長を完全に排除します。Javaでは、末尾再帰はJVMによって最適化されません(Scalaのような関数型言語とは異なります)。

エミュレーターではStackOverflowErrorが発生するのに、実機では発生しないのはなぜ?

スタックサイズはエミュレーターと実機で異なる場合があります。エミュレーターは標準的な512~1024KBのスタックを持つデスクトップJVMを使用しますが、Android ARTは32~48KBを使用します。エラーはデスクトップJVMよりもARTで先に現れます。

StackOverflowErrorとOutOfMemoryErrorの違いは?

メモリ領域:StackOverflowErrorはスタックエラー(呼び出しフレーム)、OutOfMemoryErrorはヒープエラー(オブジェクト)です。StackOverflowErrorはほとんどの場合再帰によって引き起こされますが、OutOfMemoryErrorはメモリリークや大きなオブジェクトによって引き起こされます。

まとめ

  • StackOverflowError — 再帰深度制限超過によるコールスタックオーバーフロー
  • Androidのスタック深度はメインスレッドで512~1024フレーム
  • 無限再帰が主な原因;各再帰メソッドで基本条件を確認
  • コンストラクタの循環依存 — あまり明白ではないが一般的なオーバーフローの原因
  • 明示的なStack<T>による再帰の反復置き換えでリスクを完全に排除
  • Kotlinのtailrecは末尾再帰をコンパイラレベルで反復に変換
  • 静的解析(Detekt、Infer)は実行前に潜在的な無限再帰を検出

ターンキー方式のモバイルアプリケーションを開発します

IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。

プロジェクトについて相談

こちらもお読みください