1. 序章

このチュートリアルでは、 Java StringAPIのパフォーマンスの側面に焦点を当てます。

String の作成、変換、および変更操作を掘り下げて、使用可能なオプションを分析し、それらの効率を比較します。

これから行う提案は、必ずしもすべてのアプリケーションに適しているとは限りません。 ただし、確かに、アプリケーションの実行時間が重要な場合にパフォーマンスを向上させる方法を示します。

2. 新しい文字列の作成

ご存知のように、Javaでは文字列は不変です。 したがって、 String オブジェクトを構築または連結するたびに、Javaは新しい String – を作成します。これは、ループで実行すると特にコストがかかる可能性があります。

2.1。 コンストラクターの使用

ほとんどの場合、何をしているのかわからない限り、コンストラクターを使用して文字列を作成することは避けてください

最初にnewString()コンストラクターを使用して、次に = 演算子を使用して、ループ内にnewStringオブジェクトを作成しましょう。

ベンチマークを作成するには、 JMH (Java Microbenchmark Harness)ツールを使用します。

私たちの構成:

@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Measurement(batchSize = 10000, iterations = 10)
@Warmup(batchSize = 10000, iterations = 10)
public class StringPerformance {
}

ここでは、メソッドを1回だけ実行するSingeShotTimeモードを使用しています。 ループ内のString操作のパフォーマンスを測定したいので、@Measurementアノテーションを使用できます。

ベンチマークループがテストで直接行われると、JVM によってさまざまな最適化が適用されるため、結果が歪む可能性があることを知っておくことが重要です。

したがって、単一の操作のみを計算し、JMHにループを処理させます。 簡単に言えば、JMHはbatchSizeパラメーターを使用して反復を実行します。

それでは、最初のマイクロベンチマークを追加しましょう。

@Benchmark
public String benchmarkStringConstructor() {
    return new String("baeldung");
}

@Benchmark
public String benchmarkStringLiteral() {
    return "baeldung";
}

最初のテストでは、反復ごとに新しいオブジェクトが作成されます。 2番目のテストでは、オブジェクトは1回だけ作成されます。 残りの反復では、同じオブジェクトがStringの定数プールから返されます。

ループ反復回数count= 1,000,000 を使用してテストを実行し、結果を確認してみましょう。

Benchmark                   Mode  Cnt  Score    Error     Units
benchmarkStringConstructor  ss     10  16.089 ± 3.355     ms/op
benchmarkStringLiteral      ss     10  9.523  ± 3.331     ms/op

Score の値から、違いが大きいことがはっきりとわかります。

2.2. +オペレーター

動的なString連結の例を見てみましょう。

@State(Scope.Thread)
public static class StringPerformanceHints {
    String result = "";
    String baeldung = "baeldung";
}

@Benchmark
public String benchmarkStringDynamicConcat() {
    return result + baeldung;
}

結果では、平均実行時間を確認したいと思います。 出力数値形式はミリ秒に設定されています。

Benchmark                       1000     10,000
benchmarkStringDynamicConcat    47.331   4370.411

それでは、結果を分析してみましょう。 ご覧のとおり、1000アイテムをstate.resultに追加するには、47.331ミリ秒かかります。 その結果、反復回数を10倍に増やすと、実行時間は4370.441ミリ秒に増加します。

要約すると、実行時間は2倍に増加します。 したがって、n回の反復のループにおける動的連結の複雑さは O(n ^ 2)です。

2.3. String.concat()

Strings を連結するもう1つの方法は、 concat()メソッドを使用することです。

@Benchmark
public String benchmarkStringConcat() {
    return result.concat(baeldung);
}

出力時間の単位はミリ秒で、反復回数は100,000です。 結果テーブルは次のようになります。

Benchmark              Mode  Cnt  Score     Error     Units
benchmarkStringConcat    ss   10  3403.146 ± 852.520  ms/op

2.4. String.format()

文字列を作成する別の方法は、 String.format()メソッドを使用することです。 内部では、正規表現を使用して入力を解析します。

