Javaのスタックトレースとは、例外が発生した瞬間に「どのメソッドが、どのメソッドから呼ばれていたか」を上から新しい順に並べた記録です。読む順番は「一番下のCaused byを見る → その下で自分のパッケージ名が付いた最初の行を見る」の2手で、ほとんどの場合はそこに書かれたファイル名と行番号が調べる場所になります。

この記事では、Java 21(OpenJDK 21.0.10)で実際に出力したスタックトレースを1行ずつ注釈し、「Caused by」「... n more」「Suppressed」の意味を実物で確認します。さらに、Tomcat 10.1 と Spring Framework で出した長いトレース(最長116行)を使って、読む行と読み飛ばす行の見分け方を示します。後半には、頻出例外10種の原因と対処の一覧表と、Helpful NullPointerException のメッセージの読み方をまとめました。掲載している出力は、断りのない限りすべて手元で実行した結果です。

スタックトレースの読み方の早見表

見る場所 書かれていること 読み方
1行目(Exception in thread "main" ...) 最後に投げられた例外のクラス名とメッセージ 「何が起きたか」の概要。原因そのものは下にあることが多い
at ... の行 呼び出されていたメソッド。上ほど新しい(例外に近い) クラス名.メソッド名(ファイル名:行番号) の形で読む
Caused by: ... 1つ上の例外の「原因」になった例外 一番下の Caused by が根本原因
... 3 more 上のブロックと同じなので省略した行数 情報は欠けていない。読み飛ばしてよい
Suppressed: ... 後片付け(close)中に追加で起きた例外 主役は外側の例外。Suppressed は補足情報
自分のパッケージ名の行(例:com.example....) 自分たちが書いたコード 根本原因のブロックで最初に出てくる自分の行が、最初に開くファイル
java.base/...、org.springframework... など JDKやフレームワークの内部 通常は読み飛ばす
やりたいこと 手順
原因を最短で見つけたい 一番下の Caused by → その直下で自分のパッケージの最初の行
長いトレースを絞り込みたい grep -n "Caused by" と grep -n "自分のパッケージ名" で行番号だけ拾う
NullPointerException の null の正体を知りたい メッセージの because "..." is null の部分を読む
コードの中で根本原因を取り出したい getCause() が null になるまでたどる

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

(記載例)出典:株式会社RJC「Javaスタックトレースの読み方|Caused byの意味と原因の探し方」

スタックトレースの基本の形を知る

再現用のコード(そのまま動く完成形)

注文の合計を表示する小さなプログラムです。数量のデータに「3個」という文字が入っていて、数値に変換できずに止まります。3つのファイルを src/com/example/shop/ に置いてください。

// src/com/example/shop/OrderApp.java
package com.example.shop;

public class OrderApp {
    public static void main(String[] args) {
        OrderService service = new OrderService(new OrderRepository());
        service.printTotal("A-100");
    }
}
// src/com/example/shop/OrderService.java
package com.example.shop;

public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void printTotal(String orderId) {
        try {
            int total = calcTotal(orderId);
            System.out.println("合計: " + total);
        } catch (IllegalStateException e) {
            throw new RuntimeException("注文の合計を表示できませんでした: " + orderId, e);
        }
    }

    int calcTotal(String orderId) {
        return repository.findQuantity(orderId) * 500;
    }
}
// src/com/example/shop/OrderRepository.java
package com.example.shop;

import java.util.Map;

public class OrderRepository {
    private final Map<String, String> rows = Map.of("A-100", "3個");

