JavaのStackOverflowErrorとは、メソッド呼び出しが深く重なりすぎて、スレッドごとに用意された「スタック」という領域を使い切ったときにJVMが投げるErrorです。 ほとんどの場合、原因は「終わらない再帰」か「オブジェクト同士が互いを参照していて、toStringやequalsがぐるぐる回る」のどちらかで、スタックトレースの繰り返し部分を見れば場所を特定できます。

この記事では、原因パターンごとに再現コードと実際に表示されたスタックトレースを載せ、どの行を見ればよいかを説明します。あわせて、-Xssを256k・512k・1m・2m・4mと変えたときに再帰が何回まで進むかの実測表、OS別のデフォルト値、再帰をループとDequeに書き換えるコピー用コード、OutOfMemoryErrorとの違いをまとめました。

確認環境:OpenJDK 21.0.10 と OpenJDK 25.0.4.1(どちらも Linux x64、コンテナ上)。実測値はこの環境のもので、OSやCPU、メソッドの内容によって変わります。

StackOverflowErrorの早見表

原因と対処

原因パターン トレースの繰り返し方 対処
終了条件のない再帰 同じ1行が延々と続く 終了条件(ベースケース)を足す
終了条件に届かない再帰(負の数など) 同じ1行、または2行の交互 入力の範囲を確かめる。条件を <= にする
相互再帰(AがBを、BがAを呼ぶ) 2行が交互に続く 0に届かない入力を先に確かめる
セッターなどが自分自身を呼ぶ書き間違い 同じ1行が続く this.name = name に直す
フィールド初期化で自分自身を new <init> の行が続く 初期化で自分を作らない
toString の循環参照(親が子を、子が親を出力) 数行のかたまりが繰り返す 相手側は名前やIDだけ出す
equals・hashCode の循環参照 数行のかたまりが繰り返す 相手側のオブジェクトを比較に含めない
正しい再帰だがデータが深い(100万階層など) 同じ1行が続く ループ+Dequeに書き換える。応急処置として -Xss

-Xssと到達できる再帰の深さ(実測の目安)

-Xss 引数なしの単純な再帰 long引数4つ+ローカル変数4つの再帰
256k 約1,800〜3,600回 約700〜1,000回
512k 約4,800〜10,000回 約1,900〜3,100回
1m(Linux x64の既定) 約10,700〜23,000回 約4,400〜7,200回
2m 約22,600〜49,500回(1回だけ約86,000回) 約9,200〜15,400回
4m 約46,500〜102,000回(1回だけ約243,000回) 約18,900〜32,000回

スタックを2倍にすると、進める深さもおおよそ2倍になります。幅があるのは、JITコンパイルの進み具合で1回の呼び出しが使うスタックの量が変わるためです(詳しい数値は後述)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java StackOverflowErrorの原因と対処|再帰と-Xss早見表」

StackOverflowErrorが起きる仕組み

Javaは、メソッドを呼ぶたびに「スタックフレーム」(引数・ローカル変数・戻り先を入れる箱)をスレッドのスタックに積み、メソッドが終わると取り除きます。再帰が終わらないと箱が積まれ続け、スタックの上限に達したところで StackOverflowError になります。

graph TD
  A[main] --> B[factorial 5]
  B --> C[factorial 4]
  C --> D[factorial 3]
  D --> E[...終了条件がないと積まれ続ける]
  E --> F[スタックの上限 = StackOverflowError]
項目 内容
クラス java.lang.StackOverflowError
継承元 VirtualMachineError(その上が Error)
単位 スレッドごと。1つのスレッドで起きても、ほかのスレッドのスタックには影響しない
上限を決めるもの -Xss(または -XX:ThreadStackSize)、あるいは Thread のコンストラクタで渡す stackSize
メッセージ なし(クラス名だけが表示される)

原因パターン別の再現コードとスタックトレース

パターン1:終了条件のない再帰