JMHテストケースを書いてみましょう。

String formatString = "hello %s, nice to meet you";

@Benchmark
public String benchmarkStringFormat_s() {
    return String.format(formatString, baeldung);
}

その後、それを実行して結果を確認します。

Number of Iterations      10,000   100,000   1,000,000
benchmarkStringFormat_s   17.181   140.456   1636.279    ms/op

String.format()を使用したコードは、よりクリーンで読みやすいように見えますが、パフォーマンスの点ではここでは勝ちません。

2.5. StringBuilderおよびStringBuffer

StringBufferStringBuilderを説明するwrite-upがすでにあります。 したがって、ここでは、それらのパフォーマンスに関する追加情報のみを示します。  StringBuilder は、サイズ変更可能な配列と、配列で使用された最後のセルの位置を示すインデックスを使用します。 配列がいっぱいになると、サイズが2倍に拡張され、すべての文字が新しい配列にコピーされます。

サイズ変更があまり頻繁に発生しないことを考慮すると、各append()操作をO(1)定数時間と見なすことができます。 これを考慮に入れると、プロセス全体に O(n)の複雑さがあります。

StringBufferおよびStringBuilderの動的連結テストを変更して実行すると、は次のようになります。

Benchmark               Mode  Cnt  Score   Error  Units
benchmarkStringBuffer   ss    10  1.409  ± 1.665  ms/op
benchmarkStringBuilder  ss    10  1.200  ± 0.648  ms/op

スコアの差はそれほど大きくありませんが、StringBuilderの動作が高速であることがわかります

幸い、単純なケースでは、1つのStringを別のStringに配置するためにStringBuilderは必要ありません。 時々、 +を使用した静的連結は、実際にはStringBuilderを置き換えることができます。 内部的には、最新のJavaコンパイラはStringBuilder.append()を呼び出して文字列を連結します

これは、パフォーマンスで大幅に勝つことを意味します。

3. ユーティリティ操作

3.1. StringUtils.replace() String.replace()

興味深いことに、文字列を置き換えるための Apache Commonsバージョンは、文字列自体のreplace()メソッドよりもはるかに優れています。 この違いに対する答えは、それらの実装にあります。 String.replace()は、正規表現パターンを使用してString。と一致します。

対照的に、 StringUtils.replace()は、より高速な indexOf()を広く使用しています。

さて、ベンチマークテストの時間です:

@Benchmark
public String benchmarkStringReplace() {
    return longString.replace("average", " average !!!");
}

@Benchmark
public String benchmarkStringUtilsReplace() {
    return StringUtils.replace(longString, "average", " average !!!");
}

batchSize を100,000に設定すると、次の結果が表示されます。

Benchmark                     Mode  Cnt  Score   Error   Units
benchmarkStringReplace         ss   10   6.233  ± 2.922  ms/op
benchmarkStringUtilsReplace    ss   10   5.355  ± 2.497  ms/op

数値の差はそれほど大きくありませんが、 StringUtils.replace()の方がスコアが高くなります。 もちろん、数とそれらの間のギャップは、反復回数、文字列の長さ、さらにはJDKバージョンなどのパラメーターによって異なる場合があります。

最新のJDK9+(テストはJDK 10で実行されています)バージョンでは、両方の実装でほぼ同じ結果が得られます。 それでは、JDKバージョンを8にダウングレードして、もう一度テストしてみましょう。

Benchmark                     Mode  Cnt   Score    Error     Units
benchmarkStringReplace         ss   10    48.061   ± 17.157  ms/op
benchmarkStringUtilsReplace    ss   10    14.478   ±  5.752  ms/op

現在、パフォーマンスの違いは非常に大きく、最初に説明した理論を裏付けています。

3.2. split()

始める前に、Javaで利用可能な文字列分割メソッドを確認すると便利です。

区切り文字で文字列を分割する必要がある場合、最初に頭に浮かぶ関数は通常 String.split(regex)です。 ただし、正規表現の引数を受け入れるため、パフォーマンスに重大な問題が発生します。 または、 StringTokenizer クラスを使用して、文字列をトークンに分割することもできます。

