StringBuilder とは、中身を書き換えられる(可変の)文字列を扱うクラスです。append で末尾に足していき、最後に toString() で String に変えて使います。 String は一度作ると変更できないため、ループで s += "..." を繰り返すと毎回新しい文字列が作られますが、StringBuilder は内部の配列に書き足すだけなので、たくさんつなぐ処理が速くなります。

この記事では、StringBuilder の主要メソッドを Java 21.0.10 の実行結果つき で一覧にしたうえで、+・concat・StringBuilder・StringBuffer・String.join・Collectors.joining の速度を、ループの中と外に分けて計測しました。さらに、Java 9 以降の + が内部でどう動いているかを javap で確認し、「どの場面でどれを使うか」の判断表、StringBuffer との違い(スレッド安全性を実際に再現)、初期容量の効果(時間とメモリ量)までまとめています。


StringBuilderの早見表

主要メソッド

やりたいこと メソッド 例 → 結果
末尾に追加 append(x) "Java" に append(21) → Java21
位置を指定して挿入 insert(位置, x) insert(0, "[") → [Java21
範囲を削除 delete(開始, 終了) 開始を含み、終了は含まない
1文字削除 deleteCharAt(位置) deleteCharAt(0) で先頭を削除
範囲を置換 replace(開始, 終了, 文字列) replace(0, 4, "Kotlin")
逆順にする reverse() abc → cba
1文字だけ変える setCharAt(位置, 文字) 戻り値なし(void)
長さを変える・空にする setLength(n) setLength(0) で中身を空に
検索 indexOf(文字列) 見つからなければ -1
長さ・容量 length() / capacity() 初期容量は 16
繰り返し追加(Java 21) repeat(文字列, 回数) repeat("ab", 2) → abab
String に変換 toString() 最後に1回呼ぶ

どの連結方法を使うか(計測結果からの結論)

場面 おすすめ 計測結果(Java 21.0.10)
1行で数個をつなぐ(ループの外) + 1回あたり数十ナノ秒。StringBuilder と同程度
ループで少しずつ足す StringBuilder 10万件で += 約9.4〜10.9秒、StringBuilder 約1.3〜2.1ミリ秒
リストを区切り文字でつなぐ String.join 10万件で約1.8〜2.5ミリ秒
Stream の結果をつなぐ Collectors.joining 10万件で約1.5〜2.0ミリ秒
書式をそろえる String.format / formatted 1回あたり約240〜280ナノ秒(読みやすさ優先の場面で)
複数スレッドから同じものに追加 StringBuffer(または設計を見直す) StringBuilder は文字が欠け、例外も出た

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

(記載例)出典:株式会社RJC「Java StringBuilderの使い方|文字列連結の速度をJava 21で計測」


実行環境と計測方法

$ java -version
openjdk version "21.0.10" 2026-01-20
OpenJDK Runtime Environment (build 21.0.10+7-Ubuntu-124.04)
OpenJDK 64-Bit Server VM (build 21.0.10+7-Ubuntu-124.04, mixed mode, sharing)

# 実行コマンド(単一ファイル実行。日本語の出力用に stdout.encoding を指定)
$ java -Dstdout.encoding=UTF-8 SbMethods.java
  • CPU 2コアの Linux コンテナ(他の作業と共有)で実行しました。
  • 速度は JMH(Java の計測用ライブラリ)ではなく、System.nanoTime で計測しています。 この環境では Maven Central に接続できず JMH を導入できなかったためです。その代わり、ウォームアップ(JIT コンパイルを済ませるための空回し)をしてから複数回計測し、中央値を載せています。
  • 数値は環境や実行のたびに変わります。大小関係と桁の違いを読み取ってください。

StringBuilder の基本:なぜ String の + より速いのか

String は イミュータブル(変更不可) です。s += "x" と書くと、元の文字列は変わらず、「元の文字列+x」をコピーした 新しい String が作られます。ループで n 回繰り返すと、コピーする量は 1+2+…+n と増えていき、回数の2乗に近い時間がかかります。

StringBuilder は内部に 書き足せる配列(バッファ) を持ち、append はその配列の空きに書き込むだけです。空きがなくなったときだけ大きな配列に引っ越すので、全体としては回数にほぼ比例した時間で済みます。

flowchart LR
    subgraph S["String の += (毎回コピー)"]
        A1["a"] --> A2["ab(新規)"] --> A3["abc(新規)"] --> A4["abcd(新規)"]
    end
    subgraph B["StringBuilder の append(同じ配列に追記)"]
        B1["[a_______]"] --> B2["[ab______]"] --> B3["[abc_____]"] --> B4["[abcd____]"]
    end

基本の形は次のとおりです。

StringBuilder sb = new StringBuilder();
for (String item : items) {
    sb.append(item).append(',');   // append は自分自身を返すので、つなげて書ける
}
String result = sb.toString();     // 最後に String へ

StringBuilder の主要メソッド一覧と実行結果

メソッド一覧(Java 21 の API より)

メソッド 戻り値 内容 補足
append(x) StringBuilder 末尾に追加 int・char・boolean・double・Object などすべて受け付ける
insert(位置, x) StringBuilder 指定位置に挿入 位置は 0〜length
delete(開始, 終了) StringBuilder 開始〜終了の手前を削除 終了が長さを超えても末尾までとして扱う
deleteCharAt(位置) StringBuilder 1文字削除 範囲外は StringIndexOutOfBoundsException
replace(開始, 終了, 文字列) StringBuilder 範囲を置き換え 置き換え後の長さは違ってよい
reverse() StringBuilder 逆順 サロゲートペア(絵文字など)は崩さない
setCharAt(位置, 文字) void 1文字を書き換え チェーンできない
setLength(n) void 長さを n に 短くすると切り捨て、長くすると文字コード0で埋める
indexOf(文字列) / lastIndexOf int 位置を検索 見つからなければ -1
charAt(位置) char 1文字取得
substring(開始, 終了) String 一部を String で取り出す
length() int 現在の文字数
capacity() int 今の配列で入る文字数 既定は 16
ensureCapacity(n) / trimToSize() void 容量の確保/縮小
repeat(文字列, 回数) / repeat(コードポイント, 回数) StringBuilder 繰り返し追加 Java 21 で追加
compareTo(other) int 辞書順で比較 Java 11 で追加
toString() String String に変換

Java 25 の API ドキュメントでも、上のメソッドの仕様は同じで、Java 22〜25 で StringBuilder に新しいメソッドは追加されていません(公式ドキュメントで確認)。

全メソッドを試すコード

public class SbMethods {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        System.out.println("初期 length=" + sb.length() + " capacity=" + sb.capacity());

        sb.append("Java").append(' ').append(21).append(true);
        System.out.println("append       : " + sb);

        sb.insert(0, "[").insert(sb.length(), "]");
        System.out.println("insert       : " + sb);

        sb.delete(sb.length() - 5, sb.length() - 1);   // "true" を削除
        System.out.println("delete       : " + sb);

        sb.deleteCharAt(0).deleteCharAt(sb.length() - 1); // 先頭と末尾の [ ] を削除
        System.out.println("deleteCharAt : " + sb);

        sb.replace(0, 4, "Kotlin");                      // 0〜3文字目を置換
        System.out.println("replace      : " + sb);

        sb.setCharAt(0, 'k');
        System.out.println("setCharAt    : " + sb);

        System.out.println("indexOf(\"21\") = " + sb.indexOf("21") + " / indexOf(\"x\") = " + sb.indexOf("x"));
        System.out.println("charAt(1)    : " + sb.charAt(1));

        sb.reverse();
        System.out.println("reverse      : " + sb);

        System.out.println("length=" + sb.length() + " capacity=" + sb.capacity());
        sb.setLength(3);
        System.out.println("setLength(3) : " + sb);
        sb.setLength(5);
        System.out.println("setLength(5) : length=" + sb.length() + " 4文字目のコード=" + (int) sb.charAt(4));
        sb.setLength(0);
        System.out.println("setLength(0) : [" + sb + "] capacity=" + sb.capacity());

        sb.append("-".repeat(3)).repeat("ab", 2).repeat('*', 3);   // repeat は Java 21
        System.out.println("repeat       : " + sb);

        String s = sb.toString();
        System.out.println("toString     : " + s + " (" + s.getClass().getSimpleName() + ")");

        StringBuilder grow = new StringBuilder();
        StringBuilder log = new StringBuilder();
        int last = grow.capacity();
        for (int i = 0; i < 100; i++) {
            grow.append('x');
            if (grow.capacity() != last) {
                log.append(last).append("→").append(grow.capacity()).append(' ');
                last = grow.capacity();
            }
        }
        System.out.println("容量の増え方 : " + log);

        StringBuilder a = new StringBuilder("abc"), b = new StringBuilder("abc");
        System.out.println("equals=" + a.equals(b) + " compareTo=" + a.compareTo(b)
            + " toString比較=" + a.toString().equals(b.toString())
            + " contentEquals=" + "abc".contentEquals(a));
    }
}