public class Inf {
    static int factorial(int n) {
        return n * factorial(n - 1);   // n == 0 で止める条件がない
    }
    public static void main(String[] args) {
        System.out.println(factorial(5));
    }
}

実行結果(Java 21。先頭と末尾を抜粋):

Exception in thread "main" java.lang.StackOverflowError
    at Inf.factorial(Inf.java:3)
    at Inf.factorial(Inf.java:3)
    at Inf.factorial(Inf.java:3)
    at Inf.factorial(Inf.java:3)
    (同じ行が続く)
    at Inf.factorial(Inf.java:3)

同じ1行だけが並んでいたら、そのメソッドが自分自身を呼び続けています。 表示された行(Inf.java の3行目)に終了条件があるかを確かめます。

直した後:

static int factorial(int n) {
    if (n <= 1) return 1;            // 終了条件を先に書く
    return n * factorial(n - 1);
}

n == 0 ではなく n <= 1 のように範囲で止めておくと、負の数が渡されたときにも止まります。

スタックトレースは1,024行で打ち切られる

上のトレースの at の行を数えると、Java 21でも25でもちょうど1,024行でした。実際の再帰は2万回以上進んでいますが、JVMが記録する行数に上限があるためです。

$ java -XX:+PrintFlagsFinal -version | grep MaxJavaStackTraceDepth
     intx MaxJavaStackTraceDepth                   = 1024                                      {product} {default}
知っておくとよいこと 内容
表示されるのは「いちばん深い側」の1,024行 再帰の入口(main から最初に呼んだ場所)は表示されないことが多い
入口まで見たいとき -XX:MaxJavaStackTraceDepth=100000 のように増やす
ログを短くしたいとき -XX:MaxJavaStackTraceDepth=20 のように減らす(5にすると at が5行になることを確認)

入口が見えないときは、繰り返している行のメソッドを、どこから呼んでいるかをエディタの検索(呼び出し元の検索)で探すと早くたどれます。

パターン2:相互再帰(2つのメソッドが呼び合う)

public class Mut {
    static boolean isEven(int n) { return n == 0 ? true : isOdd(n - 1); }
    static boolean isOdd(int n)  { return n == 0 ? false : isEven(n - 1); }
    public static void main(String[] args) {
        System.out.println(isEven(10));
        System.out.println(isEven(-1));   // 負の数は 0 に届かない
    }
}

実行結果(Java 21、-XX:MaxJavaStackTraceDepth=6 で短くしています):

true
Exception in thread "main" java.lang.StackOverflowError
    at Mut.isOdd(Mut.java:3)
    at Mut.isEven(Mut.java:2)
    at Mut.isOdd(Mut.java:3)
    at Mut.isEven(Mut.java:2)
    at Mut.isOdd(Mut.java:3)
    at Mut.isEven(Mut.java:2)

2行が交互に並んでいたら相互再帰です。 isEven(10) は正しく true を返しているので、コードそのものより「0に届かない入力(負の数)」が原因だとわかります。入口で if (n < 0) throw new IllegalArgumentException(...) と確かめるか、Math.abs(n) で扱える範囲に直します。

パターン3:セッターの書き間違いとフィールド初期化

static class Item {
    private String name;
    public void setName(String name) { setName(name); }   // this.name = name のつもり
}
static class Node { Node child = new Node(); }            // 自分自身をフィールド初期化で作る

それぞれ new Item().setName("pen")、new Node() を実行した結果(先頭4行):

setter: java.lang.StackOverflowError / 記録された行数=1024
    at Misc$Item.setName(Misc.java:5)
    at Misc$Item.setName(Misc.java:5)
    at Misc$Item.setName(Misc.java:5)
    at Misc$Item.setName(Misc.java:5)
ctor: java.lang.StackOverflowError / 記録された行数=1024
    at Misc$Node.<init>(Misc.java:7)
    at Misc$Node.<init>(Misc.java:7)
    at Misc$Node.<init>(Misc.java:7)
    at Misc$Node.<init>(Misc.java:7)

