Javaの文字列連結とは、複数の文字列や値をつないで1つの文字列を作る処理で、数個をつなぐなら + 演算子、リストを区切り文字でつなぐなら String.join、ループの中で足していくなら StringBuilder を使います。 書き方は10通り以上ありますが、それぞれ「null を渡したときの動き」「区切り文字を入れられるか」「ループに向くか」が違います。

この記事では、+・+=・concat・StringBuilder・StringBuffer・String.join・StringJoiner・Collectors.joining・String.format・formatted・テキストブロックの11通りを1枚の比較表にまとめました。 リストをカンマ区切りにする書き方(数値リスト、前後にかっこを付ける、null や空文字を除く)はコピペ用に並べ、null を連結したときの出力と NullPointerException の再現、ループ連結の計測結果、+ がコンパイル後にどう変換されるかまで扱います。コードはすべて OpenJDK 21.0.10 で実行し、出力をそのまま載せています。

文字列連結の方法の早見表

方法 nullを渡した時 区切り文字 ループ向きか 使えるJavaバージョン
+ 演算子 "null" という文字になる 自分で書く 1つの式の中なら向く。ループでの足し込みには向かない 1.0(Java 9 から内部の変換方法が変更)
+= "null" という文字になる 自分で書く 向かない(回数が増えると急に遅くなる) 1.0
concat NullPointerException なし 向かない 1.0
StringBuilder append は "null" を追加。コンストラクタは NullPointerException 自分で書く 向く 5
StringBuffer StringBuilder と同じ 自分で書く 向く(複数スレッドで共有する場合) 1.0
String.join 要素の null は "null"。区切り文字やリスト自体が null なら NullPointerException 指定できる ループを書かずに済む 8
StringJoiner add(null) は "null"。区切り文字が null なら NullPointerException 指定できる(前後の文字も) 向く 8
Collectors.joining 要素の null は "null" 指定できる(前後の文字も) Stream の最後に使う 8
String.format %s に null を渡すと "null"。書式が null なら NullPointerException 書式に書く 向かない(1回が重い) 5
formatted String.format と同じ 書式に書く 向かない 15
テキストブロック 文字列リテラルなので null は入らない(値の埋め込みは formatted と組み合わせる) 改行をそのまま書ける 複数行の定型文を書くためのもの 15

null を渡した時の動きは、後半の実行結果で1つずつ確かめています。バージョンのうち String.join(1.8)、formatted(15)、concat(1.0)は Java 21 の String クラスの API ドキュメントで、+ の変換方法の変更(Java 9)は JEP 280 のページで今回確かめました。そのほかの導入バージョンは一般に知られた仕様として記載しています。

選び方を3行でまとめると次のとおりです。

  • 数個の値を1行でつなぐ → +
  • リストや配列を区切り文字でつなぐ → String.join(数値や条件付きなら Collectors.joining)
  • ループの中で少しずつ足していく → StringBuilder

11通りの書き方を同じ結果で比べる

まず、11通りすべてで同じ文字列「Java 21」を作ってみます。ファイル名を Basic.java として保存し、java Basic.java で実行できます(Java 11 以降は、1ファイルのプログラムをコンパイルなしで実行できます)。

import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Collectors;

public class Basic {
    public static void main(String[] args) {
        String a = "Java", b = "21";
        List<String> list = List.of("Java", "21");

        // 1. + 演算子
        System.out.println("1  " + a + " " + b);
        // 2. += (変数に足し込む)
        String s = a; s += " "; s += b;
        System.out.println("2  " + s);
        // 3. concat
        System.out.println("3  " + a.concat(" ").concat(b));
        // 4. StringBuilder
        System.out.println("4  " + new StringBuilder().append(a).append(" ").append(b));
        // 5. StringBuffer
        System.out.println("5  " + new StringBuffer().append(a).append(" ").append(b));
        // 6. String.join
        System.out.println("6  " + String.join(" ", a, b));
        // 7. StringJoiner
        System.out.println("7  " + new StringJoiner(" ").add(a).add(b));
        // 8. Collectors.joining
        System.out.println("8  " + list.stream().collect(Collectors.joining(" ")));
        // 9. String.format
        System.out.println("9  " + String.format("%s %s", a, b));
        // 10. formatted(Java 15 以降)
        System.out.println("10 " + "%s %s".formatted(a, b));
        // 11. テキストブロック(Java 15 以降)+ formatted
        String block = """
                %s %s""".formatted(a, b);
        System.out.println("11 " + block);
    }
}