もう1つのオプションは、Guavaの SplitterAPIです。 最後に、正規表現の機能が必要ない場合は、古き良き indexOf()を使用して、アプリケーションのパフォーマンスを向上させることもできます。

次に、 String.split()オプションのベンチマークテストを作成します。

String emptyString = " ";

@Benchmark
public String [] benchmarkStringSplit() {
    return longString.split(emptyString);
}

Pattern.split()

@Benchmark
public String [] benchmarkStringSplitPattern() {
    return spacePattern.split(longString, 0);
}

StringTokenizer

List stringTokenizer = new ArrayList<>();

@Benchmark
public List benchmarkStringTokenizer() {
    StringTokenizer st = new StringTokenizer(longString);
    while (st.hasMoreTokens()) {
        stringTokenizer.add(st.nextToken());
    }
    return stringTokenizer;
}

String.indexOf()

List stringSplit = new ArrayList<>();

@Benchmark
public List benchmarkStringIndexOf() {
    int pos = 0, end;
    while ((end = longString.indexOf(' ', pos)) >= 0) {
        stringSplit.add(longString.substring(pos, end));
        pos = end + 1;
    }
    stringSplit.add(longString.substring(pos));
    return stringSplit;
}

グアバのスプリッター

@Benchmark
public List<String> benchmarkGuavaSplitter() {
    return Splitter.on(" ").trimResults()
      .omitEmptyStrings()
      .splitToList(longString);
}

最後に、 batchSize =100,000の結果を実行して比較します。

Benchmark                     Mode  Cnt    Score    Error    Units
benchmarkGuavaSplitter         ss   10    4.008  ± 1.836     ms/op
benchmarkStringIndexOf         ss   10    1.144  ± 0.322     ms/op
benchmarkStringSplit           ss   10    1.983  ± 1.075     ms/op
benchmarkStringSplitPattern    ss   10    14.891  ± 5.678    ms/op
benchmarkStringTokenizer       ss   10    2.277  ± 0.448     ms/op

ご覧のとおり、パフォーマンスが最も悪いのは BenchmarkStringSplitPattern メソッドで、Patternクラスを使用します。 その結果、 split()メソッドで正規表現クラスを使用すると、パフォーマンスが何度も低下する可能性があることがわかります。

同様に、最速の結果は、indexOf()とsplit()を使用した例を提供していることに気付きました。

3.3. 文字列に変換しています

このセクションでは、文字列変換の実行時スコアを測定します。 具体的には、 Integer.toString()連結メソッドを調べます。

int sampleNumber = 100;

@Benchmark
public String benchmarkIntegerToString() {
    return Integer.toString(sampleNumber);
}

String.valueOf()

@Benchmark
public String benchmarkStringValueOf() {
    return String.valueOf(sampleNumber);
}

[整数値]+“”

@Benchmark
public String benchmarkStringConvertPlus() {
    return sampleNumber + "";
}

String.format()

String formatDigit = "%d";

@Benchmark
public String benchmarkStringFormat_d() {
    return String.format(formatDigit, sampleNumber);
}

テストを実行すると、 batchSize =10,000の出力が表示されます。

Benchmark                     Mode  Cnt   Score    Error  Units
benchmarkIntegerToString      ss   10   0.953 ±  0.707  ms/op
benchmarkStringConvertPlus    ss   10   1.464 ±  1.670  ms/op
benchmarkStringFormat_d       ss   10  15.656 ±  8.896  ms/op
benchmarkStringValueOf        ss   10   2.847 ± 11.153  ms/op

結果を分析した後、 Integer.toString()のテストのスコアが0.953ミリ秒であることがわかります。 対照的に、 String.format(“ %d”)を含む変換は、パフォーマンスが最も低くなります。

フォーマットStringの解析はコストのかかる操作であるため、これは論理的です。

3.4. 文字列の比較