<init> はコンストラクタ(フィールドの初期化を含む)を表します。 <init> が並んでいたら、new したときに同じクラスをもう一度 new していないかを確かめます。親クラスと子クラスのコンストラクタが互いを new している場合も同じ形になります。

パターン4:toStringの循環参照

チームがメンバーの一覧を持ち、メンバーも所属チームを持つ、という双方向の参照でよく起きます。

import java.util.*;
public class Cyc {
    static class Team {
        String name; List<Member> members = new ArrayList<>();
        Team(String name) { this.name = name; }
        @Override public String toString() { return "Team{name=" + name + ", members=" + members + "}"; }
        @Override public int hashCode() { return Objects.hash(name, members); }
        @Override public boolean equals(Object o) {
            return o instanceof Team t && name.equals(t.name) && members.equals(t.members); }
    }
    static class Member {
        String name; Team team;
        Member(String name, Team team) { this.name = name; this.team = team; }
        @Override public String toString() { return "Member{name=" + name + ", team=" + team + "}"; }
        @Override public int hashCode() { return Objects.hash(name, team); }
        @Override public boolean equals(Object o) {
            return o instanceof Member m && name.equals(m.name) && Objects.equals(team, m.team); }
    }
    static Team make() { Team t = new Team("dev"); t.members.add(new Member("sato", t)); return t; }
    public static void main(String[] args) {
        Team a = make(), b = make();
        String which = args.length > 0 ? args[0] : "toString";
        switch (which) {
            case "toString" -> System.out.println(a);
            case "hashCode" -> new HashSet<Team>().add(a);
            case "equals" -> System.out.println(a.equals(b));
        }
    }
}

java Cyc toString の結果(Java 21、14行に短縮):

Exception in thread "main" java.lang.StackOverflowError
    at Cyc$Team.toString(Cyc.java:6)
    at java.base/java.lang.String.valueOf(String.java:4454)
    at Cyc$Member.toString(Cyc.java:13)
    at java.base/java.lang.String.valueOf(String.java:4454)
    at java.base/java.lang.StringBuilder.append(StringBuilder.java:173)
    at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:459)
    at java.base/java.lang.String.valueOf(String.java:4454)
    at Cyc$Team.toString(Cyc.java:6)
    at java.base/java.lang.String.valueOf(String.java:4454)
    at Cyc$Member.toString(Cyc.java:13)
    at java.base/java.lang.String.valueOf(String.java:4454)
    at java.base/java.lang.StringBuilder.append(StringBuilder.java:173)
    at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:459)
    at java.base/java.lang.String.valueOf(String.java:4454)

今度は1行ではなく、7行のかたまりが繰り返しています。読み方は次のとおりです。

  1. java.base/ で始まる行(JDKの中)を読み飛ばす。
  2. 残った自分のクラスの行を並べる → Team.toString → Member.toString → Team.toString → …
  3. この往復が循環の正体です。Team の toString が members を出力し、Member の toString が team を出力しています。

println だけでなく、ログ出力(log.info("team={}", team))、文字列連結、デバッガの変数表示でも toString は呼ばれます。「ログを1行足したら StackOverflowError になった」というときは、このパターンを疑います。

パターン5:equals・hashCodeの循環参照

同じクラスで java Cyc hashCode(HashSet に入れる)を実行した結果(先頭を抜粋):

Exception in thread "main" java.lang.StackOverflowError
    at java.base/java.util.ArrayList.hashCodeRange(ArrayList.java:670)
    at java.base/java.util.ArrayList.hashCode(ArrayList.java:664)
    at java.base/java.util.Arrays.hashCode(Arrays.java:4599)
    at java.base/java.util.Objects.hash(Objects.java:133)
    at Cyc$Team.hashCode(Cyc.java:7)
    at java.base/java.util.Arrays.hashCode(Arrays.java:4599)
    at java.base/java.util.Objects.hash(Objects.java:133)
    at Cyc$Member.hashCode(Cyc.java:14)
    at java.base/java.util.ArrayList.hashCodeRange(ArrayList.java:677)