実行結果(OpenJDK 21.0.10):

1  Java 21
2  Java 21
3  Java 21
4  Java 21
5  Java 21
6  Java 21
7  Java 21
8  Java 21
9  Java 21
10 Java 21
11 Java 21

結果はどれも同じです。違いが出るのは「null が混ざったとき」「件数が増えたとき」「区切り文字や前後の文字を付けたいとき」なので、ここから順に見ていきます。

リストをカンマ区切りにするコピペ集

「リスト カンマ区切り」で探している方向けに、よく使う形を番号つきで並べました。必要な行だけ貼って使えます。

番号 やりたいこと 使うもの
1 文字列のリストをカンマ区切りに String.join(",", list)
2 文字列の配列をつなぐ String.join(", ", arr)
3 数値のリストをカンマ区切りに map(String::valueOf) + Collectors.joining(",")
4 int 配列をカンマ区切りに Arrays.stream(ints).mapToObj(String::valueOf)
5 前後にかっこを付ける Collectors.joining(", ", "[", "]")
6 各要素を引用符で囲む map で囲んでから joining
7 null を除く filter(Objects::nonNull)
8 null・空文字・空白を除く filter(s -> s != null && !s.isBlank())
9 null を空文字に置き換える map(s -> Objects.toString(s, ""))
10 Map を key=value でつなぐ entrySet().stream() + joining
11 for 文で足しながらかっこ付きに StringJoiner
12 番号を付けながらつなぐ StringBuilder
13 重複を除いて並べ替えてからつなぐ distinct().sorted() + joining
14 空のリストのときの表示を決める setEmptyValue
15 改行でつなぐ、同じ文字を繰り返す String.join("\n", list)、repeat
16 デバッグ用に中身を表示する リストはそのまま、配列は Arrays.toString
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.stream.Collectors;

public class CsvDemo {
    public static void main(String[] args) {
        List<String> names = List.of("りんご", "みかん", "ぶどう");
        List<Integer> nums = List.of(10, 20, 30);
        List<String> dirty = Arrays.asList("A", null, "", "  ", "B");
        String[] arr = {"x", "y", "z"};
        int[] ints = {1, 2, 3};
        Map<String, Integer> stock = new LinkedHashMap<>();
        stock.put("pen", 3);
        stock.put("note", 5);

        // 1. 文字列のリストをカンマ区切りに
        System.out.println("1  " + String.join(",", names));
        // 2. 文字列の配列をカンマ + 空白区切りに
        System.out.println("2  " + String.join(", ", arr));
        // 3. 数値のリストをカンマ区切りに(文字列に変換してから)
        System.out.println("3  " + nums.stream().map(String::valueOf).collect(Collectors.joining(",")));
        // 4. int 配列をカンマ区切りに
        System.out.println("4  " + Arrays.stream(ints).mapToObj(String::valueOf).collect(Collectors.joining(",")));
        // 5. 前後にかっこを付ける
        System.out.println("5  " + names.stream().collect(Collectors.joining(", ", "[", "]")));
        // 6. 各要素を引用符で囲み、全体を丸かっこで囲む
        System.out.println("6  " + names.stream().map(s -> "'" + s + "'").collect(Collectors.joining(", ", "(", ")")));
        // 7. null を除く
        System.out.println("7  " + dirty.stream().filter(Objects::nonNull).collect(Collectors.joining(",")));
        // 8. null・空文字・空白だけの要素を除く(isBlank は Java 11 以降)
        System.out.println("8  " + dirty.stream().filter(s -> s != null && !s.isBlank()).collect(Collectors.joining(",")));
        // 9. null を空文字に置き換えて、列の数は保つ
        System.out.println("9  " + dirty.stream().map(s -> Objects.toString(s, "")).collect(Collectors.joining(",")));
        // 10. Map を key=value の形でつなぐ
        System.out.println("10 " + stock.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("&")));
        // 11. for 文で StringJoiner に足していく(かっこ付き)
        StringJoiner sj = new StringJoiner(", ", "{", "}");
        for (String s : names) {
            sj.add(s);
        }
        System.out.println("11 " + sj);
        // 12. 番号を付けながら StringBuilder でつなぐ
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < names.size(); i++) {
            if (i > 0) sb.append(",");
            sb.append(i + 1).append(":").append(names.get(i));
        }
        System.out.println("12 " + sb);
        // 13. 重複を除き、並べ替えてからつなぐ
        System.out.println("13 " + List.of("b", "a", "b").stream().distinct().sorted().collect(Collectors.joining(",")));
        // 14. 空のリスト(区切りだけ / かっこ付き / 空のときの文字を指定)
        List<String> empty = new ArrayList<>();
        System.out.println("14 \"" + String.join(",", empty) + "\" "
                + empty.stream().collect(Collectors.joining(",", "[", "]")) + " "
                + new StringJoiner(",", "[", "]").setEmptyValue("(なし)"));
        // 15. 改行でつなぐ / 同じ文字を繰り返す(repeat は Java 11 以降)
        System.out.println("15 " + String.join("\n", names).lines().count() + "行 " + "-".repeat(5));
        // 16. デバッグ用の表示ならそのまま出力できる
        System.out.println("16 " + nums + " " + Arrays.toString(ints));
    }
}