    public int findQuantity(String orderId) {
        String raw = rows.get(orderId);
        try {
            return Integer.parseInt(raw);
        } catch (NumberFormatException e) {
            throw new IllegalStateException("数量を読み取れませんでした: " + orderId, e);
        }
    }
}
javac -encoding UTF-8 -g -d out src/com/example/shop/*.java
java -cp out com.example.shop.OrderApp

実行結果(Java 21.0.10):

Exception in thread "main" java.lang.RuntimeException: 注文の合計を表示できませんでした: A-100
    at com.example.shop.OrderService.printTotal(OrderService.java:15)
    at com.example.shop.OrderApp.main(OrderApp.java:6)
Caused by: java.lang.IllegalStateException: 数量を読み取れませんでした: A-100
    at com.example.shop.OrderRepository.findQuantity(OrderRepository.java:13)
    at com.example.shop.OrderService.calcTotal(OrderService.java:20)
    at com.example.shop.OrderService.printTotal(OrderService.java:12)
    ... 1 more
Caused by: java.lang.NumberFormatException: For input string: "3個"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:662)
    at java.base/java.lang.Integer.parseInt(Integer.java:778)
    at com.example.shop.OrderRepository.findQuantity(OrderRepository.java:11)
    ... 3 more

at 行の読み方

at で始まる1行を「スタックフレーム」と呼びます。1行が「そのとき実行中だったメソッド1つ」 を表します。

at java.base/java.lang.Integer.parseInt(Integer.java:662)
   ~~~~~~~~~ ~~~~~~~~~~~~~~~~~ ~~~~~~~~ ~~~~~~~~~~~~ ~~~
   モジュール名  パッケージ名+クラス名  メソッド名  ファイル名    行番号
表記 意味 今回の出力での例
java.base/ JDKのモジュール名。JDK内部のコードだとわかる java.base/java.lang.Integer.parseInt
クラス名$内側の名前 内部クラス(ネストしたクラス) SuppressedDemo$StockFile.close
<init> コンストラクタ DiApp$StockClient.<init>(DiApp.java:13)
lambda$main$0 main メソッドの中に書いた1つ目のラムダ式 LambdaDemo.lambda$main$0(LambdaDemo.java:9)
(ファイル名:行番号) ソースの場所。IDEではクリックで開ける (OrderRepository.java:11)

上の行ほど新しく、下の行ほど呼び出し元です。1つのブロックを下から上へ読むと「main → printTotal → calcTotal → findQuantity → parseInt」という処理の流れになります。

実物のトレースを1行ずつ注釈する

Markdown では文字に色を付けられないため、ここでは色の代わりに【読む】【補足】【飛ばす】の3種類のラベルで塗り分けます。蛍光ペンで3色に塗るつもりで見てください。

行 出力(要点) ラベル 注釈
1 Exception in thread "main" java.lang.RuntimeException: 注文の合計を表示できませんでした: A-100 【補足】 main スレッドで最後に投げられた例外。「何ができなかったか」がわかる
2 at ...OrderService.printTotal(OrderService.java:15) 【補足】 RuntimeException を new した場所(包み直した場所)
3 at ...OrderApp.main(OrderApp.java:6) 【補足】 呼び出し元
4 Caused by: java.lang.IllegalStateException: 数量を読み取れませんでした: A-100 【補足】 1行目の例外の原因。まだ下に Caused by があるので途中経過
5 at ...OrderRepository.findQuantity(OrderRepository.java:13) 【補足】 IllegalStateException を new した場所
6〜7 calcTotal(OrderService.java:20)、printTotal(OrderService.java:12) 【補足】 そこまでの呼び出し経路
8 ... 1 more 【飛ばす】 残り1行(3行目の main)は上と同じなので省略
9 Caused by: java.lang.NumberFormatException: For input string: "3個" 【読む】 一番下の Caused by = 根本原因。「"3個" を数値にできなかった」
10〜12 at java.base/java.lang.... の3行 【飛ばす】 JDK内部。例外を実際に投げた場所だが、直す場所ではない
13 at ...OrderRepository.findQuantity(OrderRepository.java:11) 【読む】 根本原因のブロックで最初に出てくる自分の行。ここを開く
14 ... 3 more 【飛ばす】 残り3行は上のブロックと同じ

14行のうち、最初に読むのは9行目と13行目の2行だけです。OrderRepository.java の11行目を開くと Integer.parseInt(raw) があり、メッセージの "3個" と合わせて「数量のデータに単位が混ざっている」と判断できます。

読む手順を図にする

flowchart TD
    A[スタックトレースが出た] --> B{Caused by がある?}
    B -- ない --> C[1行目の例外名とメッセージを読む]
    B -- ある --> D[一番下の Caused by の例外名とメッセージを読む]
    C --> E[そのブロックの at 行を上から見る]
    D --> E
    E --> F{自分のパッケージ名の行がある?}
    F -- ある --> G[最初に出てきた自分の行のファイルと行番号を開く]
    F -- ない --> H[1つ上のブロックに移り 自分の行を探す]
    H --> G
    G --> I[メッセージの値と その行のコードを見比べる]

「自分のパッケージ名」は、ソースの先頭にある package 文の先頭部分です。この記事の例では com.example です。会社やチームごとに決まった接頭辞があるので、最初にそれを確認しておくと探しやすくなります。

Caused byの意味を理解する

Caused by は「この例外は、次の例外が原因で投げられました」という意味です。上の例では次の3段になっています。

段 例外 役割
1段目(一番上) RuntimeException「注文の合計を表示できませんでした」 利用者に近い側の説明
2段目 IllegalStateException「数量を読み取れませんでした」 途中の説明
3段目(一番下) NumberFormatException For input string: "3個" 根本原因

Caused by が表示されるのは、例外を包み直すときに元の例外を「原因」として渡しているからです。

} catch (NumberFormatException e) {
    // 第2引数の e が「原因(cause)」。これが Caused by として表示される
    throw new IllegalStateException("数量を読み取れませんでした: " + orderId, e);
}
書き方 トレースの表示
new IllegalStateException("...", e) Caused by に元の例外が残り、根本原因までたどれる
new IllegalStateException("...")(e を渡さない) Caused by が出ない。元の例外の情報は表示されない

例外を包み直すときは第2引数に元の例外を渡すと、あとで読む人が根本原因までたどれます。

コードの中で根本原因を取り出す

ログに根本原因だけを1行で出したいときは、getCause() が null になるまでたどります。Stream とラムダ式の例で確認します。

// src/com/example/shop/LambdaDemo.java
package com.example.shop;

import java.util.List;

public class LambdaDemo {
    public static void main(String[] args) {
        List<String> prices = List.of("120", "80", "無料");
        try {
            int sum = prices.stream().mapToInt(p -> Integer.parseInt(p)).sum();
            System.out.println(sum);
        } catch (RuntimeException e) {
            e.printStackTrace();
            Throwable root = e;
            while (root.getCause() != null) {
                root = root.getCause();
            }
            System.err.println("根本原因: " + root);
            StackTraceElement top = root.getStackTrace()[0];
            System.err.println("投げた場所: " + top.getClassName() + "." + top.getMethodName() + " 行" + top.getLineNumber());
        }
    }
}

実行結果:

java.lang.NumberFormatException: For input string: "無料"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:662)
    at java.base/java.lang.Integer.parseInt(Integer.java:778)
    at com.example.shop.LambdaDemo.lambda$main$0(LambdaDemo.java:9)
    at java.base/java.util.stream.ReferencePipeline$4$1.accept(ReferencePipeline.java:214)
    at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
    at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
    at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
    at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.base/java.util.stream.IntPipeline.reduce(IntPipeline.java:515)
    at java.base/java.util.stream.IntPipeline.sum(IntPipeline.java:473)
    at com.example.shop.LambdaDemo.main(LambdaDemo.java:9)
根本原因: java.lang.NumberFormatException: For input string: "無料"
投げた場所: java.lang.NumberFormatException.forInputString 行67

Stream を使うと、自分の行(4行目と13行目)の間に java.util.stream の行が8行はさまります。lambda$main$0 は「main の中のラムダ式」という意味で、行番号(9行目)はラムダ式を書いた行を指します。Caused by がないトレースでは、1行目がそのまま根本原因です。

「... n more」の意味を確かめる

「... n more」は「残りの n 行は、1つ上のブロックの末尾 n 行と同じなので省略しました」という意味です。情報が欠けているわけではありません。

最初の例の2段目を、省略せずに書くと次のようになります。

Caused by: java.lang.IllegalStateException: 数量を読み取れませんでした: A-100
    at com.example.shop.OrderRepository.findQuantity(OrderRepository.java:13)
    at com.example.shop.OrderService.calcTotal(OrderService.java:20)
    at com.example.shop.OrderService.printTotal(OrderService.java:12)
    at com.example.shop.OrderApp.main(OrderApp.java:6)        ← これが「... 1 more」の中身
表示 省略されている行 同じ内容がある場所
2段目の ... 1 more OrderApp.main(OrderApp.java:6) 1段目の末尾1行
3段目の ... 3 more calcTotal(20)、printTotal(12)、main(6) 2段目の末尾3行(省略分を含めて数える)

今回 Oracle の Throwable クラスの API ドキュメント(Java 21)を開いて確認したところ、「... n more」は「この例外のスタックトレースの残りが、この例外を原因として持つ外側の例外のスタックトレースの末尾 n フレームと一致する」ことを示すと説明されています。

呼び出し元までの経路を知りたいときは、1つ上のブロックの末尾を見れば同じ行があります。原因を探すだけなら読み飛ばして問題ありません。

Suppressedの意味を確かめる

Suppressed は「主役の例外を処理している途中で、追加で発生した例外」です。代表的なのは try-with-resources で、本体で例外が出たあと、自動で呼ばれる close() でも例外が出た場合です。

// src/com/example/shop/SuppressedDemo.java
package com.example.shop;

public class SuppressedDemo {
    static class StockFile implements AutoCloseable {
        void read() {
            throw new IllegalStateException("読み込み中に止まりました");
        }
        @Override
        public void close() {
            throw new UnsupportedOperationException("閉じる処理でも例外が出ました");
        }
    }

    public static void main(String[] args) {
        try (StockFile file = new StockFile()) {
            file.read();
        }
    }
}

実行結果:

Exception in thread "main" java.lang.IllegalStateException: 読み込み中に止まりました
    at com.example.shop.SuppressedDemo$StockFile.read(SuppressedDemo.java:6)
    at com.example.shop.SuppressedDemo.main(SuppressedDemo.java:16)
    Suppressed: java.lang.UnsupportedOperationException: 閉じる処理でも例外が出ました
        at com.example.shop.SuppressedDemo$StockFile.close(SuppressedDemo.java:10)
        at com.example.shop.SuppressedDemo.main(SuppressedDemo.java:15)
項目 Caused by Suppressed
関係 上の例外の「原因」 上の例外の処理中に「追加で起きた」例外
表示位置 行頭から始まる 1段インデントされる
読む優先度 一番下が最優先 外側(インデントなし)の例外が主役。Suppressed は2番目
よく出る場面 例外の包み直し try-with-resources の close()
コードで取り出す getCause() getSuppressed()(配列)

Suppressed が出ていたら、まず外側の例外(この例では「読み込み中に止まりました」)を直します。外側が解決すると、close() 側の例外も一緒に出なくなることがよくあります。

長いトレースを読み飛ばす(TomcatとSpringの実物)

Webアプリケーションでは、フレームワークが何層も呼び出しを重ねるため、トレースが数十行から100行以上になります。ここでは実際に長いトレースを出して、読む行を絞り込みます。

検証した環境と範囲は次のとおりです。

項目 内容
実行したもの Tomcat 10.1.55 の組み込み起動、Spring Framework 4.3.30(spring-context)の DI コンテナ
未実行のもの Spring Boot 3.x/4.x そのもの(この検証環境では取得できなかったため)
読み方の共通点 Spring Boot は内部で Spring Framework と Tomcat を使うため、パッケージ名の並び方と読み飛ばし方は同じ考え方で読める

Tomcatのトレース:31行のうち自分の行は5行

先ほどの OrderService をサーブレットから呼び、フィルターを1つ通した構成です。GET /orders?id=A-100 にアクセスすると、Tomcat が次のログを出しました(HTTP ステータスは 500)。

SEVERE: Servlet.service() for servlet [orders] in context with path [] threw exception [GET /orders の処理が止まりました] with root cause
java.lang.NumberFormatException: For input string: "3個"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:662)
    at java.base/java.lang.Integer.parseInt(Integer.java:778)
    at com.example.shop.OrderRepository.findQuantity(OrderRepository.java:11)
    at com.example.shop.OrderService.calcTotal(OrderService.java:20)
    at com.example.shop.OrderService.printTotal(OrderService.java:12)
    at com.example.web.WebApp$OrderServlet.doGet(WebApp.java:21)
    at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
    at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:138)
    at com.example.web.WebApp$LogFilter.doFilter(WebApp.java:31)
    at jakarta.servlet.http.HttpFilter.doFilter(HttpFilter.java:53)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:162)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:138)
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165)
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:88)
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:492)
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:113)
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:83)
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:72)
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:342)
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:399)
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:1272)
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1797)
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:973)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:491)
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
    at java.base/java.lang.Thread.run(Thread.java:1583)

31行の内訳を数えると次のようになりました。

パッケージの先頭 行数 扱い
com.example(自分のコード) 5 読む
org.apache.catalina / coyote / tomcat(Tomcat) 19 飛ばす
jakarta.servlet(サーブレットAPI) 3 飛ばす
java.base(JDK) 4 飛ばす

ポイントは2つあります。

  • Tomcat は「with root cause」と書いて、根本原因のトレースを直接出してくれます。サーブレット側では ServletException と RuntimeException で2回包んでいますが、ログに出たのは一番奥の NumberFormatException でした。この形のログでは、1行目がそのまま根本原因です。
  • 自分の行は上のほうに固まります。4行目の OrderRepository.java:11 が最初に開く場所で、12行目のフィルターより下は、リクエストを受け取ってからサーブレットに届くまでの定型の経路です。

Springのトレース:116行のうち読むのは2か所

次は、起動時に DI コンテナが Bean(部品)を組み立てる場面です。Controller → Facade → Service → Repository → Gateway → Client の順に依存する6つのクラスを登録し、一番奥の StockClient のコンストラクタで設定値のチェックに引っかかる例を動かしました。

@Component
public static class StockClient {
    public StockClient(@Value("${stock.url}") String url) {
        if (!url.startsWith("http")) {
            throw new IllegalArgumentException("stock.url は http から始めてください: " + url);
        }
    }
}

出力は全部で116行(うちトレース部分は112行、org.springframework の at 行が92行)でした。全文は載せきれないので、先頭・途中・末尾を抜粋します。メッセージが1行で最大1,546文字あったため、途中を「…(略)…」で省いています。

Exception in thread "main" org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'diApp.StockController': Unsatisfied dependency expressed through constructor parameter 0; nested exception is …(略)… nested exception is java.lang.IllegalArgumentException: stock.url は http から始めてください: localhost:9000
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:749)
    at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:189)
    …(Spring 内部の行が続く。略)…
    at org.springframework.context.annotation.AnnotationConfigApplicationContext.<init>(AnnotationConfigApplicationContext.java:84)
    at com.example.di.DiApp.main(DiApp.java:44)
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'diApp.StockFacade': …(略)…
    …(同じ形のブロックが Service、Repository、Gateway と続く。略)…
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.example.di.DiApp$StockClient]: Constructor threw exception; nested exception is java.lang.IllegalArgumentException: stock.url は http から始めてください: localhost:9000
    at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:157)
    at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:122)
    at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:271)
    ... 41 more
Caused by: java.lang.IllegalArgumentException: stock.url は http から始めてください: localhost:9000
    at com.example.di.DiApp$StockClient.<init>(DiApp.java:13)
    at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
    at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
    at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486)
    at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:145)
    ... 85 more

この長さでも、手順は同じです。ログをファイルに保存して、grep で行番号だけ拾うと全体の形が見えます。

grep -n "Caused by" di.log | cut -c1-110
21:Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 
38:Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 
55:Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 
72:Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 
89:Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'diApp.St
105:Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.example.di.DiA
110:Caused by: java.lang.IllegalArgumentException: stock.url は http から始めてください: localhost:9
grep -n "com.example" di.log | grep "at "
20:    at com.example.di.DiApp.main(DiApp.java:44)
111:    at com.example.di.DiApp$StockClient.<init>(DiApp.java:13)

Caused by は7個、自分の行は2行だけでした。読むのは次の2か所です。

  1. 110行目(一番下の Caused by) :IllegalArgumentException: stock.url は http から始めてください: localhost:9000
  2. 111行目(その直下の自分の行) :DiApp$StockClient.<init>(DiApp.java:13) = StockClient のコンストラクタの13行目

これで「設定値 stock.url に http:// が付いていない」とわかります。残りの114行は、Spring が Bean を順番に組み立てていた経路の記録です。

1行目の長いメッセージは末尾から読む

Spring の例外メッセージは、nested exception is で原因の説明をつなげていきます。1行目が長いときは、行の末尾(最後の nested exception is の後ろ)に根本原因のメッセージがあります。上の例でも、1行目の末尾は java.lang.IllegalArgumentException: stock.url は http から始めてください: localhost:9000 でした。

読み飛ばしてよいパッケージの目安

行の先頭 何のコードか 扱い
java.base/、jdk.internal. JDK 飛ばす(根本原因ブロックの一番上だけ、何の処理かを確認)
org.springframework. Spring Framework / Spring Boot 飛ばす
org.apache.catalina.、org.apache.coyote.、org.apache.tomcat. Tomcat 飛ばす
jakarta.servlet. サーブレットAPI 飛ばす
java.util.stream. Stream の内部 飛ばす
自分のパッケージ名 自分たちのコード 読む

Spring Boot では、これに加えて O/R マッパーや JSON ライブラリなどの行が増えますが(Spring Boot 自体は今回未実行)、「自分のパッケージ名で絞り込む」という方法は変わりません。IDE のコンソールには、ライブラリの行を折りたたむ表示機能を持つものもあります。

頻出例外10種の原因と対処の一覧表

よく出会う例外を10種、Java 21.0.10 で実際に発生させてメッセージを確認しました。「再現コード」をそのまま試せます。

例外 実際のメッセージ 再現コード 主な原因 対処
NullPointerException Cannot invoke "String.trim()" because "s" is null String s = null; s.trim(); null の変数に対してメソッドを呼んだ メッセージの because 以降で null の変数を特定し、値が入る経路を確認する
ArrayIndexOutOfBoundsException Index 3 out of bounds for length 3 int[] a = new int[3]; a[3] = 1; 配列の範囲外の番号を指定した 番号は 0 から「長さ−1」まで。ループの条件を < にする
IndexOutOfBoundsException Index: 2 Size: 2 List.of("a", "b").get(2); List の範囲外を指定した size() で件数を確かめてから取り出す
ClassCastException class java.lang.String cannot be cast to class java.lang.Integer ... Object o = "10"; Integer i = (Integer) o; 実際の型と違う型にキャストした instanceof で型を確かめる。文字列→数値は Integer.parseInt
NumberFormatException For input string: "12a" Integer.parseInt("12a"); 数値にできない文字列を変換した メッセージの文字列を見て、空白・単位・全角を取り除く
ArithmeticException / by zero int x = 10, y = 0; int z = x / y; 整数を 0 で割った 割る前に 0 かどうかを確かめる
ConcurrentModificationException メッセージなし(null) 拡張 for 文の中で list.remove(s) ループ中に同じ List を変更した removeIf や Iterator の remove() を使う
UnsupportedOperationException メッセージなし(null) List.of("a").add("b"); 変更できない List に追加・削除した new ArrayList<>(List.of(...)) で変更できる List を作る
NoSuchFileException config/app.properties Files.readString(Path.of("config/app.properties")); ファイルがない、または相対パスの起点が違う メッセージのパスと、実行時のカレントディレクトリを確認する
ClassNotFoundException com.example.shop.Missing Class.forName("com.example.shop.Missing"); クラスパスにそのクラスがない クラス名のつづりと、jar がクラスパスに入っているかを確認する

補足として、メソッドが自分自身を呼び続けると java.lang.StackOverflowError になります(メッセージなし)。この場合は同じ行が何百行も繰り返されるので、繰り返しの単位を1つ見つけて、再帰の終了条件を確認します。

メッセージが null の例外は、例外のクラス名そのものが手がかりです。ConcurrentModificationException なら「ループ中の変更」、UnsupportedOperationException なら「変更できないコレクション」と覚えておくと、トレースの自分の行を開いたときにすぐ気づけます。

Helpful NullPointerExceptionのメッセージを読む

Java の NullPointerException は、「何が null だったか」をメッセージで教えてくれます。この機能は Helpful NullPointerExceptions と呼ばれ、今回 OpenJDK の JEP 358 のページを開いて確認したところ、JDK 14 で追加され、その時点では既定で無効でした(-XX:+ShowCodeDetailsInExceptionMessages を付けると有効)。一般に JDK 15 以降は既定で有効になったと知られており、今回使った Java 21.0.10 では何も指定せずにメッセージが表示されました。

7つのパターンで実行した結果

// src/com/example/shop/NpeDemo.java(抜粋)
record Address(String city) {}
record Member(String name, Address address) {}

static Member find(int id) { return id == 1 ? new Member("sato", null) : null; }
コード 実際のメッセージ 読み方
find(1).address().city().length() Cannot invoke "...NpeDemo$Address.city()" because the return value of "...NpeDemo$Member.address()" is null address() の戻り値が null。つながったメソッド呼び出しのどこが null かがわかる
find(2).name() Cannot invoke "...NpeDemo$Member.name()" because the return value of "...NpeDemo.find(int)" is null find(2) が null を返した
String s = null; s.length() Cannot invoke "String.length()" because "s" is null ローカル変数 s が null
int[] arr = null; arr[0] Cannot load from int array because "arr" is null 配列そのものが null
String[][] grid = new String[2][]; grid[1][0] Cannot load from object array because "grid[1]" is null 2次元配列の内側の配列が null
int n = stock.get("pen");(Map に pen がない) Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null Map にキーがなく null が返り、int への自動変換で止まった
synchronized (lock) { ... }(lock が null) Cannot enter synchronized block because "lock" is null ロックに使う変数が null

(表の ... はパッケージ名 com.example.shop. を省略したものです。)

メッセージは 「Cannot + できなかった操作」 because 「null だったもの」 is null の形です。読むのは because の後ろです。

because の後ろ null だったもの
"s" ローカル変数 s
"article.title" 変数 article のフィールド title
"grid[1]" 配列 grid の1番目の要素
the return value of "...address()" メソッド address() の戻り値

変数名が local1 のように表示されるとき

同じコードを、-g を付けずにコンパイルした場合と付けた場合で比べました。

(javac T.java でコンパイル)
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null

(javac -g T.java でコンパイル)
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null

<local1> は「1番目のローカル変数」という意味で、クラスファイルに変数名の情報が入っていないときの表示です。javac -g でデバッグ情報を付けると変数名で表示されます。JEP 358 のページにも、javac -g でデバッグ情報が含まれる場合にローカル変数名が表示されると書かれています。フィールド名やメソッド名は -g なしでも表示されます。

また、-XX:-ShowCodeDetailsInExceptionMessages を付けて同じプログラムを動かすと、メッセージは 7パターンとも null になりました。メッセージが出ない環境では、起動オプションにこの指定が入っていないかを確認するとよいでしょう。

トレースを読んだあとの調べ方チェックリスト

順番 確認すること 見る場所
1 根本原因の例外名とメッセージ 一番下の Caused by(なければ1行目)
2 自分のコードの場所 根本原因ブロックで最初に出る自分のパッケージの行
3 メッセージに出ている値 "3個"、Index 3 ... length 3、because "s" is null など
4 その値がどこから来たか 自分の行から下(呼び出し元)へ順にたどる
5 いつ起きたか トレースの直前のログ(リクエストのURL、入力値、時刻)
6 同じ例外が他にも出ていないか ログ全体を例外名で検索する

人に相談するときは、トレースの全文をテキストのまま渡すのがおすすめです。画面の写真や先頭の数行だけでは、一番下の Caused by が切れてしまうことがあります。

原因を見つけやすいコードの書き方

書き方 トレースがどうなるか
例外を包み直すとき、元の例外を第2引数に渡す Caused by で根本原因までたどれる
メッセージに、対象のIDや入力値を入れる 「どのデータで起きたか」がトレースだけでわかる
ロガーには例外オブジェクトごと渡す(例:logger.error("注文の表示で例外", e)) スタックトレース全体がログに残る
e.getMessage() だけを出力する 例外名も行番号も残らない。メッセージが null の例外では何も読み取れなくなる
catch して何もしない トレースが出ず、後ろの処理で別の例外として現れる

よくある質問

Q. スタックトレースは上から読むのですか、下から読むのですか?

A. 原因を探すときは「一番下の Caused by を見て、その直下を上から読む」のが近道です。1つのブロックの中では、上の行ほど例外に近く、下の行ほど呼び出し元です。処理の流れを追いたいときは、ブロックを下から上へ読みます。

Q. Caused by が1つもないときは、どこを見ればよいですか?

A. 1行目の例外名とメッセージが根本原因です。その下の at 行を上から見て、自分のパッケージ名が最初に出てくる行を開きます。Tomcat の「with root cause」のログも同じ読み方です。

Q. 自分のパッケージ名の行が1つもないときは?

A. 設定ファイルの値やアノテーションの書き方が原因で、フレームワークの中だけで例外が起きている場合があります。一番下の Caused by のメッセージに、設定のキー名・Bean の名前・クラス名が含まれていないかを探してください。Spring の例では、メッセージに 'diApp.StockClient' のように Bean 名が入っていました。

Q. 行番号が (Unknown Source) と表示されます。

A. クラスファイルに行番号の情報が含まれていないときの表示として知られています(今回は再現していません)。自分のコードであれば、デバッグ情報を付けてコンパイルする設定になっているかをビルドの設定で確認してください。

まとめ

  • スタックトレースは「例外が起きた瞬間の呼び出し履歴」。上の行ほど新しく、下の行ほど呼び出し元
  • 読む順番は「一番下の Caused by → その直下で自分のパッケージ名が付いた最初の行」
  • 「... n more」は上のブロックの末尾 n 行と同じという省略記号で、情報は欠けていない
  • Suppressed は close() などで追加で起きた例外。主役は外側の例外
  • Tomcat は31行中5行、Spring は116行中2行が自分の行だった。grep で Caused by と自分のパッケージ名を拾えば、長さに関係なく同じ手順で読める
  • NullPointerException は because の後ろに null だったものが書かれている。javac -g で変数名まで表示される
  • 例外を包み直すときは元の例外を第2引数に渡すと、あとから根本原因までたどれる

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

(記載例)出典:株式会社RJC「Javaスタックトレースの読み方|Caused byの意味と原因の探し方」

株式会社RJCでは、スタックトレースの読み方や例外の調べ方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?