java Cyc equals の結果(先頭を抜粋):

Exception in thread "main" java.lang.StackOverflowError
    at java.base/java.util.Objects.equals(Objects.java:64)
    at java.base/java.util.ArrayList.equalsArrayList(ArrayList.java:643)
    at java.base/java.util.ArrayList.equals(ArrayList.java:611)
    at Cyc$Team.equals(Cyc.java:8)
    at java.base/java.util.Objects.equals(Objects.java:64)
    at Cyc$Member.equals(Cyc.java:15)

HashSet や HashMap のキーに入れる、List.contains や remove を呼ぶ、といった操作の裏で hashCode・equals が呼ばれます。 トレースの先頭がJDKのクラスでも、あわてずに自分のクラスの行(Team.hashCode と Member.hashCode)を拾えば、往復していることがわかります。

なお、リストが自分自身を要素に持つ場合、toString は [(this Collection)] と表示して止まりますが、hashCode は StackOverflowError になることも確認しました。record が自分を含むリストを持つ場合も、record の自動生成 toString で同じエラーになります。

循環参照の直し方(直した後のコード)

片方向だけ中身を出し、逆方向は名前やIDだけにするのが基本です。

import java.util.*;
public class Fix {
    static class Team {
        String name; List<Member> members = new ArrayList<>();
        Team(String name) { this.name = name; }
        @Override public String toString() { return "Team{name=" + name + ", members=" + members + "}"; }
        @Override public int hashCode() { return Objects.hash(name); }
        @Override public boolean equals(Object o) { return o instanceof Team t && name.equals(t.name); }
    }
    static class Member {
        String name; Team team;
        Member(String name, Team team) { this.name = name; this.team = team; }
        // 相手側は「名前だけ」を出す。Team 全体を出さない
        @Override public String toString() {
            return "Member{name=" + name + ", team=" + (team == null ? null : team.name) + "}"; }
        @Override public int hashCode() { return Objects.hash(name); }
        @Override public boolean equals(Object o) { return o instanceof Member m && name.equals(m.name); }
    }
    public static void main(String[] args) {
        Team t = new Team("dev"); t.members.add(new Member("sato", t));
        System.out.println(t);
        System.out.println(new HashSet<>(List.of(t)).size() + " " + t.equals(t));
    }
}

実行結果:

Team{name=dev, members=[Member{name=sato, team=dev}]}
1 true
道具 循環を止める書き方(一般に知られた仕様。今回は未実行)
Lombok の @ToString @EqualsAndHashCode @Data 相手を指すフィールドに @ToString.Exclude @EqualsAndHashCode.Exclude を付ける
IDEの自動生成 生成ダイアログで、相手を指すフィールドのチェックを外す
record 双方向の参照を持たせない。必要なら toString を自分で書く

パターン6:JPAの双方向関連とJSON変換

JPAで @OneToMany と @ManyToOne を両側に書いた双方向関連は、パターン4・5と同じ構造です。エンティティに自動生成の toString・equals・hashCode を付けると、同じ理由で StackOverflowError になります。

// 構造の例(JPAとLombokのライブラリが必要なため、この記事では未実行)
@Entity
public class Team {
    @Id Long id;
    String name;
    @OneToMany(mappedBy = "team")
    List<Member> members = new ArrayList<>();   // toString・equals に含めない
}

@Entity
public class Member {
    @Id Long id;
    String name;
    @ManyToOne
    Team team;                                  // toString では team.getId() だけ出す
}

今回の環境にはJPAの実装(Hibernateなど)を入れられないため、JPAそのものでの再現は未実行です。代わりに、同じ形のクラスをJSONに変換したときの動きを Jackson 2.16.1 で確かめました。

public static class Team { public String name = "dev"; public List<Member> members = new ArrayList<>(); }
public static class Member { public String name = "sato"; public Team team; }
// Team t と Member m を互いに参照させてから
new ObjectMapper().writeValueAsString(t);