実行結果(Java 21.0.10):

初期 length=0 capacity=16
append       : Java 21true
insert       : [Java 21true]
delete       : [Java 21]
deleteCharAt : Java 21
replace      : Kotlin 21
setCharAt    : kotlin 21
indexOf("21") = 7 / indexOf("x") = -1
charAt(1)    : o
reverse      : 12 niltok
length=9 capacity=16
setLength(3) : 12 
setLength(5) : length=5 4文字目のコード=0
setLength(0) : [] capacity=16
repeat       : ---abab***
toString     : ---abab*** (String)
容量の増え方 : 16→34 34→70 70→142 
equals=false compareTo=0 toString比較=true contentEquals=true

読み取れるポイント

  • append や insert は自分自身を返すので、.append(...).append(...) とつなげて書けます。 setCharAt と setLength は戻り値が void なのでつなげられません。
  • delete(開始, 終了) は「終了の手前まで」を消します(String#substring と同じ数え方)。
  • setLength(0) で中身を空にしても、容量(capacity)はそのままです。同じ StringBuilder を使い回すときに便利です。setLength で長くすると、増えた部分は文字コード 0 の文字で埋まります。
  • 容量は 16 → 34 → 70 → 142 と「今の容量×2+2」で増えました。 足りなくなるたびに新しい配列を作ってコピーしています(後で初期容量の効果を計測します)。
  • StringBuilder 同士の equals は中身が同じでも false です。StringBuilder は equals をオーバーライドしていないため、== と同じ判定になります。中身を比べるときは compareTo(...) == 0、toString().equals(...)、String#contentEquals を使います。

つまずきやすい動き(実行結果)

public class Pitfalls {
    public static void main(String[] args) {
        String name = null;
        StringBuilder sb = new StringBuilder();
        sb.append("名前:").append(name);
        System.out.println(sb);

        StringBuilder c = new StringBuilder('A');     // char を渡すと「容量65」の意味になる
        System.out.println("[" + c + "] length=" + c.length() + " capacity=" + c.capacity());

        try {
            new StringBuilder("abc").insert(5, "x");
        } catch (Exception e) {
            System.out.println(e);
        }
        try {
            new StringBuilder("abc").deleteCharAt(3);
        } catch (Exception e) {
            System.out.println(e);
        }
        System.out.println(new StringBuilder("abc").delete(1, 100));   // end が長さを超えても OK
    }
}
名前:null
[] length=0 capacity=65
java.lang.StringIndexOutOfBoundsException: Range [5, 3) out of bounds for length 3
java.lang.StringIndexOutOfBoundsException: Index 3 out of bounds for length 3
a
書き方 結果 こうするとよい
append(null の String) 文字列 "null" が入る 事前に Objects.requireNonNullElse(name, "") などで置き換える
new StringBuilder('A') 中身は空、容量が 65(char が int として扱われる) new StringBuilder("A") と文字列で渡す
insert(長さより大きい位置, ...) StringIndexOutOfBoundsException 位置は 0〜length()
delete(1, 100) 例外にならず末尾まで削除 終了位置は長さを超えてもよい

文字列連結6種の速度比較(ループの中)

ループの中で n 個の文字列("item0,"、"item1,"、…)をつないで1つの文字列にする時間を、6つの方法で計測しました。String.join と Collectors.joining は、つなぐ文字列をあらかじめ List に用意した状態から計測しています。

import java.util.*;
import java.util.function.Supplier;
import java.util.stream.*;

// ループの中で n 個の文字列をつなぐ速さを比べる(System.nanoTime、ウォームアップ付き)
public class LoopBench {
    static List<String> parts;

    static String plus(int n)    { String s = ""; for (int i = 0; i < n; i++) s += parts.get(i); return s; }
    static String concat(int n)  { String s = ""; for (int i = 0; i < n; i++) s = s.concat(parts.get(i)); return s; }
    static String builder(int n) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < n; i++) sb.append(parts.get(i)); return sb.toString(); }
    static String buffer(int n)  { StringBuffer sb = new StringBuffer(); for (int i = 0; i < n; i++) sb.append(parts.get(i)); return sb.toString(); }
    static String join(int n)    { return String.join("", parts.subList(0, n)); }
    static String joining(int n) { return parts.subList(0, n).stream().collect(Collectors.joining()); }

    static long sink;   // 結果を捨てないための変数(JIT による最適化で処理が消えるのを防ぐ)

    static double measure(Supplier<String> task, int warmup, int runs) {
        for (int i = 0; i < warmup; i++) sink += task.get().length();
        double[] t = new double[runs];
        for (int i = 0; i < runs; i++) {
            long st = System.nanoTime();
            sink += task.get().length();
            t[i] = (System.nanoTime() - st) / 1_000_000.0;
        }
        Arrays.sort(t);
        return t[runs / 2];   // 中央値(ミリ秒)
    }

    public static void main(String[] args) {
        parts = IntStream.range(0, 100_000).mapToObj(i -> "item" + i + ",").toList();
        System.out.println("Java " + System.getProperty("java.version"));
        for (int n : new int[]{1_000, 10_000, 100_000}) {
            int warm = n >= 100_000 ? 2 : 10, runs = n >= 100_000 ? 5 : 15;
            System.out.printf("n=%,d(中央値 ms)%n", n);
            System.out.printf("  +=             %10.3f%n", measure(() -> plus(n), warm, runs));
            System.out.printf("  concat         %10.3f%n", measure(() -> concat(n), warm, runs));
            System.out.printf("  StringBuilder  %10.3f%n", measure(() -> builder(n), warm * 5, runs * 3));
            System.out.printf("  StringBuffer   %10.3f%n", measure(() -> buffer(n), warm * 5, runs * 3));
            System.out.printf("  String.join    %10.3f%n", measure(() -> join(n), warm * 5, runs * 3));
            System.out.printf("  joining        %10.3f%n", measure(() -> joining(n), warm * 5, runs * 3));
        }
        System.out.println("sink=" + (sink > 0));
    }
}

