NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

最近のJDKで文字列操作をするときの最適解について検証してみる

本記事は NRIネットコム Advent Calendar 2022 3日目の記事です。
🎁 2日目 ▶▶本記事 ▶▶ 4日目 🎄

はじめに

石橋です。2回目の投稿になります。

今回は、Javaを使う上で切っても切り離せない文字列操作についてです。
Java17 を使った場合どれを使うのがいいのかを、いくつかのケースから性能面、使いやすさの観点で検証したいと思います。

検証内容は以下の3つとします。

  1. 大量の文字列連結
  2. フォーマット形式の文字列
  3. ログ出力

検証環境

検証に使用したPCは以下のものとなります。

  • MacBook Pro 2020
  • Core i5 2 GHz
  • Memory 32 GB 3733 MHz
  • macOS Catalina

環境

  • Eclipse 2022-06 (4.24.0)
  • OpenJDK Runtime Environment Corretto-17.0.1.12.1
  • 検証用プログラム起動時オプション ( -Xms4096M -Xmx4096M )

計測時の共通事項

  • 検証実行前に System.gc() を入れて強制的にGCします(いくつかのケースを纏めて実行するので公平性を考えて)
  • 開始前後の System.currentTimeMillis() を取得し、実行時間 (ミリ秒) を計算します
  • 開始前後の Runtime.getRuntime().freeMemory() を取得し、空きメモリの消費量を計算します
    • 本来は Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() から使用メモリ量を計算し、それを使って消費量を見ますが、-Xmx で totalMemory は固定しているため freeMemory だけで計算しています
  • 5回実行したうちの中央値で比較します

1. 大量の文字列連結

文字列を大量に連結して大きな1つの文字列を作るパターンです。

  • 「1234567890」 という 10 byte の文字列を 10 万回結合して、1 MByte の文字列を作成します
  • ケースは以下の4つです
    • +で結合
    • concat
    • StringBuilder
    • StringBuffer

計測

ケース 実行時間(ミリ秒) 消費メモリ(MB)
+で結合 10,938 1,247.2
concat 4,311 1949.8
StringBuilder 4 3.6
StringBuffer 5 3.6

見やすさ

検証で作成した場合。

// +で結合
str += "1234567890"

// concat
str = str.concat("1234567890");

// StringBuilder/StringBuffer
str.append("1234567890");

3つの文字列を連結する場合。

// +で結合
str = "123" + "456" + "7890";
//   or
str += "123";
str += "456";
str += "7890";

// concat
str = str.concat("123").concat("456").concat("7890");
//   or
str = str.concat("123");
str = str.concat("456");
str = str.concat("7890");

// StringBuilder/StringBuffer
str.append("123").append("456").append("7890");
//   or
str.append("123");
str.append("456");
str.append("7890");

検証結果

  • 手軽に結合するなら + 結合で十分
  • 大量に繰り返し結合する、メモリを気にするなら今も StringBuilder

速度面では圧倒的に StringBuilder , StringBuffer が早い結果となりました。 とはいえ、10 万回繰り返して実行しているので、1000 回ぐらいにすると+結合でも数十ミリ秒ぐらいなので気にならないレベルかと思います。
それより気にすべきはメモリ消費量。
+結合とconcat結合は明らかにメモリ消費量が大きい結果となりました。
これも 10 万回繰り返しているからというのもありますが、1000 回ぐらいにしても+結合とStringBuilder で 9 倍ほどメモリ消費に差がありました。
見やすさとしては、やはり+結合のほうが余計なコードも無いので見やすく、concat は値を代入しないといけないので StringBuilder よりコード量が少し長くなります。

以上のことから、大した量の文字列連結をしないならどれでもよく、文字列連結をループで何度も実行する場合やメモリに少しでも優しいものを作りたい場合は StringBuilder かなと思います。 concat は使う理由は特にないかと思います。 StringBuffer はスレッドセーフな実装が必要な時ぐらいだけですね。

2. フォーマット形式の文字列

文字列を特定のフォーマット形式で出力するパターンです。 例えば、カンマ区切り、○○○円、user=xxxxx&id=yyyyy といったものです。 今回は、「abc,def,ghi」というカンマ区切りの文字列を 10 万回生成します。 なお、文字列結合はおこなわず、ただひたすら「abc,def,ghi」を作るのみにしています。 結合すると上述のような性能差が生まれるため、実際使用時に結合が必要な場合は結合時の性能や使いやすさも鑑みて使用していただけばと思います。