実行結果:

1  りんご,みかん,ぶどう
2  x, y, z
3  10,20,30
4  1,2,3
5  [りんご, みかん, ぶどう]
6  ('りんご', 'みかん', 'ぶどう')
7  A,,  ,B
8  A,B
9  A,,,  ,B
10 pen=3&note=5
11 {りんご, みかん, ぶどう}
12 1:りんご,2:みかん,3:ぶどう
13 a,b
14 "" [] (なし)
15 3行 -----
16 [10, 20, 30] [1, 2, 3]

補足です。

  • 7と9の違い:7は null だけを除くので、空文字と空白だけの要素は残り A,, ,B になりました。CSV のように列の数を保ちたいときは、除かずに9のように空文字へ置き換えます。
  • 14の空リスト:String.join は空文字、かっこ付きの joining は [] を返しました。「データがないとき専用の表示」にしたい場合は StringJoiner の setEmptyValue が使えます。
  • 16の表示:リストを + でつなぐと [10, 20, 30] という形(toString の結果)になります。画面やファイルに出す形を自分で決めたいときは、1〜15の書き方を使います。

数値のリストをString.joinに渡すとコンパイルエラーになる

String.join が受け取れるのは文字列(CharSequence)の並びだけです。数値のリストをそのまま渡すと、コンパイルの時点でエラーになります。

import java.util.List;

public class Bad {
    public static void main(String[] args) {
        List<Integer> nums = List.of(10, 20, 30);
        System.out.println(String.join(",", nums));
    }
}

javac Bad.java の結果:

Bad.java:6: error: no suitable method found for join(String,List<Integer>)
        System.out.println(String.join(",", nums));
                                 ^
    method String.join(CharSequence,CharSequence...) is not applicable
      (varargs mismatch; List<Integer> cannot be converted to CharSequence)
    method String.join(CharSequence,Iterable<? extends CharSequence>) is not applicable
      (argument mismatch; List<Integer> cannot be converted to Iterable<? extends CharSequence>)
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

直し方は、文字列に変換してからつなぐことです。 コピペ集の3番のとおり、nums.stream().map(String::valueOf).collect(Collectors.joining(",")) と書きます。

nullを連結した時の出力とNullPointerException

null が混ざったときの動きは、方法によって「"null" という4文字になる」か「NullPointerException で止まる」かに分かれます。19通りを一度に確かめました。

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.function.Supplier;
import java.util.stream.Collectors;