1回目の実行結果(Java 21.0.10、この記事の別の計測を同時に動かしていない状態):

Java 21.0.10
n=1,000(中央値 ms)
  +=                  2.290
  concat              0.893
  StringBuilder       0.024
  StringBuffer        0.043
  String.join         0.114
  joining             0.034
n=10,000(中央値 ms)
  +=                 64.957
  concat             66.545
  StringBuilder       0.145
  StringBuffer        0.386
  String.join         0.211
  joining             0.224
n=100,000(中央値 ms)
  +=               9441.885
  concat           9407.086
  StringBuilder       1.331
  StringBuffer        1.269
  String.join         1.832
  joining             1.490
sink=true

2回目(別の計測と同時に動いていた時間帯を含むため、ばらつきが大きめ)も合わせて表にします。

方法 n=1,000 n=10,000 n=100,000
+= 2.3〜2.5 ms 約66 ms 約9.4〜10.9 秒
concat 約0.9 ms 67〜146 ms 約9.4〜12.3 秒
StringBuilder 0.02〜0.04 ms 約0.14 ms 1.3〜2.1 ms
StringBuffer 0.03〜0.04 ms 0.13〜0.39 ms 1.3 ms
String.join 約0.12 ms 約0.21 ms 1.8〜2.5 ms
Collectors.joining 約0.03 ms 0.17〜0.22 ms 1.5〜2.0 ms