ケースは以下の5つです * +で結合 * String.join * String.format * org.apache.commons.lang3.StringUtils.join * StringJoiner

計測

ケース 実行時間(ミリ秒) 消費メモリ(MB)
+で結合 2 1.0
String.join 31 12.0
String.format 115 41.0
StringUtils.join 31 16.0
StringJoiner 12 9.0

見やすさ

// +で結合
"abc" + "," + "def" + "," + "ghi";

// String.join
String.join(",", "abc", "def", "ghi");

// String.format
String.format("%s,%s,%s", "abc", "def", "ghi");

// org.apache.commons.lang3.StringUtils.join
StringUtils.join(new String[] { "abc", "def", "ghi" }, ",");

// StringJoiner
var str = new StringJoiner(",");
str.add("abc").add("def").add("ghi").toString();

検証結果

  • 見やすさ度外視で性能重視なら + 結合 ( StringBuilder ですればさらに良くなる )
  • 見やすさ重視なら、単純な区切り文字ならString.join、汎用性があるのは String.format

今回はシンプルな結合だけしている+結合が性能面では最も良い結果となった。 ただ、今回は3列のCSVデータの例ですが、これが20列あったりしたら恐らく発狂します。

// + で結合
"abc" + "," + "def" + "," + "ghi" + "," + "jkl" + "," + "mno" + "," + "pqr" + "," + "stu" + "," + "vwx" + "," + "yz";
// String.join
String.join(",", "abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz");

コードは短いに越したことはない。

3. ログ出力

最後にログ出力です。
ログ出力の中でも、定型文と変数を組み合せて表示するようなことがよくあるかと思います。
ここではfor 文でループさせて「これは 111 回目の出力です」というログを出力します。111 の部分が変数になります。 10 万回コンソール出力した時間を計測します。
ロギングのライブラリは色々種類があるのですが、今回は種類ではなく出力形式による差を求めたいので、使用するものは org.slf4j.Logger のみとします。

ケースは以下の3つです。 * +結合 * String.format * Argument

計測

ケース 実行時間(ミリ秒) 消費メモリ(MB)
+で結合 537 77.0
String.join 599 113.0
Argument 668 104.0

見やすさ

// Lombok の @Slf4j を使用

// + で結合
log.info("これは " + i + " 回目の出力です");

// String.format
log.info(String.format("これは %s 回目の出力です", i));

// Argument
log.info("これは {} 回目の出力です", i);

検証結果

  • org.slf4j.Logger なら Argument {} を使うのがベター

10万回出力しても各ケースでの性能面ではほとんど差がありませんでした。となると見やすさ勝負。
ついつい + 結合をしてしまいがちですが、{} を使って変換させる方法が個人的には一番スッキリしていいと思っています。

最後に

コードを書いて出来上がるものは1つでも、それを実装する方法はいくつもあります。
そのときの状況次第では、選択した実装が思わぬ速度劣化やメモリ消費を起こすことがあるので、今回のような文字列操作に限らず、開発する方は気にして頂ければ幸いです。

余談

最初の画像に映っているもの(Dukeじゃなく)が懐かしいと感じる方はどれぐらいいてはります?(笑)
ちなみに、この写真は 2013 年に会社からサンフランシスコへ1ヶ月間研修に行かせてもらったときに私が撮ったものです。 ちょうど一眼レフを始めた年でした。

検証で書いたコードの一部

(急いで作ってたので変数名やメソッド名はかなり雑です)

@Slf4j
public class StringMetrics {
    private static final long LOOP = 100000;

    public static void main(String[] args) {
        plus();
        // 他の検証用メソッドがここに並ぶ
    }

    private static void plus() {
        System.out.println("plus");
        System.gc();  // お掃除
        var start = System.currentTimeMillis();
        var before = getMemory();
        var str = "";
        for (var i = 0; i < LOOP; i++)
            str += "1234567890";

        // 計測結果
        memoryInfo(before);
        time(start);
        // 正しく結合されているかを文字列のサイズでチェック
        System.out.println(str.length());
        str = null;
        System.out.println("");
    }

    private static void memoryInfo(long before) {
        var after = getMemory();
        System.out.println(String.format("Used Memory=%,d", before - after));
    }

    private static void time(long start) {
        var end = System.currentTimeMillis();
        System.out.println(String.format("処理時間=%,d", end - start));
    }

    private static long getMemory() {
        return Runtime.getRuntime().freeMemory();
    }
}

執筆者石橋章太郎

アプリケーションエンジニア。
Java の開発が好物です。
趣味はカメラ・ドライブ・旅行。