Java | Java 標準ライブラリ:文字列結合のパフォーマンス

Java Java
スポンサーリンク

なぜ「文字列結合のパフォーマンス」を気にする必要があるのか

Java では文字列を扱う機会がとても多いです。ログ、SQL の組み立て、メッセージ生成、CSV 出力など、気づけばどこも文字列だらけになります。

一見、"Hello " + name のようなコードは harmless(無害)に見えますが、使い方を間違えると「大量の文字列オブジェクトを無駄に生成し続ける」ことになり、パフォーマンスが落ちます。特にループの中で文字列を連結するときは要注意です。

ここでは、「どんな書き方がヤバいのか」「なぜ遅くなるのか」「どう書けばいいのか」を、初心者向けにじっくり整理していきます。


前提知識:String がイミュータブルだから起きること

String は「一度作ったら中身が変わらない」

String はイミュータブル(不変)です。一度 "hello" という String オブジェクトが作られたら、あとから中身を "HELLO" に書き換えることはできません。

例えば次のコードを考えます。

String s = "hello";
s = s + " world";
Java

ここで起きているのは、「s に新しい String を代入し直している」だけです。

最初は "hello" というオブジェクトを参照していたのに、
"hello" + " world" という新しいオブジェクトが作られ、
s はそっちを指すように変わります。

元の "hello" のオブジェクトは、その後どこからも参照されなくなれば GC の対象になります。

つまり、「文字列を足す」たびに、新しい String オブジェクトが生成され、古いものは捨てられていく、という動きになります。

この「毎回新しいオブジェクトを作る」というコストが、パフォーマンスの鍵になります。


危険なパターン:ループ中での「+=」連結

典型的な悪い例

次のようなコードは、初心者がかなりやりがちです。

String result = "";
for (int i = 0; i < 10000; i++) {
    result = result + i;
}
System.out.println(result);
Java

見た目はシンプルですが、中で何が起きているかをイメージしてみてください。

1回目のループでは
"" + 0"0" という新しい String インスタンスが作られます。

2回目では
"0" + 1"01" という新しい String が作られます。

3回目では
"01" + 2"012" が作られます。

こうしてループ回数ぶん、毎回「前の文字列+新しい文字」を合成した新しい String が作られ、前のオブジェクトは捨てられていきます。

ループ回数が 10000 回なら、「不要になっていく中間オブジェクト」が 10000 個近く生まれます。1 回 2 回なら気になりませんが、大量になるとメモリ確保・GC の負担が積み上がっていきます。


Java コンパイラがやってくれる最適化と、その限界

単純な「+」は裏で StringBuilder に変換される

Java コンパイラは、単純な文字列連結に対しては、次のような最適化をしてくれます。

String s = "Hello, " + name + "!";
Java

これはコンパイル時に、実際にはだいたい次のようなコードに変換されます。

String s = new StringBuilder()
        .append("Hello, ")
        .append(name)
        .append("!")
        .toString();
Java

つまり、1 行の中で完結しているような "A" + x + "B" 程度の連結なら、「自分で StringBuilder を書かなくても」コンパイラが StringBuilder を使う形にしてくれます。

このレベルであれば、パフォーマンスはほぼ気にしなくて構いません。読みやすさのためにも、素直に + を使ったほうが良いことが多いです。

ループをまたぐと、毎回 StringBuilder が new される

問題はループの中です。例えばこう書いた場合:

String result = "";
for (int i = 0; i < 10000; i++) {
    result = result + i;
}
Java

コンパイラはだいたい、ループの一回ごとに新しい StringBuilder を作るようなコードに変換します。

イメージとしては次のような動きです。

for (...) {
    result = new StringBuilder()
                 .append(result)
                 .append(i)
                 .toString();
}
Java

つまり「ループのたびに StringBuilder を new して、前の result をコピーして、最後に文字列に戻す」という動きを毎回繰り返します。

「コンパイラが StringBuilder に変えてくれるから安心」と思っていると、ここでやられます。
ループをまたいだ連結は、自分で StringBuilder を外に出してやらないと、最適化されきらないのです。