わかったこと

  • ループ内の += と concat は、件数が10倍になると時間が100倍以上に伸びました(1万件 約66ms → 10万件 約9.4秒)。毎回それまでの文字列を丸ごとコピーするためです。
  • StringBuilder・StringBuffer・String.join・joining は、件数にほぼ比例し、10万件でも数ミリ秒でした。10万件で += との差は 約5,000倍以上 です。
  • 1スレッドで使う限り、StringBuffer と StringBuilder の差はこの計測では誤差の範囲でした(ロックの処理が JIT で軽くなるため。後述)。
  • String.join と Collectors.joining も StringBuilder と同じ桁です。区切り文字があるなら、こちらの方が短く書けます。

ループの外(1行の連結)の速度比較

次は、ループの外で "ID:" + id + " 名前:" + name + " 数量:" + qty のような1行を作る処理です。100万回繰り返して1回あたりの時間(ナノ秒)を出しました。

import java.util.*;
import java.util.function.IntFunction;

// ループの外(1行の連結)を比べる:1回あたりのナノ秒
public class OneLineBench {
    static String name = "在庫A";
    static long sink;

    static String plus(int i)    { return "ID:" + i + " 名前:" + name + " 数量:" + (i % 100); }
    static String concat(int i)  { return "ID:".concat(String.valueOf(i)).concat(" 名前:").concat(name).concat(" 数量:").concat(String.valueOf(i % 100)); }
    static String builder(int i) { return new StringBuilder().append("ID:").append(i).append(" 名前:").append(name).append(" 数量:").append(i % 100).toString(); }
    static String buffer(int i)  { return new StringBuffer().append("ID:").append(i).append(" 名前:").append(name).append(" 数量:").append(i % 100).toString(); }
    static String join(int i)    { return String.join("", "ID:", String.valueOf(i), " 名前:", name, " 数量:", String.valueOf(i % 100)); }
    static String format(int i) { return String.format("ID:%d 名前:%s 数量:%d", i, name, i % 100); }

