toStringとは、オブジェクトを人が読める文字列に変換するメソッドで、オーバーライドしないと「クラス名@ハッシュ値」という表示になります。 System.out.println(obj) や文字列連結 "" + obj、ログ出力のたびに自動で呼ばれるため、自分のクラスで toString() を上書きしておくと、デバッグやログの読みやすさが大きく変わります。

この記事では、オーバーライドなしの Item@64c87930、配列の [I@2aece37d という表示を実際に再現したうえで、Arrays.toString / Arrays.deepToString、List・Map の表示、Objects.toString(obj, 既定値) と String.valueOf の null 時の違いを、すべて実行結果つきでまとめます。record・Lombok・IDE 自動生成・手書きの比較、循環参照による StackOverflowError、個人情報をログに出さない書き方まで、1ページで確認できます。コードは OpenJDK 21.0.10 で java ファイル名.java として実行した結果です(Lombok と IDE 生成の部分は未実行で、その旨を各所に明記しています)。

toStringと配列・List表示の早見表

やりたいこと 書き方 表示(実行結果)
自作クラスをそのまま表示(オーバーライドなし) System.out.println(item) Item@64c87930
自作クラスを読める形で表示 toString() をオーバーライド Item{name='pen', price=120}
record を表示 record Item(String name, int price) {} Item[name=pen, price=120]
int 配列をそのまま表示 System.out.println(nums) [I@2aece37d
配列の中身を表示 Arrays.toString(nums) [1, 2, 3]
2次元配列の中身を表示 Arrays.deepToString(grid) [[1, 2], [3, 4]]
2次元配列に Arrays.toString Arrays.toString(grid) [[I@17c386de, [I@5af97850]
配列を区切り文字つきで表示 String.join(" / ", names) pen / note
List を表示 System.out.println(list) [1, 2, 3]
Map を表示 System.out.println(map) {pen=120, note=300}
null でも例外にしない Objects.toString(obj) null(4文字の文字列)
null のとき既定値にする Objects.toString(obj, "(未設定)") (未設定)
null のまま obj.toString() obj.toString() NullPointerException

迷ったときの目安は3つです。 自作クラスは toString() をオーバーライドする(または record にする)、配列は Arrays.toString / Arrays.deepToString を通す、null の可能性があるなら Objects.toString(obj, 既定値) を使う、です。

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

(記載例)出典:株式会社RJC「Java toStringのオーバーライドと配列・Listの表示方法まとめ」

toStringをオーバーライドしないとどう表示されるか

まず、toString() を書いていないクラスを表示してみます。次のコードは、そのまま NoOverride.java として保存して java NoOverride.java で実行できます。

public class NoOverride {
    public static void main(String[] args) {
        Item item = new Item("pen", 120);

        System.out.println(item);                 // println が内部で toString() を呼ぶ
        System.out.println(item.toString());      // 明示的に呼んでも同じ
        System.out.println("item=" + item);       // 文字列連結でも呼ばれる

        // Object.toString() が組み立てている中身
        String same = item.getClass().getName() + "@" + Integer.toHexString(item.hashCode());
        System.out.println(same);
    }
}

class Item {
    String name;
    int price;

    Item(String name, int price) {
        this.name = name;
        this.price = price;
    }
}

実行結果(OpenJDK 21.0.10):

Item@64c87930
Item@64c87930
item=Item@64c87930
Item@64c87930

pen も 120 も表示されず、クラス名@16進数 だけが出ます。 これはすべてのクラスの親である Object クラスの toString() がそのまま使われているためです。Java SE 21 の API ドキュメント(Object.toString)には、この既定の実装が次の式と等しいと書かれています。

getClass().getName() + '@' + Integer.toHexString(hashCode())

上のコードの4行目の出力が1〜3行目と一致しているのは、この式を自分で組み立てたからです。

部分 意味
Item getClass().getName() の結果。パッケージがあれば com.example.Item のように完全修飾名になる
@ 区切りの記号
64c87930 hashCode() の値を16進数にしたもの。メモリアドレスそのものではなく、実行環境や実行のたびに変わることがある

同じ API ドキュメントには「すべてのサブクラスでこのメソッドをオーバーライドすることを推奨する」とあり、読みやすく情報のある文字列を返すように上書きするのが基本です。

toStringが自動で呼ばれる場面

toString() は自分で呼ばなくても、次の場面で自動的に使われます。だからこそ、上書きしておく効果が大きくなります。

場面 コード例 補足
標準出力 System.out.println(item) 内部で String.valueOf(item) が呼ばれる
文字列連結 "item=" + item null のときは "null" という文字列になる
書式指定 String.format("%s", item) %s に渡したとき
コレクションの表示 System.out.println(list) 各要素の toString() が呼ばれる
ログ出力 logger.info("item={}", item) ロガーが文字列化するときに呼ぶ(ロガーの例は未実行)
デバッガ IDE の変数表示 多くの IDE が toString() の結果を表示に使う

toStringをオーバーライドする基本の書き方

toString() を上書きするには、public String toString() というシグネチャで、フィールドの値を並べた文字列を返すだけです。@Override を付けておくと、名前の打ち間違いをコンパイラが教えてくれます。

public class OverrideBasic {
    public static void main(String[] args) {
        Item item = new Item("pen", 120);
        System.out.println(item);
        System.out.println("item=" + item);
        System.out.println(String.format("[%s]", item));
    }
}

class Item {
    private final String name;
    private final int price;

    Item(String name, int price) {
        this.name = name;
        this.price = price;
    }

    @Override
    public String toString() {
        return "Item{name='" + name + "', price=" + price + "}";
    }
}

実行結果:

Item{name='pen', price=120}
item=Item{name='pen', price=120}
[Item{name='pen', price=120}]

オーバーライドの決まりごと

決まり 守らないとどうなるか
メソッド名は toString(S が大文字) tostring と書くと別メソッドになり、表示は Item@... のまま
アクセス修飾子は public コンパイルエラーになる
戻り値は String、引数はなし 引数を付けると別メソッド(オーバーロード)になる
@Override を付ける 付けなくても動くが、打ち間違いに気づけなくなる
null を返さない 呼び出し側で NullPointerException につながる

@Override の効果を確かめるため、わざと tostring と書いてコンパイルしてみました。

class Item {
    @Override
    public String tostring() {
        return "Item";
    }
}
Typo.java:7: error: method does not override or implement a method from a supertype
    @Override
    ^
1 error

@Override があるおかげで、実行する前に気づけます。 public を外した場合は次のエラーになりました。

Weaker.java:8: error: toString() in Item cannot override toString() in Object
    String toString() {
           ^
  attempting to assign weaker access privileges; was public
1 error

親の Object.toString() が public なので、それより狭い公開範囲にはできない、という意味です。

手書きの5パターン(連結・format・StringJoiner・StringBuilder・super)

手書きにもいくつかの書き方があります。次のコードは5つのパターンを1ファイルにまとめたものです。

import java.util.StringJoiner;

public class HandWritten {
    public static void main(String[] args) {
        System.out.println(new A("pen", 120));
        System.out.println(new B("pen", 120));
        System.out.println(new C("pen", 120));
        System.out.println(new D("pen", 120));
        System.out.println(new Child());
    }
}

// 1. + 演算子で連結
class A {
    String name; int price;
    A(String name, int price) { this.name = name; this.price = price; }
    @Override public String toString() {
        return "A{name='" + name + "', price=" + price + "}";
    }
}

// 2. String.format
class B {
    String name; int price;
    B(String name, int price) { this.name = name; this.price = price; }
    @Override public String toString() {
        return String.format("B{name='%s', price=%d}", name, price);
    }
}

// 3. StringJoiner(区切り・接頭辞・接尾辞をまとめて指定)
class C {
    String name; int price;
    C(String name, int price) { this.name = name; this.price = price; }
    @Override public String toString() {
        return new StringJoiner(", ", C.class.getSimpleName() + "[", "]")
                .add("name='" + name + "'")
                .add("price=" + price)
                .toString();
    }
}

// 4. StringBuilder(項目が多い・条件で出し分けたいとき)
class D {
    String name; int price;
    D(String name, int price) { this.name = name; this.price = price; }
    @Override public String toString() {
        StringBuilder sb = new StringBuilder("D{");
        sb.append("name='").append(name).append('\'');
        if (price > 0) {
            sb.append(", price=").append(price);
        }
        return sb.append('}').toString();
    }
}

// 5. 継承しているときは super.toString() で親の項目も含める
class Base {
    int id = 1;
    @Override public String toString() { return "Base{id=" + id + "}"; }
}

class Child extends Base {
    String memo = "x";
    @Override public String toString() {
        return "Child{memo='" + memo + "', super=" + super.toString() + "}";
    }
}

実行結果:

A{name='pen', price=120}
B{name='pen', price=120}
C[name='pen', price=120]
D{name='pen', price=120}
Child{memo='x', super=Base{id=1}}
書き方 向いている場面 気をつける点
+ で連結 項目が少ないクラス。もっとも読みやすい 項目が増えると引用符の対応を見失いやすい
String.format 桁そろえや小数点以下の桁数を指定したい 書式指定子と引数の型・数を合わせる
StringJoiner 区切りと前後の括弧を一括で指定したい 各項目は自分で "名前=" + 値 の形にする
StringBuilder 条件によって項目を出し分けたい コードが長くなる
super.toString() 親クラスの項目も含めたい 親が上書きしていないと クラス名@ハッシュ が混ざる

書式に決まりはありませんが、チーム内で形をそろえておくと、ログを検索するときに便利です。 よく見かける形は クラス名{項目=値, 項目=値}(波括弧)と クラス名[項目=値, 項目=値](角括弧、record と同じ形)の2つです。

配列を表示する方法([I@...と表示される理由)

配列を println にそのまま渡すと、[I@2aece37d のような文字列になります。配列も Object の一種で、toString() が上書きされていないため、先ほどの「クラス名@ハッシュ」と同じ仕組みで表示されています。

import java.util.Arrays;

public class ArrayPrint {
    public static void main(String[] args) {
        int[] nums = {1, 2, 3};
        String[] names = {"pen", "note"};
        int[][] grid = {{1, 2}, {3, 4}};
        char[] chars = {'a', 'b'};

        // そのまま表示すると「型の記号@ハッシュ」
        System.out.println(nums);
        System.out.println(names);
        System.out.println(grid);

        // Arrays.toString で中身を表示
        System.out.println(Arrays.toString(nums));
        System.out.println(Arrays.toString(names));

        // 2次元配列は deepToString
        System.out.println(Arrays.toString(grid));
        System.out.println(Arrays.deepToString(grid));

        // char[] だけは println に専用の受け口がある
        System.out.println(chars);
        System.out.println("chars=" + chars);

        // 区切り文字を変えたいとき
        System.out.println(String.join(" / ", names));

        // null の配列を渡しても例外にならない
        int[] none = null;
        System.out.println(Arrays.toString(none));
    }
}

実行結果:

[I@2aece37d
[Ljava.lang.String;@548a102f
[[I@5762806e
[1, 2, 3]
[pen, note]
[[I@17c386de, [I@5af97850]
[[1, 2], [3, 4]]
ab
chars=[C@5ef60048
pen / note
null

[I や [Ljava.lang.String; の読み方

@ の前は、配列のクラス名です。[ が「配列」を表し、その後ろの文字が要素の型を表します。次の表の「表示」は、各型の空配列を実際に println した結果です(@ 以降は実行ごとに変わることがあります)。

配列の型 表示(実行結果) 記号の意味
int[] [I@6b67034 I = int
long[] [J@16267862 J = long(L は参照型で使うため)
double[] [D@453da22c D = double
boolean[] [Z@71248c21 Z = boolean(B は byte で使うため)
byte[] [B@442675e1 B = byte
char[] [C@6166e06f C = char
String[] [Ljava.lang.String;@49e202ad L完全修飾クラス名; = 参照型
int[][] [[I@1c72da34 [ が2つ = 2次元

配列表示の使い分け表

配列の種類 使うメソッド 結果
1次元(int[]、String[] など) Arrays.toString(arr) [1, 2, 3]
2次元以上(int[][] など) Arrays.deepToString(arr) [[1, 2], [3, 4]]
自作クラスの配列 Arrays.toString(arr)(要素の toString() が使われる) 要素側をオーバーライドしておく
区切りを変えたい String[] String.join(区切り, arr) pen / note
char[] を文字列として new String(chars) または String.valueOf(chars) ab

2次元配列に Arrays.toString を使うと、内側の配列が [I@17c386de のままになる点が、つまずきやすい箇所です。内側も配列なので、入れ子をたどってくれる Arrays.deepToString を選びます。

もう1つ、char[] は少し特別です。System.out.println(chars) は ab と表示されますが、これは println(char[]) という専用のオーバーロードがあるためです。"chars=" + chars のように文字列連結にすると、他の配列と同じ [C@5ef60048 になります。

List・Mapを表示する方法

List や Map は、配列と違って toString() が最初から上書きされているので、println にそのまま渡せば中身が表示されます。ただし、要素の表示には「要素のクラスの toString()」が使われます。

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

public class CollectionPrint {
    public static void main(String[] args) {
        // 要素が String や Integer なら、そのまま読める
        List<Integer> nums = List.of(1, 2, 3);
        System.out.println(nums);

        Map<String, Integer> stock = new LinkedHashMap<>();
        stock.put("pen", 120);
        stock.put("note", 300);
        System.out.println(stock);

        // 要素が自作クラスのときは、そのクラスの toString() が使われる
        System.out.println(List.of(new Plain("pen")));
        System.out.println(List.of(new Named("pen"), new Named("note")));

        // List の中に配列を入れると、配列部分は記号のまま
        List<int[]> arrays = new ArrayList<>();
        arrays.add(new int[]{1, 2});
        System.out.println(arrays);

        // 1行ずつ・区切り文字つきで表示
        List<Named> items = List.of(new Named("pen"), new Named("note"));
        items.forEach(System.out::println);
        System.out.println(items.stream().map(Named::toString).collect(Collectors.joining(" | ")));

        // null 要素、Optional、自分自身を含む List
        List<Object> mixed = new ArrayList<>();
        mixed.add(null);
        mixed.add(Optional.of("x"));
        mixed.add(Optional.empty());
        mixed.add(mixed);
        System.out.println(mixed);
    }
}

class Plain {
    String name;
    Plain(String name) { this.name = name; }
}

class Named {
    String name;
    Named(String name) { this.name = name; }

    @Override
    public String toString() {
        return "Named{name='" + name + "'}";
    }
}

実行結果:

[1, 2, 3]
{pen=120, note=300}
[Plain@4d9e68d0]
[Named{name='pen'}, Named{name='note'}]
[[I@6dc17b83]
Named{name='pen'}
Named{name='note'}
Named{name='pen'} | Named{name='note'}
[null, Optional[x], Optional.empty, (this Collection)]
表示したいもの 書き方 表示の形
List / Set 全体 System.out.println(list) [要素, 要素]
Map 全体 System.out.println(map) {キー=値, キー=値}
1要素ずつ改行して list.forEach(System.out::println) 1行に1要素
区切り文字を指定して stream().map(...).collect(Collectors.joining(" / ")) 好きな区切り
List<String> を区切って String.join(", ", list) 括弧なし
List<int[]> の中身 要素ごとに Arrays.toString を通す そのままだと [[I@6dc17b83]

[Plain@4d9e68d0] のように List の中に「@ハッシュ」が見えたら、要素のクラスで toString() をオーバーライドする合図です。 List 側のコードを変える必要はなく、要素のクラスに1メソッド足すだけで、List・Map・配列(Arrays.toString)のすべての表示がまとめて読みやすくなります。

最後の行の (this Collection) は、List が自分自身を要素に持っているときの表示です。標準のコレクションは、自分自身に限っては無限ループにならないように作られています。一方、2つの自作クラスが互いを参照している場合は守られないので、後述の循環参照の節で扱います。

null対策:Objects.toStringとString.valueOfの違い

変数が null かもしれないとき、obj.toString() を直接呼ぶと NullPointerException になります。null でも安全に文字列化する方法が Objects.toString と String.valueOf です。違いを1つのプログラムで確かめます。

import java.util.Objects;

public class NullSafe {
    public static void main(String[] args) {
        Object obj = null;

        System.out.println("1: " + Objects.toString(obj));
        System.out.println("2: " + Objects.toString(obj, "(未設定)"));
        System.out.println("3: " + String.valueOf(obj));
        System.out.println("4: " + ("" + obj));
        System.out.println("5: " + String.valueOf(obj).length());
        System.out.println("6: " + Objects.toString(obj, null));
        System.out.println("7: " + Objects.requireNonNullElse(obj, "(未設定)"));
        System.out.println("8: " + String.format("%s", obj));

        try {
            System.out.println(obj.toString());
        } catch (NullPointerException e) {
            System.out.println("9: " + e);
        }

        try {
            char[] chars = null;
            System.out.println(String.valueOf(chars));
        } catch (NullPointerException e) {
            System.out.println("10: " + e);
        }

        // null でないときは、どれも同じ結果
        Integer n = 42;
        System.out.println("11: " + n.toString() + " " + Objects.toString(n, "(未設定)") + " " + String.valueOf(n));
    }
}

実行結果:

1: null
2: (未設定)
3: null
4: null
5: 4
6: null
7: (未設定)
8: null
9: java.lang.NullPointerException: Cannot invoke "Object.toString()" because "<local1>" is null
10: java.lang.NullPointerException: Cannot read the array length because "value" is null
11: 42 42 42

9行目の <local1> は変数名の代わりに出ている表示です。java ファイル名.java の単一ファイル実行ではデバッグ情報なしでコンパイルされるためで、javac -g でコンパイルすると変数名が表示されます。

null時の挙動 早見表

書き方 obj が null のとき null でないとき 使いどころ
obj.toString() NullPointerException toString() の結果 null でないと保証できる場所
String.valueOf(obj) 文字列の "null"(長さ4) toString() の結果 とにかく例外にしたくないとき
Objects.toString(obj) 文字列の "null" toString() の結果 String.valueOf と同じ結果
Objects.toString(obj, "既定値") "既定値" toString() の結果 画面や帳票に null と出したくないとき
Objects.toString(obj, null) null(文字列ではない) toString() の結果 null のままにしたいとき
"" + obj 文字列の "null" toString() の結果 手軽だが意図が伝わりにくい
String.format("%s", obj) 文字列の "null" toString() の結果 書式の中に埋め込むとき
Objects.requireNonNullElse(obj, 既定値) 既定値(型はそのまま) obj 自身 文字列以外の型でも使える(Java 9 以降)

Java SE 21 の API ドキュメント(Objects)でも、toString(Object o) は「null でなければ toString の結果、null なら "null"」、toString(Object o, String nullDefault) は「null でなければ toString の結果、null なら第2引数」を返すと説明されています。

押さえておきたい点は2つです。

  • String.valueOf(obj) と Objects.toString(obj) が返すのは、null ではなく "null" という4文字の文字列です。5行目で長さが 4 と出ているのがその証拠で、そのまま画面に出すと「null」と表示されます。表示用には Objects.toString(obj, "(未設定)") のように既定値を渡す形が向いています。
  • String.valueOf には char[] を受け取るオーバーロードがあり、char[] 型の null 変数を渡すと NullPointerException(10行目)になります。同じ理由で、String.valueOf(null) と null を直接書いた場合も char[] 版が選ばれます。Object 型の変数経由なら "null" が返ります。

record・Lombok・IDE自動生成・手書きの比較

toString() を用意する方法は、手書きのほかにもあります。それぞれの特徴を整理します。

方法 表示の形 フィールド追加時 追加の依存 今回の確認
手書き 自由 自分で書き足す なし 実行済み
record(Java 16 以降) Item[name=pen, price=120] 自動で反映 なし 実行済み
IDE の自動生成 IntelliJ IDEA の既定は Item{name='pen', price=120}、Eclipse の既定は Item [name=pen, price=120] 生成し直す なし 未実行(一般に知られた既定の形)
Lombok @ToString Item(name=pen, price=120) 自動で反映 Lombok 未実行(一般に知られた仕様)

recordの自動toString

record は、データを運ぶためのクラスを短く書ける仕組みで、toString()・equals()・hashCode() が自動で用意されます。

import java.util.Arrays;
import java.util.List;

public class RecordDemo {
    record Item(String name, int price) {}

    record Order(int id, List<Item> items) {}

    record Scores(String name, int[] points) {}

    record ScoresFixed(String name, int[] points) {
        @Override
        public String toString() {
            return "ScoresFixed[name=" + name + ", points=" + Arrays.toString(points) + "]";
        }
    }

    public static void main(String[] args) {
        System.out.println(new Item("pen", 120));
        System.out.println(new Order(1, List.of(new Item("pen", 120), new Item("note", 300))));
        System.out.println(new Item(null, 0));
        System.out.println(new Scores("sato", new int[]{80, 95}));
        System.out.println(new ScoresFixed("sato", new int[]{80, 95}));
    }
}

実行結果:

Item[name=pen, price=120]
Order[id=1, items=[Item[name=pen, price=120], Item[name=note, price=300]]]
Item[name=null, price=0]
Scores[name=sato, points=[I@1ffe63b9]
ScoresFixed[name=sato, points=[80, 95]]

record は1行の宣言だけで読みやすい表示になり、入れ子の record や List もそのまま展開されます。 一方で、コンポーネントに配列があると points=[I@1ffe63b9 と記号のままになります。その場合は ScoresFixed のように record の中で toString() を上書きするか、配列の代わりに List<Integer> を使うと中身が表示されます。

Lombokの@ToString(未実行)

Lombok は、アノテーションを付けるとコンパイル時にメソッドを生成してくれるライブラリです。今回の検証環境ではライブラリを取得できなかったため、以下のコードは未実行で、一般に知られた使い方として紹介します。

import lombok.ToString;

@ToString
public class Item {
    private String name;
    private int price;

    @ToString.Exclude          // この項目は出力に含めない
    private String secretMemo;
}

一般に知られた仕様では、出力は Item(name=pen, price=120) の形になり、@ToString.Exclude を付けたフィールドは含まれません。親クラスの項目も含めたいときは @ToString(callSuper = true) を指定します。@Data を付けたクラスにも @ToString 相当が含まれるため、後述の循環参照と個人情報の注意点は Lombok を使う場合にもそのまま当てはまります。

IDEの自動生成(未実行)

IDE には、フィールドを選ぶだけで toString() を書いてくれる機能があります。今回は IDE を起動していないため手順は未実行ですが、一般に知られた操作は次のとおりです。

IDE 操作
IntelliJ IDEA エディタ上で Generate(Windows は Alt + Insert、macOS は Command + N)→ toString() を選び、含めるフィールドにチェック
Eclipse メニューの「ソース」→「toString() の生成」→ 含めるフィールドにチェック
VS Code(Java 拡張) 右クリック →「ソース アクション」→「Generate toString()」

自動生成したコードは、その時点のフィールドだけが対象です。 あとでフィールドを追加したら、生成し直すか手で書き足します。

どれを選ぶかの判断表

状況 選び方
値を運ぶだけの不変なクラスを新しく作る record
既存の通常クラスで、依存を増やしたくない IDE 自動生成 → 必要に応じて手で調整
すでに Lombok を使っているコードベース @ToString(出さない項目に @ToString.Exclude)
出す項目を厳密に選びたい、伏せ字にしたい 手書き
条件によって出力を変えたい 手書き(StringBuilder)

循環参照でStackOverflowErrorになる例と直し方

2つのクラスが互いを参照していて、どちらの toString() も相手を丸ごと出力していると、呼び出しが終わらなくなります。再現してみます。

import java.util.ArrayList;
import java.util.List;

public class Circular {
    public static void main(String[] args) {
        Dept dept = new Dept("dev");
        dept.members.add(new Member("sato", dept));

        try {
            System.out.println(dept);
        } catch (StackOverflowError e) {
            System.out.println("caught: " + e);
            StackTraceElement[] st = e.getStackTrace();
            for (int i = 0; i < 6; i++) {
                System.out.println("  at " + st[i]);
            }
            System.out.println("  ...");
        }
    }
}

class Dept {
    String name;
    List<Member> members = new ArrayList<>();
    Dept(String name) { this.name = name; }

    @Override public String toString() {
        return "Dept{name='" + name + "', members=" + members + "}";
    }
}

class Member {
    String name;
    Dept dept;
    Member(String name, Dept dept) { this.name = name; this.dept = dept; }

    @Override public String toString() {
        return "Member{name='" + name + "', dept=" + dept + "}";   // Dept → Member → Dept → ...
    }
}

実行結果:

caught: java.lang.StackOverflowError
  at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:451)
  at java.base/java.lang.String.valueOf(String.java:4454)
  at Dept.toString(Circular.java:28)
  at java.base/java.lang.String.valueOf(String.java:4454)
  at Member.toString(Circular.java:38)
  at java.base/java.lang.String.valueOf(String.java:4454)
  ...

スタックトレースに Dept.toString と Member.toString が交互に並んでいるのが、循環参照の目印です。 Dept が members を出力 → Member が dept を出力 → Dept が members を出力…と続き、呼び出しの深さが上限に達して StackOverflowError になります。

直し方は、どちらか一方(多くの場合は「子」側)で、相手のオブジェクト全体ではなく ID や名前だけを出すことです。

class Member {
    String name;
    Dept dept;
    Member(String name, Dept dept) { this.name = name; this.dept = dept; }

    @Override public String toString() {
        // 親側はオブジェクト全体ではなく、名前(またはID)だけを出す
        return "Member{name='" + name + "', dept=" + (dept == null ? null : dept.name) + "}";
    }
}

Member をこの形に差し替えて System.out.println(dept) と System.out.println(dept.members.get(0)) を実行した結果:

Dept{name='dev', members=[Member{name='sato', dept=dev}]}
Member{name='sato', dept=dev}
起きやすい構造 対処
親子が互いを持つ(部署と社員、注文と明細など) 子の toString() では親の ID だけを出す
JPA などで双方向の関連を持つエンティティ 関連フィールドを toString() から外す
Lombok の @ToString / @Data を双方に付けている 片方の関連フィールドに @ToString.Exclude(未実行・一般に知られた対処)
木構造のノードが親への参照を持つ 親は出さず、子だけをたどる

個人情報をログに出さないtoStringの書き方

toString() はログ出力や例外メッセージで自動的に呼ばれるため、全フィールドを機械的に並べると、パスワードやメールアドレスがログファイルに残ることがあります。出力してよい項目だけを選ぶ、という視点で書くと安心です。

public class MaskDemo {
    public static void main(String[] args) {
        User user = new User(101, "Sato Taro", "sato@example.com", "p@ss1234");
        System.out.println(user);
        System.out.println("login: " + user);
    }
}

class User {
    private final long id;
    private final String name;
    private final String email;
    private final String password;

    User(long id, String name, String email, String password) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.password = password;
    }

    @Override
    public String toString() {
        // id と、伏せ字にしたメールだけを出す。氏名とパスワードは含めない
        return "User{id=" + id + ", email=" + mask(email) + "}";
    }

    private static String mask(String s) {
        if (s == null) return null;
        int at = s.indexOf('@');
        return at <= 1 ? "***" : s.charAt(0) + "***" + s.substring(at);
    }
}

実行結果:

User{id=101, email=s***@example.com}
login: User{id=101, email=s***@example.com}

toStringに含める項目のチェックリスト

項目の種類 扱い方
ID、状態、件数、日時など調査に役立つ値 そのまま出す
パスワード、トークン、API キー、秘密の質問の答え 出さない
氏名、住所、電話番号、メールアドレス 出さないか、伏せ字にする
大きな本文、バイナリ、長い List 件数や長さだけ出す(例:items=3件)
相互に参照している関連オブジェクト ID だけ出す

record・Lombok・IDE 自動生成は「全フィールドを出す」のが既定の動きです。秘密にしたい項目を持つクラスでは、record なら toString() を上書きし、Lombok なら @ToString.Exclude を付け、IDE 生成ならチェックを外す、というひと手間を加えます。

つまずきやすいポイントと直し方

症状 理由 直し方
Item@64c87930 と表示される toString() を上書きしていない toString() をオーバーライド、または record にする
[I@2aece37d と表示される 配列をそのまま表示している Arrays.toString(arr)
[[I@17c386de, [I@5af97850] と表示される 2次元配列に Arrays.toString を使っている Arrays.deepToString(arr)
List の中に Plain@4d9e68d0 が見える 要素のクラスが上書きしていない 要素のクラスに toString() を追加
上書きしたのに反映されない tostring など名前や引数が違う @Override を付けてコンパイルエラーで確認
画面に null と出る String.valueOf や連結で "null" になった Objects.toString(obj, "既定値")
NullPointerException null の変数に .toString() を呼んだ Objects.toString(obj) に置き換える
StackOverflowError 相互参照するクラスが互いを出力 片方は ID だけを出す
enum の表示を変えたい 既定では定数名がそのまま出る enum 側で toString() を上書き(name() は変わらない)

enum については、enum Status { OPEN, DONE } を表示すると OPEN、toString() を name().toLowerCase() で上書きした enum では high、その name() は HIGH のまま、という結果を確認しました。表示用の文字列は toString()、保存や比較に使う名前は name() と使い分けると混乱しません。

また、toString() を上書きしたクラスで元の「クラス名@ハッシュ」形式が欲しいときは、Java 19 以降の Objects.toIdentityString(obj) が使えます。Tag(x) と表示されるオブジェクトに対して Tag@769f71a9 が得られました。

よくある質問

Q. toString()の出力形式に決まりはありますか? A. 言語としての決まりはありません。Java SE 21 の API ドキュメントでは「簡潔で、人が読みやすく、情報のある表現」が望ましいとされています。クラス名{項目=値, ...} や record と同じ クラス名[項目=値, ...] の形にそろえておくと、ログを検索しやすくなります。なお、同じドキュメントに「出力は時間の経過や JVM の起動をまたいで安定しているとは限らない」とあるため、toString() の結果を解析してデータを取り出す使い方は避け、値は getter から取得します。

Q. @の後ろの数字はメモリアドレスですか? A. hashCode() の値を16進数にしたものです。hashCode() を上書きしていないクラスでは、オブジェクトごとに割り当てられた値が使われますが、アドレスそのものを表しているわけではなく、実行のたびに変わることがあります。同じオブジェクトかどうかの確認には == を使います。

Q. toString()とString.valueOf()はどちらを使えばよいですか? A. 対象が null になり得ないなら obj.toString()、null の可能性があるなら String.valueOf(obj) や Objects.toString(obj) が向いています。null のときに "null" ではなく別の文字列を出したい場合は Objects.toString(obj, "既定値") を選びます。

Q. toString()をオーバーライドしたら、equals()やhashCode()も必要ですか? A. toString() は表示用なので、単独でオーバーライドしてかまいません。equals() と hashCode() は「等しさ」の判定に関わる別の話で、この2つはセットで上書きします。record を使うと3つとも自動で用意されます。

まとめ

  • オーバーライドしない toString() は クラス名@ハッシュ値(例:Item@64c87930)を返す。中身は getClass().getName() + "@" + Integer.toHexString(hashCode())
  • public String toString() を @Override 付きで上書きすると、println・文字列連結・List の表示・ログがまとめて読みやすくなる
  • 配列は Arrays.toString、2次元以上は Arrays.deepToString。そのまま表示すると [I@... になる
  • List・Map はそのまま表示できるが、要素のクラスの toString() が使われるので、要素側を上書きする
  • null の可能性があるなら Objects.toString(obj, 既定値)。String.valueOf(obj) は "null" という文字列を返す
  • record は自動で Item[name=pen, price=120] 形式になる。配列コンポーネントだけは自分で上書きする
  • 相互参照は ID だけを出して StackOverflowError を防ぎ、パスワードや個人情報は toString() に含めない

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

(記載例)出典:株式会社RJC「Java toStringのオーバーライドと配列・Listの表示方法まとめ」

株式会社RJCでは、toStringのような基礎の仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

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