正しいパターン:ループ外で StringBuilder を1つだけ使う

書き換え例:悪いコード → 良いコード

さきほどの「悪い例」を、StringBuilder を使って書き換えてみます。

悪い例:

String result = "";
for (int i = 0; i < 10000; i++) {
    result = result + i;
}
Java

良い例:

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}
String result = sb.toString();
Java

ポイントは、「StringBuilder をループの外で一度だけ生成する」ことです。

ループの中では append でどんどん足していくだけなので、

内部バッファは必要に応じて拡張されるが
毎回前の文字列全体をコピーする必要はない

という構造になります。

こう書き換えることで、「中間 String オブジェクト」の大量生成を防ぎ、メモリ確保・GC のコストを大きく減らせます。


具体的なシナリオで考える:CSV 行の生成

String だけで書いた場合

例えば、リストから CSV の1行を作るケースを考えます。

List<String> values = List.of("Taro", "25", "Tokyo");

String line = "";
for (String v : values) {
    if (!line.isEmpty()) {
        line += ",";
    }
    line += v;
}

System.out.println(line);  // "Taro,25,Tokyo"
Java

これも、要素数が増えれば増えるほど
line の中身が増え、それを含めた新しい String が何度も作られます。

StringBuilder を使うバージョン

同じ処理を StringBuilder で書き直します。

List<String> values = List.of("Taro", "25", "Tokyo");

StringBuilder sb = new StringBuilder();
for (String v : values) {
    if (sb.length() > 0) {
        sb.append(",");
    }
    sb.append(v);
}

String line = sb.toString();
System.out.println(line);
Java

sb.length() を使うことで、「一番最初の要素ではないときだけカンマを付ける」という判定も簡単に書けます。

このコードは、values の要素数に比例して少しずつバッファを拡張しながら連結していくので、毎回 String を作り直すよりずっと効率が良いです。


StringBuilder と StringBuffer の違いも絡めておく

StringBuilder を基本、StringBuffer は歴史&特殊用途

StringBuilder とよく似たクラスに StringBuffer があります。
どちらも「可変の文字列バッファ」ですが、

StringBuilder は同期化なし(スレッドセーフではないが速い)
StringBuffer は同期化あり(スレッドセーフだがその分遅い)

という違いがあります。

今どきのコードでは、

単一スレッドで文字列を構築 → StringBuilder を使う
本当に複数スレッドから同じバッファをいじる必要がある → よく設計を考えたうえで StringBuffer も検討

というイメージで十分です。

新しくコードを書くときは、基本的に StringBuilder を選べば大丈夫です。


パフォーマンスと「読みやすさ」のバランスの取り方

なんでもかんでも StringBuilder にしなくていい

ここまで読むと「じゃあ全部 StringBuilder で書いたほうがいいのか?」と思うかもしれませんが、そんなことはありません。

例えばこういうコード。

String msg = "Hello, " + name + "!";
Java

これをわざわざ

StringBuilder sb = new StringBuilder();
sb.append("Hello, ").append(name).append("!");
String msg = sb.toString();
Java

と書き換えるのは、読みやすさを犠牲にしすぎですし、実行速度もほぼ変わりません(むしろ悪化することもあります)。

目安としては、

一度きり、数回程度の連結 → + で素直に書く(コンパイラがうまくやってくれる)
ループの中で何度も連結 → StringBuilder に変える

くらいの線引きで十分です。

パフォーマンスは大事ですが、「可読性」と「過剰な最適化」のバランスを取ることも同じくらい大事です。


まとめ:文字列結合パフォーマンスで初心者が意識すべきポイント

原因は String のイミュータブル性にある(連結のたびに新しいオブジェクトができる)。
単発の "A" + x + "B" はコンパイラが StringBuilder にしてくれるのであまり気にしなくてよい。
ループ中での result = result + ... は、中間オブジェクト乱発になりやすく危険。
ループ外で StringBuilder を一度だけ生成し、ループ内では append する形にすると効率が良い。

タイトルとURLをコピーしました