    static double nsPerOp(IntFunction<String> f) {
        int ops = 1_000_000;
        for (int w = 0; w < 5; w++) for (int i = 0; i < ops; i++) sink += f.apply(i).length();   // ウォームアップ
        double[] r = new double[7];
        for (int k = 0; k < r.length; k++) {
            long st = System.nanoTime();
            for (int i = 0; i < ops; i++) sink += f.apply(i).length();
            r[k] = (System.nanoTime() - st) / (double) ops;
        }
        Arrays.sort(r);
        return r[r.length / 2];
    }

    public static void main(String[] args) {
        System.out.println("Java " + System.getProperty("java.version") + " 例: " + plus(42));
        System.out.printf("+              %6.1f ns%n", nsPerOp(OneLineBench::plus));
        System.out.printf("concat         %6.1f ns%n", nsPerOp(OneLineBench::concat));
        System.out.printf("StringBuilder  %6.1f ns%n", nsPerOp(OneLineBench::builder));
        System.out.printf("StringBuffer   %6.1f ns%n", nsPerOp(OneLineBench::buffer));
        System.out.printf("String.join    %6.1f ns%n", nsPerOp(OneLineBench::join));
        System.out.printf("String.format  %6.1f ns%n", nsPerOp(OneLineBench::format));
        System.out.println("sink=" + (sink > 0));
    }
}

実行結果(Java 21.0.10、この記事の別の計測を同時に動かしていない状態での1回目):

Java 21.0.10 例: ID:42 名前:在庫A 数量:42
+                35.8 ns
concat          112.3 ns
StringBuilder    45.4 ns
StringBuffer     30.0 ns
String.join      95.5 ns
String.format   240.5 ns
sink=true

同じ条件で2回実行した結果の範囲です。

方法 1回あたり 読みやすさ
+ 28.6〜35.8 ns ◎
StringBuilder(チェーン) 45.4〜45.5 ns ○
StringBuffer(チェーン) 27.5〜30.0 ns ○
concat 112〜192 ns △(数値は String.valueOf が必要)
String.join("", ...) 96〜137 ns △
String.format 240〜279 ns ◎(書式指定が必要なとき)

ループの外では + は StringBuilder と同じ数十ナノ秒の範囲で、読みやすさを考えると + で十分です。String.format は書式を解釈する分だけ時間がかかりますが、それでも1回 0.3 マイクロ秒以下なので、ログやメッセージの組み立てでは読みやすさを優先して選べます。


Java 9 以降の + の仕組みを javap で確認する

Java 8 までは、コンパイラが + を StringBuilder の append の連続に置き換えていました。Java 9 以降(JEP 280)は、invokedynamic 命令で StringConcatFactory に連結のしかたを任せる形に変わっています。実際にバイトコードを見てみます。