実行結果(Jackson 2.16.1、長いので途中まで):

com.fasterxml.jackson.databind.JsonMappingException: Document nesting depth (1001) exceeds the maximum allowed (1000, from `StreamWriteConstraints.getMaxNestingDepth()`) (through reference chain: Jk$Team["members"]->java.util.ArrayList[0]->Jk$Member["team"]->J ...

この版のJacksonでは、StackOverflowErrorになる前に「入れ子が1,000階層を超えた」という例外で止まりました。 メッセージの reference chain に Team["members"] -> Member["team"] -> ... と往復が出るので、循環している場所がわかります。直し方は次の2通りを実行して確かめました。

書き方 出力されたJSON
親側に @JsonManagedReference、子側に @JsonBackReference {"name":"dev","members":[{"name":"sato"}]}
両側に @JsonIgnoreProperties("相手側のプロパティ名") Team: {"name":"dev","members":[{"name":"sato"}]} / Member: {"name":"sato","team":{"name":"dev"}}

エンティティをそのまま返さず、画面やAPI用のDTO(必要な項目だけを持つクラス)に詰め替える方法も、循環を持ち込まない確実な手です。

-Xssを変えたときの到達再帰深度(実測)

計測に使ったコード

public class Depth {
    static int depth;
    static void simple() { depth++; simple(); }
    static long heavy(long a, long b, long c, long d) {
        depth++;
        long x = a + 1, y = b + 2, z = c + 3, w = d + 4;
        return heavy(x, y, z, w) + x + y + z + w;
    }
    static int run(Runnable r) {
        depth = 0;
        try { r.run(); } catch (StackOverflowError e) { }
        return depth;
    }
    public static void main(String[] args) {
        System.out.println("simple=" + run(Depth::simple) + " heavy=" + run(() -> heavy(1, 2, 3, 4)));
    }
}

java -Xss512k Depth のように実行し、各設定で3回ずつ、さらに -Xint(JITコンパイルなし)で1回計測しました。

結果:単純な再帰(simple)

-Xss Java 21(3回) Java 25(3回) -Xint(21・25とも)
256k 3,640 / 1,810 / 3,640 3,640 / 3,640 / 3,640 1,793〜1,794
512k 4,772 / 10,194 / 10,194 10,194 / 10,194 / 9,197 4,772
指定なし 21,833 / 23,300 / 23,300 17,224 / 23,302 / 23,302 10,730
1m 23,300 / 23,300 / 23,241 23,302 / 23,302 / 23,302 10,730
2m 86,193 / 49,514 / 49,514 49,516 / 49,516 / 49,516 22,646
4m 243,189 / 101,944 / 101,944 101,944 / 101,944 / 101,944 46,477

結果:引数とローカル変数が多い再帰(heavy)

-Xss Java 21(3回) Java 25(3回) -Xint(21・25とも)
256k 1,056 / 730 / 1,056 1,057 / 1,057 / 907 730
512k 1,943 / 3,104 / 3,104 3,105 / 1,944 / 2,473 1,943〜1,944
指定なし 4,371 / 7,200 / 7,200 9,026 / 9,026 / 7,201 4,371
1m 4,371 / 7,200 / 7,200 7,201 / 7,201 / 7,201 4,371
2m 13,396 / 9,225 / 15,392 15,393 / 15,393 / 15,393 9,225
4m 29,871 / 18,934 / 31,776 31,777 / 31,777 / 32,162 18,934

結果から読み取れること:

  • 「指定なし」と -Xss1m はほぼ同じ値で、この環境の既定が1MBであることと合っています。
  • 深さは -Xss にほぼ比例します(-Xint の列で 1,793 → 4,772 → 10,730 → 22,646 → 46,477)。
  • 引数やローカル変数が多いメソッドほど、1回の呼び出しで使うスタックが増え、進める深さは浅くなります(1mで simple 約23,000回に対し heavy 約7,200回)。
  • 同じ設定でも実行ごとに値が変わります。 JITコンパイルされたメソッドはフレームが小さくなるため、コンパイルのタイミングで到達できる深さが変わります。-Xint では毎回ほぼ同じ値でした。
  • Java 21 と 25 で傾向は同じでした。

「この再帰は何回まで進めるか」という数は環境と実行のたびに変わるので、深さが入力データに比例する処理は、スタックに頼らない書き方(後述のループ+Deque)にするのが確実です。

OS別のデフォルト値と確認方法

Java 21 の公式ドキュメント(javaコマンドのマニュアル、-Xss の項)には、次のように書かれています。

プラットフォーム -Xss のデフォルト
Linux / x64 1024 KB
Linux / AArch64 2048 KB
macOS / x64 1024 KB
macOS / AArch64 2048 KB
Windows 仮想メモリに依存

手元の値は次のコマンドで確かめられます。Linux x64 では Java 21・25 とも 1024(KB)でした。Linux x64 以外は今回実行していません(未実行)。

$ java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
     intx CompilerThreadStackSize                  = 1024                                   {pd product} {default}
     intx ThreadStackSize                          = 1024                                   {pd product} {default}
     intx VMThreadStackSize                        = 1024                                   {pd product} {default}

-Xssの指定方法

指定する場所 書き方
コマンドライン java -Xss2m -jar app.jar(k m g の単位を付けられる)
環境変数でまとめて JAVA_TOOL_OPTIONS="-Xss2m"
特定のスレッドだけ new Thread(null, 処理, "名前", スタックのバイト数)

小さすぎる値は起動時に断られます。java -Xss64k -version を実行すると、次のメッセージが出ました(Java 21、Linux x64)。

The Java thread stack size specified is too small. Specify at least 136k
Error: Could not create the Java Virtual Machine.

-Xss は全スレッドに効くので、スレッドを数百本作るアプリケーションで大きくすると、その分だけメモリの予約が増えます。深い再帰が必要なのが一部の処理だけなら、その処理だけを大きなスタックのスレッドで動かす方法があります。

public class Thr {
    static int depth;
    static void simple() { depth++; simple(); }
    static int measure(long stackSize) throws InterruptedException {
        depth = 0;
        Thread t = new Thread(null, () -> {
            try { simple(); } catch (StackOverflowError e) { }
        }, "deep", stackSize);
        t.start(); t.join();
        return depth;
    }
    public static void main(String[] args) throws Exception {
        System.out.println("stackSize=0(既定) : " + measure(0));
        System.out.println("stackSize=16MB    : " + measure(16L * 1024 * 1024));
        Thread t = new Thread(() -> simple(), "worker-1");
        t.start(); t.join();
        System.out.println("main は続行");
    }
}

実行結果(Java 21、トレースは3行に短縮):

stackSize=0(既定) : 21958
stackSize=16MB    : 416824
Exception in thread "worker-1" java.lang.StackOverflowError
    at Thr.simple(Thr.java:3)
    at Thr.simple(Thr.java:3)
    at Thr.simple(Thr.java:3)
main は続行

16MBのスタックを渡したスレッドでは約41万回まで進みました。また、別スレッドで StackOverflowError が起きても、main スレッドはそのまま続行しています。1行目の Exception in thread "worker-1" で、どのスレッドで起きたかがわかります。

再帰をループ+Dequeに書き換える(コピー用コード)

-Xss を増やすのは「上限を先に延ばす」対応です。データの深さに上限がない処理は、呼び出しスタックの代わりに自分で用意した Deque(ヒープ上のスタック)を使うと、深さの制限から自由になります。

import java.util.*;

public class Deq {
    record Node(String name, List<Node> children) {}

    // 直す前:再帰で全ノードを数える(深い木で StackOverflowError)
    static int countRecursive(Node node) {
        int count = 1;
        for (Node child : node.children()) count += countRecursive(child);
        return count;
    }

    // 直した後:ループ + Deque(深さ優先)
    static int countIterative(Node root) {
        Deque<Node> stack = new ArrayDeque<>();
        stack.push(root);
        int count = 0;
        while (!stack.isEmpty()) {
            Node node = stack.pop();
            count++;
            for (Node child : node.children()) stack.push(child);
        }
        return count;
    }

    // 循環があっても止まる版(訪問済みを記録)
    static int countWithVisited(Node root) {
        Deque<Node> stack = new ArrayDeque<>();
        Set<Node> visited = Collections.newSetFromMap(new IdentityHashMap<>());
        stack.push(root);
        while (!stack.isEmpty()) {
            Node node = stack.pop();
            if (!visited.add(node)) continue;
            for (Node child : node.children()) stack.push(child);
        }
        return visited.size();
    }

    // 単純な再帰はただのループに
    static long sumRecursive(int n) { return n == 0 ? 0 : n + sumRecursive(n - 1); }
    static long sumLoop(int n) { long s = 0; for (int i = 1; i <= n; i++) s += i; return s; }

    static Node chain(int depth) {
        Node node = new Node("leaf", new ArrayList<>());
        for (int i = 0; i < depth; i++) node = new Node("n" + i, new ArrayList<>(List.of(node)));
        return node;
    }

    public static void main(String[] args) {
        Node deep = chain(1_000_000);
        try {
            System.out.println("再帰: " + countRecursive(deep));
        } catch (StackOverflowError e) {
            System.out.println("再帰: StackOverflowError");
        }
        System.out.println("ループ+Deque: " + countIterative(deep));

        Node a = new Node("a", new ArrayList<>());
        Node b = new Node("b", new ArrayList<>(List.of(a)));
        a.children().add(b);                       // a -> b -> a の循環
        System.out.println("循環ありでも止まる: " + countWithVisited(a));

        try {
            System.out.println("sumRecursive: " + sumRecursive(1_000_000));
        } catch (StackOverflowError e) {
            System.out.println("sumRecursive: StackOverflowError");
        }
        System.out.println("sumLoop: " + sumLoop(1_000_000));
    }
}

実行結果(Java 21・25 とも同じ):

再帰: StackOverflowError
ループ+Deque: 1000001
循環ありでも止まる: 2
sumRecursive: StackOverflowError
sumLoop: 500000500000

書き換えの型は次の3つです。

元の再帰の形 書き換え先 ポイント
自分を1回だけ呼ぶ(合計、階乗、リストをたどる) for / while ループ 引数を更新する変数に置き換える
子を順にたどる(木、フォルダ、入れ子のメニュー) Deque に push / pop する深さ優先 「次に処理するもの」を自分で積む
循環があるかもしれないグラフ Deque + 訪問済みの Set 同じノードを2回処理しない

訪問済みの記録に IdentityHashMap を元にした Set を使っているのは、ノードの equals・hashCode を呼ばずに「同じインスタンスか」で判定するためです(record の hashCode は中身をたどるので、循環があるとそこで StackOverflowError になります)。幅優先でたどりたいときは、push / pop を addLast / pollFirst に替えます。

StackOverflowErrorとOutOfMemoryErrorの違い

比べる点 StackOverflowError OutOfMemoryError
足りなくなる領域 スレッドごとのスタック ヒープ(オブジェクト置き場)など
主な原因 メソッド呼び出しの重なりすぎ(再帰、循環参照) オブジェクトの持ちすぎ、大きな配列
調整するオプション -Xss -Xmx など
メッセージ なし Java heap space など種類を示す文がある
影響の範囲 そのスレッド。スタックは巻き戻る ヒープは全スレッド共有のため、ほかの処理にも及ぶ
調べる手がかり スタックトレースの繰り返し行 ヒープダンプ、メッセージの種類
継承元 VirtualMachineError VirtualMachineError

両方を1つのプログラムで起こして catch した結果(-Xmx64m で実行):

catch: java.lang.StackOverflowError -> 処理を続けられる
catch: java.lang.OutOfMemoryError: Java heap space
class java.lang.VirtualMachineError / class java.lang.VirtualMachineError

StackOverflowError を catch して処理を続けること自体は可能ですが、途中まで進んだ処理の状態(更新しかけのデータなど)は中途半端なまま残ります。catch は「ログに記録して、その処理を安全に打ち切る」ために使い、根本はコードの構造で直すのがおすすめです。

なお、スレッドを大量に作ってスタック用のメモリを確保できなくなった場合は、StackOverflowError ではなく OutOfMemoryError(unable to create native thread)になることが一般に知られています(今回は未実行)。-Xss を大きくしすぎると、この状態に近づきやすくなります。

調べるときのチェックリスト

順番 確認すること
1 1行目の Exception in thread "..." で、どのスレッドかを確かめる
2 at の行から java.base/ で始まる行を除き、自分のクラスの行だけを見る
3 同じ1行の繰り返しか、2行以上のかたまりの繰り返しかを見分ける
4 1行の繰り返し → そのメソッドの終了条件と、渡している引数を確かめる
5 toString・hashCode・equals・<init> が含まれる → 双方向の参照を探す
6 入口が見えない → -XX:MaxJavaStackTraceDepth を増やして再実行する
7 コードが正しくデータが深いだけ → ループ+Dequeへ。応急処置として -Xss

よくある質問

Q. -Xssを大きくすれば解決しますか? A. 終了条件のない再帰や循環参照が原因なら、どれだけ大きくしても同じエラーになります。-Xss が有効なのは、再帰は正しく終わるものの、データが深くて既定のスタックでは足りない場合です。その場合も、深さが入力に比例する処理はループ+Dequeへの書き換えが確実です。

Q. 再帰は何回まで呼べますか? A. 決まった回数はありません。今回の環境(Linux x64、既定の1MB)では、引数なしの単純なメソッドで約1万〜2万3千回、引数とローカル変数が多いメソッドで約4千〜9千回でした。メソッドの内容やJITコンパイルの状況で変わるので、数千回を超える深さになりうる処理はループにしておくと安心です。

Q. スタックトレースが長すぎて、最初の呼び出し元が見えません。 A. JVMが記録するのは既定で1,024行までです。-XX:MaxJavaStackTraceDepth=100000 のように増やすと、入口側まで表示されやすくなります。まずは繰り返している行のメソッドを特定し、その呼び出し元をエディタで検索するのが早道です。

Q. Javaに末尾再帰の最適化はありますか? A. 今回の計測では、simple() のように最後に自分を呼ぶだけのメソッドでも、Java 21・25 のどちらでも StackOverflowError になりました。再帰の形にかかわらずスタックは積まれる前提で、深い処理はループで書きます。

まとめ

  • StackOverflowError は、メソッド呼び出しの重なりがスレッドのスタックを使い切ったときに起きるErrorです。メッセージはなく、スタックトレースが手がかりになります。
  • 同じ1行の繰り返しは終わらない再帰、数行のかたまりの繰り返しは相互再帰か循環参照です。JDKの行を除いて自分のクラスの行を見ます。
  • toString・equals・hashCode の循環は、双方向の参照の片側を「名前やIDだけ」にすると止まります。ログ出力や HashSet への追加でも起きます。
  • トレースは既定で1,024行まで。 入口を見たいときは -XX:MaxJavaStackTraceDepth を増やします。
  • -Xss の既定は Linux x64 で1MB。 2倍にすると進める深さもほぼ2倍ですが、実行ごとに幅があります。
  • データが深いだけの再帰は、ループ+Deque に書き換えると深さの制限から自由になります(100万階層でも完走)。
  • OutOfMemoryError はヒープ、StackOverflowError はスタックの不足で、調整するオプションも -Xmx と -Xss で別です。

参考・出典

  • Java SE 21 ドキュメント「The java Command」(-Xss と -XX:ThreadStackSize の説明、プラットフォーム別のデフォルト値を確認) https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java StackOverflowErrorの原因と対処|再帰と-Xss早見表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?