文字列を比較するさまざまな方法を評価してみましょう。反復回数は100,000です。

String.equals()操作のベンチマークテストは次のとおりです。

@Benchmark
public boolean benchmarkStringEquals() {
    return longString.equals(baeldung);
}

String.equalsIgnoreCase()

@Benchmark
public boolean benchmarkStringEqualsIgnoreCase() {
    return longString.equalsIgnoreCase(baeldung);
}

String.matches()

@Benchmark
public boolean benchmarkStringMatches() {
    return longString.matches(baeldung);
}

String.compareTo()

@Benchmark
public int benchmarkStringCompareTo() {
    return longString.compareTo(baeldung);
}

その後、テストを実行して結果を表示します。

Benchmark                         Mode  Cnt    Score    Error  Units
benchmarkStringCompareTo           ss   10    2.561 ±  0.899   ms/op
benchmarkStringEquals              ss   10    1.712 ±  0.839   ms/op
benchmarkStringEqualsIgnoreCase    ss   10    2.081 ±  1.221   ms/op
benchmarkStringMatches             ss   10    118.364 ± 43.203 ms/op

いつものように、数字はそれ自体を物語っています。 matches()は、正規表現を使用して等式を比較するため、最も時間がかかります。

対照的に、 equals()とequalsIgnoreCase()が最良の選択です

3.5.  String.matches()プリコンパイルされたパターン

それでは、 String.matches()パターンと Matcher.matches()パターンを別々に見てみましょう。 最初のものは、引数として正規表現を取り、実行する前にそれをコンパイルします。

したがって、 String.matches()を呼び出すたびに、 Pattern:がコンパイルされます。

@Benchmark
public boolean benchmarkStringMatches() {
    return longString.matches(baeldung);
}

2番目のメソッドは、Patternオブジェクトを再利用します。

Pattern longPattern = Pattern.compile(longString);

@Benchmark
public boolean benchmarkPrecompiledMatches() {
    return longPattern.matcher(baeldung).matches();
}

そして今、結果:

Benchmark                      Mode  Cnt    Score    Error   Units
benchmarkPrecompiledMatches    ss   10    29.594  ± 12.784   ms/op
benchmarkStringMatches         ss   10    106.821 ± 46.963   ms/op

ご覧のとおり、プリコンパイルされた正規表現とのマッチングは約3倍高速です。

3.6. 長さの確認

最後に、 String.isEmpty()メソッドを比較してみましょう。

@Benchmark
public boolean benchmarkStringIsEmpty() {
    return longString.isEmpty();
}

およびString.length()メソッド:

@Benchmark
public boolean benchmarkStringLengthZero() {
    return emptyString.length() == 0;
}

まず、 longString =“こんにちはbaeldung、平均して他のStringより少し長いです”Stringで呼び出します。batchSize10,000です。

Benchmark                  Mode  Cnt  Score   Error  Units
benchmarkStringIsEmpty       ss   10  0.295 ± 0.277  ms/op
benchmarkStringLengthZero    ss   10  0.472 ± 0.840  ms/op

その後、 longString =“” の空の文字列を設定して、テストを再実行しましょう。

Benchmark                  Mode  Cnt  Score   Error  Units
benchmarkStringIsEmpty       ss   10  0.245 ± 0.362  ms/op
benchmarkStringLengthZero    ss   10  0.351 ± 0.473  ms/op

お気づきのとおり、 branchmarkStringLengthZero()メソッドと branchmarkStringIsEmpty()メソッドは、どちらの場合もほぼ同じスコアです。 ただし、 isEmpty()の呼び出しは、文字列の長さがゼロであるかどうかを確認するよりも高速に機能します。

4. 文字列の重複排除

JDK 8以降、メモリ消費を排除するために文字列重複排除機能を使用できます。 簡単に言えば、このツールは、同じまたは重複した内容の文字列を探して、それぞれの個別の文字列値の1つのコピーを文字列プールに格納します。

現在、Stringの重複を処理する方法は2つあります。

  • String.intern()を手動で使用する
  • 文字列の重複排除を有効にする