public class Concat {
    static String line(int id, String name) {
        return "ID:" + id + " name:" + name;
    }
    static String loop(String[] items) {
        String s = "";
        for (String it : items) s += it;
        return s;
    }
}
$ javac Concat.java && javap -c Concat.class

Java 21 でコンパイルした結果(抜粋):

  static java.lang.String line(int, java.lang.String);
    Code:
       0: iload_0
       1: aload_1
       2: invokedynamic #7,  0              // InvokeDynamic #0:makeConcatWithConstants:(ILjava/lang/String;)Ljava/lang/String;
       7: areturn

javap -v で見ると、呼び出し先は java/lang/invoke/StringConcatFactory.makeConcatWithConstants でした。"ID:" + id + " name:" + name 全体が1つの命令になり、定数部分(ID: と name:)は「レシピ」として渡されます。 実行時に JVM が、最終的な長さを計算してから1回で文字列を組み立てる処理を作るため、1行の連結は速くなっています。

比較のため、同じソースを --release 8(Java 8 向け)でコンパイルすると、昔ながらの StringBuilder 展開になりました。

$ javac --release 8 -d old Concat.java && javap -c old/Concat.class
  static java.lang.String line(int, java.lang.String);
    Code:
       0: new           #7                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #9                  // Method java/lang/StringBuilder."<init>":()V
       7: ldc           #10                 // String ID:
       9: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      12: iload_0
      13: invokevirtual #16                 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
      16: ldc           #19                 // String  name:
      18: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      21: aload_1
      22: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      25: invokevirtual #21                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      28: areturn

大事なのは、ループの中の s += it も、Java 21 では1回ごとに invokedynamic が実行されることです(loop メソッドのバイトコードでも、ループ本体の中に makeConcatWithConstants が1つありました)。1回の連結は速くなっても、「毎回それまでの文字列を全部コピーする」点は変わらないため、先ほどの計測のとおり、ループ内では StringBuilder との差が大きく開きます。

Java 8 まで Java 9 以降
+ のコンパイル結果 new StringBuilder().append(...)...toString() invokedynamic(StringConcatFactory)
1行の連結 StringBuilder と同等 長さを先に計算して組み立てるので効率がよい
ループ内の += 毎回 StringBuilder を作ってコピー 毎回連結してコピー(StringBuilder を使うべき点は同じ)

判断基準表:どの連結方法を使うか

場面 使うもの コード例
ループの外で数個をつなぐ + "ID:" + id + " 名前:" + name
ループで少しずつ足す StringBuilder sb.append(row).append('\n');
リストを区切り文字でつなぐ String.join String.join(", ", names)
Stream で加工しながらつなぐ Collectors.joining .map(Item::name).collect(Collectors.joining(", ", "[", "]"))
前後に括弧を付けてつなぐ StringJoiner / joining(区切り, 前, 後) new StringJoiner(", ", "[", "]")
書式(桁数・ゼロ埋め)をそろえる String.format / "...".formatted(...) "%05d".formatted(id)
同じ文字列を繰り返す String#repeat / StringBuilder#repeat(Java 21) "-".repeat(20)
長さがだいたい分かっている大量の連結 new StringBuilder(初期容量) new StringBuilder(rows.size() * 64)
複数スレッドから同じバッファに足す StringBuffer(できれば各スレッドで作って最後にまとめる) 後述

迷ったら「ループの外は +、ループの中は StringBuilder、区切りがあるなら join」と覚えておけば十分です。


StringBuffer との違い:スレッド安全性を再現する

StringBuffer と StringBuilder は メソッドの名前も使い方もほぼ同じです。違いは StringBuffer のメソッドには synchronized(同時に1スレッドしか入れないロック)が付いている点です。

StringBuilder StringBuffer
追加された版 Java 5 Java 1.0
スレッド安全 なし あり(各メソッドが synchronized)
1スレッドでの速さ(この記事の計測) 同程度 同程度
使う場面 ほぼすべて 複数スレッドから同じオブジェクトに書き込む場合

2つのスレッドから、同じオブジェクトに100万回ずつ append('x') してみます。

import java.util.concurrent.CountDownLatch;