public class NullDemo {
    static void t(String label, Supplier<String> f) {
        try {
            System.out.println(label + " => \"" + f.get() + "\"");
        } catch (Exception e) {
            System.out.println(label + " => " + e.getClass().getName() + ": " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        String n = null;
        List<String> withNull = Arrays.asList("a", null, "c");

        t("\"a\" + null", () -> "a" + n);
        t("null + null", () -> n + n);
        t("s += null", () -> { String s = "a"; s += n; return s; });
        t("\"a\".concat(null)", () -> "a".concat(n));
        t("null.concat(\"a\")", () -> n.concat("a"));
        t("StringBuilder.append(null)", () -> new StringBuilder("a").append(n).toString());
        t("StringBuffer.append(null)", () -> new StringBuffer("a").append(n).toString());
        t("new StringBuilder(null)", () -> new StringBuilder(n).toString());
        t("String.join(\",\", \"a\", null)", () -> String.join(",", "a", n));
        t("String.join(\",\", 要素にnullのあるList)", () -> String.join(",", withNull));
        t("String.join(null, \"a\", \"b\")", () -> String.join(n, "a", "b"));
        t("String.join(\",\", (List) null)", () -> String.join(",", (List<String>) null));
        t("StringJoiner.add(null)", () -> new StringJoiner(",").add("a").add(n).toString());
        t("new StringJoiner(null)", () -> new StringJoiner(n).toString());
        t("Collectors.joining(要素にnull)", () -> withNull.stream().collect(Collectors.joining(",")));
        t("String.format(\"%s-%s\", \"a\", null)", () -> String.format("%s-%s", "a", n));
        t("\"%s\".formatted((Object) null)", () -> "%s".formatted((Object) n));
        t("String.format(null, \"a\")", () -> String.format(n, "a"));
        t("Objects.toString(null, \"\")", () -> Objects.toString(n, ""));
    }
}

実行結果(OpenJDK 21.0.10。例外のメッセージは Java のバージョンによって変わります):

"a" + null => "anull"
null + null => "nullnull"
s += null => "anull"
"a".concat(null) => java.lang.NullPointerException: Cannot invoke "String.isEmpty()" because "str" is null
null.concat("a") => java.lang.NullPointerException: Cannot invoke "String.concat(String)" because "<parameter1>" is null
StringBuilder.append(null) => "anull"
StringBuffer.append(null) => "anull"
new StringBuilder(null) => java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
String.join(",", "a", null) => "a,null"
String.join(",", 要素にnullのあるList) => "a,null,c"
String.join(null, "a", "b") => java.lang.NullPointerException: Cannot invoke "java.lang.CharSequence.toString()" because "delimiter" is null
String.join(",", (List) null) => java.lang.NullPointerException: null
StringJoiner.add(null) => "a,null"
new StringJoiner(null) => java.lang.NullPointerException: The delimiter must not be null
Collectors.joining(要素にnull) => "a,null,c"
String.format("%s-%s", "a", null) => "a-null"
"%s".formatted((Object) null) => "null"
String.format(null, "a") => java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null
Objects.toString(null, "") => ""

整理すると次のようになります。

操作 結果
"a" + null、s += null "anull"(例外にならない)
null + null(String 型の変数同士) "nullnull"
"a".concat(null) NullPointerException
null の変数に対して .concat("a") NullPointerException
StringBuilder.append(null)、StringBuffer.append(null) "anull"
new StringBuilder(null) NullPointerException
String.join の要素が null "a,null"
String.join の区切り文字が null、リスト自体が null NullPointerException
StringJoiner.add(null) "a,null"
new StringJoiner(null) NullPointerException
Collectors.joining の要素が null "a,null,c"
String.format の %s に null "a-null"
String.format の書式が null NullPointerException

覚え方は「つなぐ中身の null は "null" という文字になり、道具そのもの(区切り文字、書式、リスト、メソッドを呼ぶ相手)が null だと例外になる」です。 例外になるのは concat だけ、と覚えるよりも、この見方のほうが応用が利きます。concat は引数の文字列を直接読みに行くため、中身の null でも例外になります。

"null"という文字を出さないための書き方

画面やファイルに「null」という4文字が出てしまうのを避けたいときは、つなぐ前に置き換えるか、取り除きます。

やりたいこと 書き方
null を空文字に置き換える Objects.toString(s, "")
null を決まった文字に置き換える Objects.requireNonNullElse(s, "未設定")(Java 9 以降。今回は未実行)
リストから null を除く filter(Objects::nonNull)
null と空白だけの要素を除く filter(s -> s != null && !s.isBlank())
null なら早めに知らせる Objects.requireNonNull(s, "name is null")(今回は未実行)

Objects.toString(null, "") が空文字を返すことは、上の実行結果の最後の行で確かめています。

+演算子でつまずきやすい計算の順番

  • は左から順に計算されます。数値と文字列が混ざると、どこから連結に切り替わるかで結果が変わります。
public class Order {
    public static void main(String[] args) {
        System.out.println(1 + 2 + "a");        // 左から計算:3 になってから連結
        System.out.println("a" + 1 + 2);        // 文字列が先なので順に連結
        System.out.println("a" + (1 + 2));      // かっこで先に足し算
        System.out.println('a' + 'b' + "c");    // char 同士の + は数値の足し算
        System.out.println("" + 'a' + 'b');     // 先頭を文字列にすると連結
        char[] cs = {'x', 'y'};
        System.out.println("cs=" + String.valueOf(cs));   // char 配列は valueOf で文字列に
        Object o = null;
        System.out.println("o=" + o);           // null は "null" という文字になる
    }
}

実行結果:

3a
a12
a3
195c
ab
cs=xy
o=null
  • 1 + 2 + "a" は "3a" です。左の 1 + 2 が先に数値として計算されます。
  • "a" + 1 + 2 は "a12" です。文字列が先にあるので、順に連結されます。足し算の結果をつなぎたいときは "a" + (1 + 2) とかっこで囲みます。
  • 'a' + 'b' + "c" は "195c" です。char 同士の + は文字コードの足し算(97 + 98)になります。先頭に空文字を置くか、String.valueOf で文字列にしてからつなぎます。

Java 21でのループ連結の計測結果

「ループの中で += を使うと遅くなる」とよく言われます。どのくらい違うのかを、短い文字列(item0、item1、…)を N 個つなぐ処理で計測しました。

import java.util.*;
import java.util.function.Supplier;
import java.util.stream.*;
public class Bench {
    // warm 回の準備運転のあと runs 回計測し、最も速い1回(ナノ秒)を返す
    static long time(Supplier<String> f, int warm, int runs) {
        int len = 0;
        for (int i = 0; i < warm; i++) len += f.get().length();
        long best = Long.MAX_VALUE;
        for (int i = 0; i < runs; i++) { long t = System.nanoTime(); len += f.get().length(); best = Math.min(best, System.nanoTime() - t); }
        if (len == 0) throw new IllegalStateException();
        return best;
    }
    public static void main(String[] args) {
        for (int n : new int[]{1_000, 10_000, 100_000}) {
            final int N = n;
            List<String> items = new ArrayList<>();
            for (int i = 0; i < N; i++) items.add("item" + i);
            int warm = N >= 100_000 ? 2 : 20, runs = 5;
            Map<String, Supplier<String>> m = new LinkedHashMap<>();
            m.put("+= (ループ)", () -> { String s = ""; for (String x : items) { s += x; } return s; });
            m.put("concat (ループ)", () -> { String s = ""; for (String x : items) { s = s.concat(x); } return s; });
            m.put("StringBuilder", () -> { StringBuilder sb = new StringBuilder(); for (String x : items) sb.append(x); return sb.toString(); });
            m.put("StringBuffer", () -> { StringBuffer sb = new StringBuffer(); for (String x : items) sb.append(x); return sb.toString(); });
            m.put("String.join", () -> String.join("", items));
            m.put("StringJoiner", () -> { StringJoiner sj = new StringJoiner(""); for (String x : items) sj.add(x); return sj.toString(); });
            m.put("Collectors.joining", () -> items.stream().collect(Collectors.joining()));
            if (N <= 10_000) m.put("String.format (ループ)", () -> { String s = ""; for (String x : items) { s = String.format("%s%s", s, x); } return s; });
            System.out.println("--- 連結する個数 N = " + N);
            for (var e : m.entrySet()) {
                System.out.printf("%-22s %10.3f ms%n", e.getKey(), time(e.getValue(), warm, runs) / 1e6);
            }
        }
        System.out.println(System.getProperty("java.version") + " / " + System.getProperty("os.arch") + " / cpus=" + Runtime.getRuntime().availableProcessors());
    }
}

実行結果(OpenJDK 21.0.10、Linux amd64、2コアの検証用コンテナ。System.nanoTime による簡易計測で、数値は環境や実行のたびに変わります):

--- 連結する個数 N = 1000
+= (ループ)                    1.604 ms
concat (ループ)                0.620 ms
StringBuilder               0.034 ms
StringBuffer                0.035 ms
String.join                 0.102 ms
StringJoiner                0.065 ms
Collectors.joining          0.017 ms
String.format (ループ)         6.425 ms
--- 連結する個数 N = 10000
+= (ループ)                   54.583 ms
concat (ループ)               54.907 ms
StringBuilder               0.150 ms
StringBuffer                0.239 ms
String.join                 0.314 ms
StringJoiner                0.347 ms
Collectors.joining          0.120 ms
String.format (ループ)       210.021 ms
--- 連結する個数 N = 100000
+= (ループ)                 5921.735 ms
concat (ループ)             5898.623 ms
StringBuilder               1.200 ms
StringBuffer                2.554 ms
String.join                 1.899 ms
StringJoiner                2.187 ms
Collectors.joining          1.179 ms
21.0.10 / amd64 / cpus=2
方法 1,000個 10,000個 100,000個
+=(ループ) 1.604 ms 54.583 ms 5921.735 ms
concat(ループ) 0.620 ms 54.907 ms 5898.623 ms
StringBuilder 0.034 ms 0.150 ms 1.200 ms
StringBuffer 0.035 ms 0.239 ms 2.554 ms
String.join 0.102 ms 0.314 ms 1.899 ms
StringJoiner 0.065 ms 0.347 ms 2.187 ms
Collectors.joining 0.017 ms 0.120 ms 1.179 ms
String.format(ループ) 6.425 ms 210.021 ms 計測せず

読み取れることは次のとおりです。

  • += と concat は、個数が10倍になると時間が約100倍になりました(10,000個で約 55 ms、100,000個で約 5.9 秒)。String は中身を書き換えられないため、足すたびに「それまでの全体 + 新しい分」をコピーした新しい文字列を作るからです。
  • StringBuilder・String.join・StringJoiner・Collectors.joining は、100,000個でも 1〜3 ms でした。 個数が10倍になると時間もおよそ10倍、という素直な増え方です。
  • StringBuffer は StringBuilder より少し時間がかかりました(100,000個で 2.554 ms と 1.200 ms)。append のたびに同期処理が入るためです。1つのスレッドの中だけで使うなら StringBuilder で足ります。
  • String.format をループで使うと、+= よりもさらに時間がかかりました。 書式の解釈が毎回行われるためです。format は「1回で形を整える」用途に使い、ループでの足し込みには StringBuilder を使います。
  • 1,000個程度までなら、どの方法でも 2 ms 未満でした。 数個〜数十個をつなぐ処理で速度を気にする必要はなく、読みやすさで選んで問題ありません。

なお、これは JMH のような専用の計測ツールではなく、準備運転のあとに5回計測して最も速い値を取る簡易的な方法です。細かい順位よりも、「+= と concat だけが桁違いに増える」という傾向を見てください。

直す前と直した後

// 直す前:ループの中で += (件数が多いと時間がかかる)
String csv = "";
for (String item : items) {
    if (!csv.isEmpty()) csv += ",";
    csv += item;
}

// 直した後 その1:区切り文字でつなぐだけなら String.join
String csv1 = String.join(",", items);

// 直した後 その2:ループの中で加工しながら足すなら StringBuilder
StringBuilder sb = new StringBuilder();
for (String item : items) {
    if (sb.length() > 0) sb.append(",");
    sb.append(item.trim());
}
String csv2 = sb.toString();

この3つの断片は形の説明用で、単独では実行していません(未実行)。同じ形のコードは、計測プログラムとコピペ集の12番で実行しています。

+はコンパイル後にどう変換されるか

「+ は内部で StringBuilder に変換される」と説明されることがありますが、Java 9 以降は invokedynamic という命令に変換されます(JEP 280: Indify String Concatenation。今回 JEP のページを開いて、Java 9 で入ったことを確かめました)。Java 21 で実際に確かめます。

public class Plus {
    static String two(String a, int n) {
        return "id=" + a + ", n=" + n;
    }

    static final String CONST = "a" + "b" + 1;

    static String loop(String[] xs) {
        String s = "";
        for (String x : xs) {
            s += x;
        }
        return s;
    }
}

javac Plus.java のあと javap -c -p Plus で two メソッドを見た結果(Java 21):

  static java.lang.String two(java.lang.String, int);
    Code:
       0: aload_0
       1: iload_1
       2: invokedynamic #7,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
       7: areturn

StringBuilder は出てこず、makeConcatWithConstants という呼び出し1つになっています。「id=」「, n=」という固定の部分はひな形としてクラスファイルに記録され、実行時に java.lang.invoke.StringConcatFactory が、そのひな形と引数から連結の処理を組み立てます。どう組み立てるかは Java の実行環境側に任されているので、コンパイルし直さなくても、実行環境が新しくなれば連結の処理も新しくなるしくみです。

同じファイルを javac --release 8 Plus.java でコンパイルすると、Java 8 向けの形になります。two メソッドは次のようになりました(Java 21 の javac で実行。途中の行は省略し、コメント部分も短くしています)。

       0: new           #7    // class java/lang/StringBuilder
       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;)...
      (append の呼び出しがあと3回続く)
      25: invokevirtual #21   // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      28: areturn

こちらは「new StringBuilder → append を4回 → toString」という並びです。

あわせて、次の2点も javap で確かめました。

書き方 コンパイル後
"a" + "b" + 1(定数だけの連結) コンパイル時に "ab1" という1つの定数になる(javap -v で ConstantValue: String ab1 と表示)
ループの中の s += x ループ1回ごとに makeConcatWithConstants を呼ぶ(ループ全体をまとめてはくれない)

ここから言えることは2つです。 1つの式の中で + を使うぶんには、コンパイラと実行環境が効率よく処理してくれるので、わざわざ StringBuilder に書き換える必要はありません。一方、ループの中の += は1回ごとに新しい文字列を作る点が変わらないので、計測結果のとおり StringBuilder や String.join に替える効果があります。

String.format・formatted・テキストブロックの使い分け

値を「はめ込む」形で文字列を作りたいときは、format 系を使います。

public class Fmt {
    public static void main(String[] args) {
        String name = "田中";
        int count = 3;
        double total = 1234.5;

        System.out.println(String.format("%sさんの注文は%d件、合計%,.1f円", name, count, total));
        System.out.println("%sさんの注文は%d件".formatted(name, count));
        System.out.println(String.format("%05d|%-6s|%6s|", 42, "ab", "cd"));

        String mail = """
                %sさん
                ご注文は%d件です。
                """.formatted(name, count);
        System.out.print(mail);

        String sql = """
                SELECT id, name \
                FROM users
                WHERE id = 1""";
        System.out.println(sql);
    }
}

実行結果:

田中さんの注文は3件、合計1,234.5円
田中さんの注文は3件
00042|ab    |    cd|
田中さん
ご注文は3件です。
SELECT id, name FROM users
WHERE id = 1
書式 意味 例と結果
%s 文字列(null は "null") "%s".formatted("ab") → ab
%d 整数 %d に 3 → 3
%05d 5桁になるよう0で埋める 42 → 00042
%,.1f 3桁区切り + 小数1桁 1234.5 → 1,234.5
%-6s 6文字幅で左寄せ ab のあとに空白4つ
%6s 6文字幅で右寄せ 空白4つのあとに cd
%n 改行 実行環境に合った改行
  • String.format と formatted は同じ結果になります。 formatted は Java 15 以降で使え、書式の文字列の後ろに続けて書けるので、テキストブロックと相性がよい書き方です。
  • テキストブロック(ダブルクォート3つで囲む)は Java 15 以降で使えます。行頭の共通の字下げは自動で取り除かれ、行末にバックスラッシュを置くと「そこでは改行しない」という意味になります。上の SQL の例では、1行目と2行目がつながって出力されました。
  • テキストブロックは「連結の代わりに複数行をそのまま書く」ためのものです。"SELECT ...\n" + "FROM ...\n" + ... と + で1行ずつつないでいたコードを、見たままの形で書けます。

なお、SQL に値を入れるときは、文字列の連結や format ではなく PreparedStatement のプレースホルダ(?)を使います。連結で SQL を組み立てると、入力された値が SQL の一部として解釈されてしまうおそれがあるためです。

どれを使うかの判断表

状況 選ぶ方法 理由
2〜数個の値を1行でつなぐ + 短く読みやすい。Java 9 以降は実行環境が効率よく処理する
文字列のリスト・配列を区切り文字でつなぐ String.join 1行で書け、先頭や末尾に余分な区切りが付かない
数値のリスト、絞り込みや変換をしてからつなぐ Collectors.joining Stream の map・filter と組み合わせられる
前後にかっこなどを付けたい Collectors.joining(区切り, 前, 後)、StringJoiner 前後の文字を引数で指定できる
ループの中で条件に応じて足していく StringBuilder 計測で 100,000個でも約 1.2 ms
1つの入れ物を複数スレッドから使う StringBuffer append が同期される
桁そろえ・0埋め・小数の桁数 String.format、formatted 書式で形を指定できる
複数行の定型文 テキストブロック + formatted 見たままの形で書ける
null が来るかもしれない2つをつなぐ +、または事前に Objects.toString(s, "") concat は NullPointerException になる

よくある質問

Q. +とStringBuilderはどちらを使えばよいですか? A. 1つの式の中で数個をつなぐなら + で十分です。Java 9 以降、+ は invokedynamic に変換され、実行環境が効率よく処理します。ループの中で少しずつ足していく場合は StringBuilder を使います。今回の計測では、100,000個の連結で += が約 5.9 秒、StringBuilder が約 1.2 ms でした。

Q. String.joinとCollectors.joiningの違いは何ですか? A. String.join は文字列のリストや配列を区切り文字でつなぐだけのシンプルなメソッドです。Collectors.joining は Stream の最後に使うもので、map で数値を文字列に変換したり、filter で null を除いたりしてからつなげます。前後に付ける文字も指定できます。

Q. StringBuilderとStringBufferの違いは何ですか? A. 使い方は同じで、StringBuffer は各メソッドに同期処理が付いています。1つのスレッドの中で使うなら StringBuilder、同じ入れ物を複数のスレッドから同時に使うなら StringBuffer を選びます。今回の計測では 100,000個の連結で StringBuilder が 1.200 ms、StringBuffer が 2.554 ms でした。

Q. nullを連結するとどうなりますか? A. +、+=、StringBuilder の append、String.join の要素、format の %s では「null」という4文字になります。concat に null を渡した場合や、区切り文字・書式・リストそのものが null の場合は NullPointerException になります。「null」と出したくないときは、Objects.toString(s, "") で空文字に置き換えるか、filter(Objects::nonNull) で取り除きます。

まとめ

  • 数個をつなぐなら +、リストを区切り文字でつなぐなら String.join、ループで足すなら StringBuilder が基本です。
  • 数値のリストは String.join にそのまま渡せません。 map(String::valueOf) と Collectors.joining を使います。
  • 前後にかっこを付けるなら Collectors.joining(", ", "[", "]") か StringJoiner、null や空文字は filter で除きます。
  • つなぐ中身の null は "null" という文字になり、concat や、区切り文字・書式が null の場合は NullPointerException になります。
  • ループの += と concat は、個数が10倍で時間が約100倍になりました(100,000個で約 5.9 秒)。StringBuilder は約 1.2 ms でした。
  • + は Java 9 以降 invokedynamic(makeConcatWithConstants)に変換されます。 定数だけの連結はコンパイル時に1つの定数になります。
  • 桁そろえは String.format / formatted、複数行の定型文はテキストブロックが向いています。

参考

  • Java SE 21 API ドキュメント「Class String」(join・formatted・concat の説明): https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html
  • JEP 280: Indify String Concatenation: https://openjdk.org/jeps/280

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