各オプションを詳しく見てみましょう。

4.1. String.intern()

先に進む前に、記事で手動インターンについて読むと便利です。 String.intern()を使用すると、グローバル文字列プール内のStringオブジェクトの参照を手動で設定できます。

次に、JVMは必要に応じて参照を返すことができます。 パフォーマンスの観点から、私たちのアプリケーションは、定数プールからの文字列参照を再利用することで大きなメリットを得ることができます。

知っておくべき重要なこと JVM文字列プールはスレッドに対してローカルではありません。 プールに追加する各文字列は、他のスレッドでも使用できます

ただし、重大な欠点もあります。

  • アプリケーションを適切に維持するには、 -XX:StringTableSizeJVMパラメーターを設定してプールサイズを増やす必要がある場合があります。 プールサイズを拡張するには、JVMを再起動する必要があります
  • String.intern()を手動で呼び出すには時間がかかります O(n)の複雑さを持つ線形時間アルゴリズムで成長します
  • さらに、長い文字列オブジェクトを頻繁に呼び出すと、メモリの問題が発生する可能性があります

いくつかの証明された数値を取得するために、ベンチマークテストを実行してみましょう。

@Benchmark
public String benchmarkStringIntern() {
    return baeldung.intern();
}

さらに、出力スコアはミリ秒単位です。

Benchmark               1000   10,000  100,000  1,000,000
benchmarkStringIntern   0.433  2.243   19.996   204.373

ここでの列ヘッダーは、1000から1,000,000までのさまざまな反復カウントを表しています。 反復回数ごとに、テストパフォーマンススコアがあります。 お気づきのとおり、反復回数に加えてスコアが劇的に増加します。

4.2. 重複排除を自動的に有効にする

まず、このオプションはG1ガベージコレクターの一部です。デフォルトでは、この機能は無効になっています。 したがって、次のコマンドで有効にする必要があります。

 -XX:+UseG1GC -XX:+UseStringDeduplication

このオプションを有効にしても、文字列の重複排除が発生することは保証されないことに注意してください。 また、若いStringsを処理しません。 Stringsの処理の最小経過時間を管理するために、XX:StringDeduplicationAgeThreshold = 3JVMオプションを使用できます。 ここで、3がデフォルトのパラメータです。

5. 概要

このチュートリアルでは、日常のコーディング生活で文字列をより効率的に使用するためのヒントを提供しようとしています。

その結果、アプリケーションのパフォーマンスを向上させるためにいくつかの提案を強調することができます

  • 文字列を連結する場合、StringBuilderが最も便利なオプションです。 ただし、文字列が小さい場合、+操作のパフォーマンスはほぼ同じです。 内部的には、Javaコンパイラは StringBuilder クラスを使用して、文字列オブジェクトの数を減らすことができます。
  • 値を文字列に変換するには、 [some type] .toString() Integer.toString()など)は String.valueOf()[よりも高速に動作します。 X154X]。 その違いは重要ではないため、 String.valueOf()を自由に使用して、入力値の型に依存しないようにすることができます。
  • 文字列の比較に関しては、これまでのところ String.equals()に勝るものはありません。
  • String 重複排除により、大規模なマルチスレッドアプリケーションのパフォーマンスが向上します。 ただし、 String.intern()を使いすぎると、深刻なメモリリークが発生し、アプリケーションの速度が低下する可能性があります。
  • 文字列を分割するには、indexOf()を使用してパフォーマンスを向上させる必要があります。 ただし、重要ではない場合には、 String.split()関数が適している場合があります。
  • Pattern.match()を使用すると、文字列によってパフォーマンスが大幅に向上します
  • String.isEmpty()はString .length()==0よりも高速です

また、ここに示す数値は単なるJMHベンチマーク結果であることに注意してください。したがって、これらの種類の最適化の影響を判断するには、常に独自のシステムとランタイムの範囲でテストする必要があります。

最後に、いつものように、ディスカッション中に使用されたコードは、GitHubにあります。