SJ blog
beginner
S

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

StringはなぜImmutableなのか — +=の正体とStringBuilderが必要な理由

Stringがimmutable(不変)である設計理由、s += 'x'で何が起きているのか、ループでの文字列連結がなぜ遅いのか、StringBuilderとStringPoolの仕組みを根本から解説。

一言結論

Stringがimmutableなのはスレッドセーフ・Stringプール・セキュリティの3つの設計要件から。s += 'x'は毎回新しいオブジェクトを作るため、ループではStringBuilderを使う。

Stringは変更できない

String s = "hello";
s.toUpperCase();
System.out.println(s);  // hello(変わっていない!)

toUpperCase()sを変えない。新しいStringオブジェクトを返す

String s = "hello";
String upper = s.toUpperCase();
System.out.println(s);      // hello(元のまま)
System.out.println(upper);  // HELLO(新しいオブジェクト)

replace(), substring(), trim()なども同じだ。Stringのメソッドは元の文字列を変えず、常に新しいStringを返す。

これを**Immutable(不変)**という。一度作ったStringオブジェクトの内容は絶対に変わらない。

s += “x” の正体

String s = "hello";
s += " world";
System.out.println(s);  // hello world

sの内容が変わったように見える。でも本当は:

1. "hello" と " world" を結合した新しいStringオブジェクト "hello world" を作る
2. 変数 s を新しいオブジェクト "hello world" に向け直す
3. 元の "hello" オブジェクトはどこからも参照されなくなり、GC対象になる
変更前: s ──→ ["hello"]
変更後: s ──→ ["hello world"](新しいオブジェクト)
                    ["hello"](誰も参照していない。GC対象)

変数sが指すオブジェクトが変わっただけで、"hello"オブジェクト自体は変わっていない。

なぜImmutableにしたのか

設計理由は主に3つある。

理由1:Stringプールを安全に使うため

前の記事(==とequals()の違い)で説明したStringプールを思い出してほしい。同じ文字列リテラルは同じオブジェクトを共有する:

String a = "hello";
String b = "hello";
// a と b は同じオブジェクト(アドレスが同じ)

もしStringが変更可能だったとしたら:

a.setChar(0, 'J');  // もしこんなメソッドがあったら
// aを変えた → bも "Jello" になってしまう!(同じオブジェクトを指しているので)

プールで共有しているオブジェクトを変更すると、そのオブジェクトを参照している全員が影響を受ける。Immutableだからこそプールで安全に共有できる

理由2:スレッドセーフのため

マルチスレッドプログラムでは複数のスレッドが同じオブジェクトを使うことがある。オブジェクトが変更可能だと、あるスレッドが変更している最中に別のスレッドが読むと壊れた値を読む危険がある(競合状態)。

Immutableなオブジェクトは変更されないので、どのスレッドから読んでも安全だ。同期処理が不要になる。

理由3:セキュリティのため

Javaのクラスローダーはクラス名(String)を元にクラスファイルを探す。もしStringが変更可能だったら:

String className = "java.lang.String";
// ← ここでclassNameを変更されたら?
Class c = Class.forName(className);  // ← 意図しないクラスがロードされるかも

Immutableなので、渡したStringが後から書き変えられる心配がない。セキュリティの前提になっている。

Stringがfinalクラスである理由

class HackedString extends String { }  // ❌ コンパイルエラー: Stringはfinal

Stringfinalクラスなので継承できない。なぜか?

Immutableという保証を守るためだ。もし継承できると:

class MutableString extends String {
    public void setChar(int index, char c) {
        // 内部バッファを直接書き換えるイカサマ
    }
}

サブクラスがImmutabilityを破ることができてしまう。finalにすることで継承自体を禁止し、Immutabilityを保護している。

ループでの文字列連結がなぜ遅いのか

String result = "";
for (int i = 0; i < 10000; i++) {
    result += i;  // ← これが問題
}

1万回のループで何が起きるか:

1回目: "" + "0" → "0"(新しいオブジェクト)
2回目: "0" + "1" → "01"(新しいオブジェクト)
3回目: "01" + "2" → "012"(新しいオブジェクト)
...
10000回目: ...(巨大な文字列の新しいオブジェクト)

1万個のStringオブジェクトが作られては捨てられる。しかも回を重ねるたびに文字列がどんどん長くなり、コピーのコストも増える。計算量は O(n²)(nは繰り返し回数)になる。

StringBuilderを使う

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);  // ← 内部バッファに追記するだけ
}
String result = sb.toString();

StringBuilderは内部に可変長のバッファ(char配列)を持ち、append()するたびにバッファに追記する。新しいオブジェクトは作らない。最後にtoString()で一度だけStringに変換する。計算量は O(n)。

主なメソッド:

StringBuilder sb = new StringBuilder("hello");
sb.append(" world");     // "hello world"
sb.insert(5, ",");       // "hello, world"
sb.delete(5, 6);         // "hello world"
sb.reverse();            // "dlrow olleh"
sb.replace(0, 5, "bye"); // "bye olleh"
int len = sb.length();   // 長さ

実はJavaコンパイラも自動でStringBuilderを使う

String a = "hello";
String b = " world";
String c = a + b;

コンパイラはこれを自動的に:

String c = new StringBuilder(a).append(b).toString();

に変換する。ループの外の結合はコンパイラが最適化してくれる

ただしループの中の+=はコンパイラが最適化できない

for (int i = 0; i < n; i++) {
    result += i;  // コンパイラはここをStringBuilderに変換できない
    // → 毎回 new StringBuilder(result).append(i).toString() が走る
    // → 毎回 result の内容をコピーした新しいStringBuilderを作る → 遅い
}

StringとStringBuilderの選び分け

String = 文字列の「値」を表す。変更しない前提で使う(関数の戻り値・定数など)
StringBuilder = 文字列を「組み立てる」ために使う(ループでの連結・動的な文字列生成)
StringBuffer = StringBuilderのスレッドセーフ版(マルチスレッド用。通常はStringBuilderで十分)

まとめ

Immutableの理由:
  1. Stringプールの安全な共有(変更されると共有者全員が影響を受ける)
  2. スレッドセーフ(変更されないので競合が起きない)
  3. セキュリティ(渡したStringが後から書き換えられない)

StringがfinalクラスのわけE: 継承でImmutabilityを破られないようにするため

s += "x" の正体: 新しいStringオブジェクトを作って変数を付け替える。元のオブジェクトは変わらない

ループの += が遅い理由: 毎回新しいオブジェクトが作られてO(n²)になる
解決策: StringBuilderを使う(内部バッファに追記するだけ。O(n))

コンパイラ最適化: ループ外の+はコンパイラがStringBuilderに変換。ループ内は変換されない