// 2つのスレッドから同じオブジェクトに 100万回ずつ append する
public class ThreadSafe {
    static String run(StringBuilder sb, StringBuffer buf) throws InterruptedException {
        CountDownLatch start = new CountDownLatch(1);
        String[] error = {""};
        Runnable task = () -> {
            try {
                start.await();                                  // 2つ同時にスタート
                for (int i = 0; i < 1_000_000; i++) {
                    if (sb != null) sb.append('x'); else buf.append('x');
                }
            } catch (Exception e) {
                error[0] = " 例外: " + e;
            }
        };
        Thread t1 = new Thread(task), t2 = new Thread(task);
        t1.start(); t2.start();
        start.countDown();
        t1.join(); t2.join();
        return (sb != null ? sb.length() : buf.length()) + error[0];
    }

    public static void main(String[] args) throws Exception {
        for (int k = 1; k <= 3; k++) {
            System.out.println(k + "回目 StringBuilder 長さ=" + run(new StringBuilder(), null));
            System.out.println(k + "回目 StringBuffer  長さ=" + run(null, new StringBuffer()));
        }
        System.out.println("期待値 2000000");
    }
}

実行結果(Java 21.0.10):

1回目 StringBuilder 長さ=1418581
1回目 StringBuffer  長さ=2000000
2回目 StringBuilder 長さ=2000000
2回目 StringBuffer  長さ=2000000
3回目 StringBuilder 長さ=1000016 例外: java.lang.ArrayIndexOutOfBoundsException: Index 286 out of bounds for length 34
3回目 StringBuffer  長さ=2000000
期待値 2000000

StringBuilder は、1回目で約58万文字が欠け、3回目は ArrayIndexOutOfBoundsException が発生しました。2回目のようにたまたま正しく見えることもあるのが、この種の問題の分かりにくいところです。StringBuffer は3回とも 2,000,000 文字でした。

ただし、StringBuffer でも「1回の append」が安全になるだけで、「append を2回続けた間に別スレッドが割り込まない」ことまでは保証されません。複数スレッドの結果をまとめるときは、スレッドごとに StringBuilder を作り、最後に1か所でつなぐ(または Stream の Collectors.joining を使う)方が、順序も含めて扱いやすくなります。


初期容量の効果を計測する

StringBuilder の容量は既定で16文字です。足りなくなると「今の容量×2+2」の配列を作ってコピーします。最終的な長さが分かっているなら、new StringBuilder(容量) で先に確保しておくと、このコピーを省けます。

約790万文字(100万行)を作る処理で、時間と確保したメモリ量を比べました。

import java.lang.management.ManagementFactory;

// 初期容量の有無で「確保したメモリ量」を比べる(com.sun.management.ThreadMXBean を使用)
public class CapacityAlloc {
    static final int N = 1_000_000;
    static String build(int initial) {
        StringBuilder sb = initial < 0 ? new StringBuilder() : new StringBuilder(initial);
        for (int i = 0; i < N; i++) sb.append("row").append(i % 10_000).append('\n');
        return sb.toString();
    }
    public static void main(String[] args) {
        var mx = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
        long tid = Thread.currentThread().threadId();
        for (int k = 0; k < 3; k++) { build(-1); build(8_000_000); }   // ウォームアップ
        for (int initial : new int[]{-1, 8_000_000}) {
            long before = mx.getThreadAllocatedBytes(tid);
            String s = build(initial);
            long after = mx.getThreadAllocatedBytes(tid);
            System.out.printf("%-14s 文字数=%,d 確保したメモリ=%,d バイト%n",
                initial < 0 ? "初期容量なし" : "初期容量 800万", s.length(), after - before);
        }
    }
}

実行結果(Java 21.0.10):

初期容量なし         文字数=7,889,000 確保したメモリ=26,763,736 バイト
初期容量 800万      文字数=7,889,000 確保したメモリ=15,889,080 バイト

時間は、同じ処理を System.nanoTime で15回計測した中央値(別プログラム CapacityBench.java、ウォームアップ10回)で比べました。初期容量なしの場合、容量の拡張は 19回 起き、最終的な容量は 9,437,182 文字でした。

計測 初期容量なし 初期容量 800万
時間(別の計測と重ならない回・1回目) 17.11 ms 19.95 ms
時間(別の計測と重ならない回・2回目) 16.74 ms 17.06 ms
時間(別の計測と同時に動いた回) 39.61〜52.62 ms 21.57〜35.71 ms
確保したメモリ 約26.8 MB 約15.9 MB
容量の拡張回数 19回 0回

わかったこと

  • 確保したメモリは約4割少なくなりました(拡張のたびに作られる途中の配列がなくなるため)。メモリが限られる環境や、GC(不要なメモリの回収)の回数を減らしたい場面で効果があります。
  • 時間は、別の計測と重ならない回ではほぼ同じでした。配列のコピーはとても速く、拡張も19回だけだからです。CPU が混み合っている回では初期容量ありの方が速い結果になりましたが、ばらつきも大きく、はっきりした差とまでは言えません。
  • 初期容量は 「だいたいの長さ」で十分です。多めに取りすぎると、使わない領域の分だけメモリを確保することになります。

よくある書き方の改善例

1. ループの中の += を StringBuilder に

// 改善前:件数が増えると2乗で遅くなる
String csv = "";
for (Item item : items) {
    csv += item.code() + "," + item.qty() + "\n";
}

// 改善後
StringBuilder sb = new StringBuilder(items.size() * 16);
for (Item item : items) {
    sb.append(item.code()).append(',').append(item.qty()).append('\n');
}
String csv = sb.toString();

sb.append(a + "," + b) と書くと、括弧の中で一度 String が作られます。 動作は正しいですが、append をつなげる方が無駄がありません。

2. 区切り文字の最後の1つを消す処理を join に

// 改善前:最後のカンマを消す処理が必要
StringBuilder sb = new StringBuilder();
for (String name : names) sb.append(name).append(", ");
if (sb.length() > 0) sb.setLength(sb.length() - 2);

// 改善後
String joined = String.join(", ", names);

3. StringBuilder の使い回しは setLength(0)

StringBuilder line = new StringBuilder(128);
for (Order o : orders) {
    line.setLength(0);                      // 中身だけ空にする(容量はそのまま)
    line.append(o.id()).append('\t').append(o.total());
    writer.write(line.toString());
}

よくある質問

Q. Java 9 以降は + が最適化されたので、StringBuilder は不要になりましたか? A. 1行の連結なら + で十分ですが、ループの中では今も StringBuilder が必要です。この記事の計測では、Java 21 でも10万件のループで += が約9.4秒、StringBuilder が約1.3ミリ秒でした。Java 9 の最適化は「1回の連結」を速くするもので、「毎回それまでの文字列をコピーする」ことは変わらないためです。

Q. StringBuffer と StringBuilder、どちらを使えばよいですか? A. ほとんどの場合は StringBuilder です。1つのメソッドの中で作って使い終わるなら、複数スレッドから触られることはありません。複数スレッドから同じオブジェクトに書き込む必要があるときだけ StringBuffer を検討し、できれば「スレッドごとに作って最後にまとめる」形にします。

Q. StringBuilder の中身を比べるには? A. equals は中身が同じでも false になります(オーバーライドされていないため)。sb1.compareTo(sb2) == 0(Java 11 以降)、sb1.toString().equals(sb2.toString())、"文字列".contentEquals(sb) のいずれかを使います。

Q. 初期容量はいくつにすればよいですか? A. 最終的な文字数の見込みを渡します。1行あたりの平均文字数×行数のような大まかな値で十分です。小さな連結(数十文字程度)なら指定しなくても差はほとんど出ません。


まとめ

  • StringBuilder は書き換えできる文字列。append でつなぎ、最後に toString()
  • 主要メソッドは append・insert・delete・deleteCharAt・replace・reverse・setLength・setCharAt・indexOf。setLength(0) で空にでき、Java 21 では repeat も使える
  • ループ内の += は件数の2乗で遅くなり、10万件で StringBuilder の数千倍の時間がかかった(Java 21.0.10 で計測)
  • ループ外の1行は + で十分。Java 9 以降の + は invokedynamic(StringConcatFactory)で組み立てられる
  • 判断基準は「ループの外は +、ループの中は StringBuilder、区切りがあるなら String.join / joining」
  • StringBuffer はスレッド安全な版。StringBuilder を2スレッドで共有すると文字が欠け、例外も出た
  • 初期容量の指定で、確保したメモリは約26.8MB → 約15.9MBに。時間の差は小さかった

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

(記載例)出典:株式会社RJC「Java StringBuilderの使い方|文字列連結の速度をJava 21で計測」

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

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