Java 8:Class.getName()が文字列連結チェーンを遅くする


13

最近、文字列の連結に関する問題に遭遇しました。このベンチマークはそれを要約します:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class BrokenConcatenationBenchmark {

  @Benchmark
  public String slow(Data data) {
    final Class<? extends Data> clazz = data.clazz;
    return "class " + clazz.getName();
  }

  @Benchmark
  public String fast(Data data) {
    final Class<? extends Data> clazz = data.clazz;
    final String clazzName = clazz.getName();
    return "class " + clazzName;
  }

  @State(Scope.Thread)
  public static class Data {
    final Class<? extends Data> clazz = getClass();

    @Setup
    public void setup() {
      //explicitly load name via native method Class.getName0()
      clazz.getName();
    }
  }
}

JDK 1.8.0_222(OpenJDK 64ビットサーバーVM、25.222-b10)では、次の結果が得られます。

Benchmark                                                            Mode  Cnt     Score     Error   Units
BrokenConcatenationBenchmark.fast                                    avgt   25    22,253 ±   0,962   ns/op
BrokenConcatenationBenchmark.fastgc.alloc.rate                     avgt   25  9824,603 ± 400,088  MB/sec
BrokenConcatenationBenchmark.fastgc.alloc.rate.norm                avgt   25   240,000 ±   0,001    B/op
BrokenConcatenationBenchmark.fastgc.churn.PS_Eden_Space            avgt   25  9824,162 ± 397,745  MB/sec
BrokenConcatenationBenchmark.fastgc.churn.PS_Eden_Space.norm       avgt   25   239,994 ±   0,522    B/op
BrokenConcatenationBenchmark.fastgc.churn.PS_Survivor_Space        avgt   25     0,040 ±   0,011  MB/sec
BrokenConcatenationBenchmark.fastgc.churn.PS_Survivor_Space.norm   avgt   25     0,001 ±   0,001    B/op
BrokenConcatenationBenchmark.fastgc.count                          avgt   25  3798,000            counts
BrokenConcatenationBenchmark.fastgc.time                           avgt   25  2241,000                ms

BrokenConcatenationBenchmark.slow                                    avgt   25    54,316 ±   1,340   ns/op
BrokenConcatenationBenchmark.slowgc.alloc.rate                     avgt   25  8435,703 ± 198,587  MB/sec
BrokenConcatenationBenchmark.slowgc.alloc.rate.norm                avgt   25   504,000 ±   0,001    B/op
BrokenConcatenationBenchmark.slowgc.churn.PS_Eden_Space            avgt   25  8434,983 ± 198,966  MB/sec
BrokenConcatenationBenchmark.slowgc.churn.PS_Eden_Space.norm       avgt   25   503,958 ±   1,000    B/op
BrokenConcatenationBenchmark.slowgc.churn.PS_Survivor_Space        avgt   25     0,127 ±   0,011  MB/sec
BrokenConcatenationBenchmark.slowgc.churn.PS_Survivor_Space.norm   avgt   25     0,008 ±   0,001    B/op
BrokenConcatenationBenchmark.slowgc.count                          avgt   25  3789,000            counts
BrokenConcatenationBenchmark.slowgc.time                           avgt   25  2245,000                ms

これは、JDK-8043677と同様の問題のように見えます。この場合、副作用のある式が新しいStringBuilder.append().append().toString()チェーンの最適化を中断します。しかし、Class.getName()それ自体のコードには副作用がないようです:

private transient String name;

public String getName() {
  String name = this.name;
  if (name == null) {
    this.name = name = this.getName0();
  }

  return name;
}

private native String getName0();

ここで唯一疑わしいのは、実際には1回だけ発生するネイティブメソッドの呼び出しであり、その結果はクラスのフィールドにキャッシュされます。私のベンチマークでは、それをセットアップメソッドに明示的にキャッシュしました。

私はブランチプレディクターが各ベンチマーク呼び出しでthis.nameの実際の値がnullになることはなく、式全体を最適化することを理解することを期待していました。

しかし、BrokenConcatenationBenchmark.fast()私にとってはこれがあります:

@ 19   tsypanov.strings.benchmark.concatenation.BrokenConcatenationBenchmark::fast (30 bytes)   force inline by CompileCommand
  @ 6   java.lang.Class::getName (18 bytes)   inline (hot)
    @ 14   java.lang.Class::initClassName (0 bytes)   native method
  @ 14   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
  @ 19   java.lang.StringBuilder::append (8 bytes)   inline (hot)
  @ 23   java.lang.StringBuilder::append (8 bytes)   inline (hot)
  @ 26   java.lang.StringBuilder::toString (35 bytes)   inline (hot)

つまり、コンパイラはすべてをインライン化できます。BrokenConcatenationBenchmark.slow()それが異なるためです。

@ 19   tsypanov.strings.benchmark.concatenation.BrokenConcatenationBenchmark::slow (28 bytes)   force inline by CompilerOracle
  @ 9   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
    @ 3   java.lang.AbstractStringBuilder::<init> (12 bytes)   inline (hot)
      @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
  @ 14   java.lang.StringBuilder::append (8 bytes)   inline (hot)
    @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   inline (hot)
      @ 10   java.lang.String::length (6 bytes)   inline (hot)
      @ 21   java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)   inline (hot)
        @ 17   java.lang.AbstractStringBuilder::newCapacity (39 bytes)   inline (hot)
        @ 20   java.util.Arrays::copyOf (19 bytes)   inline (hot)
          @ 11   java.lang.Math::min (11 bytes)   (intrinsic)
          @ 14   java.lang.System::arraycopy (0 bytes)   (intrinsic)
      @ 35   java.lang.String::getChars (62 bytes)   inline (hot)
        @ 58   java.lang.System::arraycopy (0 bytes)   (intrinsic)
  @ 18   java.lang.Class::getName (21 bytes)   inline (hot)
    @ 11   java.lang.Class::getName0 (0 bytes)   native method
  @ 21   java.lang.StringBuilder::append (8 bytes)   inline (hot)
    @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   inline (hot)
      @ 10   java.lang.String::length (6 bytes)   inline (hot)
      @ 21   java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)   inline (hot)
        @ 17   java.lang.AbstractStringBuilder::newCapacity (39 bytes)   inline (hot)
        @ 20   java.util.Arrays::copyOf (19 bytes)   inline (hot)
          @ 11   java.lang.Math::min (11 bytes)   (intrinsic)
          @ 14   java.lang.System::arraycopy (0 bytes)   (intrinsic)
      @ 35   java.lang.String::getChars (62 bytes)   inline (hot)
        @ 58   java.lang.System::arraycopy (0 bytes)   (intrinsic)
  @ 24   java.lang.StringBuilder::toString (17 bytes)   inline (hot)

問題は、これがJVMの適切な動作なのか、コンパイラのバグなのかということです。

一部のプロジェクトがまだJava 8を使用しており、リリースの更新で修正されない場合はClass.getName()、ホットスポットから手動で呼び出しをホイストするのが理にかなっているので、質問します。

PS最新のJDK(11、13、14-eap)では、問題は再現されません。


そこに副作用があります-への割り当てthis.name
RealSkeptic、

@RealSkeptic割り当ては、最初に呼び出されたときClass.getName()setUp()メソッド内で1回だけ行われ、ベンチマーク対象の本体では行われません。
セルゲイTsypanov

回答:


7

HotSpot JVMは、バイトコードごとに実行統計を収集します。同じコードが異なるコンテキストで実行される場合、結果プロファイルはすべてのコンテキストの統計を集約します。この影響はプロファイル汚染として知られています

Class.getName()ベンチマークコードからだけでなく、明らかに呼び出されます。JITはベンチマークのコンパイルを開始する前に、次の条件Class.getName()が複数回満たされたことをすでに認識しています。

    if (name == null)
        this.name = name = getName0();

少なくとも、統計的に重要なこのブランチを扱うのに十分な時間。したがって、JITはこのブランチをコンパイルから除外しなかったため、副作用の可能性があるため、文字列連結を最適化できませんでした。

これは、ネイティブのメソッド呼び出しである必要さえありません。通常のフィールド割り当てだけでも副作用と見なされます。

これは、プロファイルの汚染がさらなる最適化に悪影響を与える可能性がある例です。

@State(Scope.Benchmark)
public class StringConcat {
    private final MyClass clazz = new MyClass();

    static class MyClass {
        private String name;

        public String getName() {
            if (name == null) name = "ZZZ";
            return name;
        }
    }

    @Param({"1", "100", "400", "1000"})
    private int pollutionCalls;

    @Setup
    public void setup() {
        for (int i = 0; i < pollutionCalls; i++) {
            new MyClass().getName();
        }
    }

    @Benchmark
    public String fast() {
        String clazzName = clazz.getName();
        return "str " + clazzName;
    }

    @Benchmark
    public String slow() {
        return "str " + clazz.getName();
    }
}

これは基本的に、getName()プロファイルの汚染をシミュレートするベンチマークの変更バージョンです。getName()新しいオブジェクトに対する予備呼び出しの数に応じて、文字列連結のパフォーマンスが大幅に異なる場合があります。

Benchmark          (pollutionCalls)  Mode  Cnt   Score   Error  Units
StringConcat.fast                 1  avgt   15  11,458 ± 0,076  ns/op
StringConcat.fast               100  avgt   15  11,690 ± 0,222  ns/op
StringConcat.fast               400  avgt   15  12,131 ± 0,105  ns/op
StringConcat.fast              1000  avgt   15  12,194 ± 0,069  ns/op
StringConcat.slow                 1  avgt   15  11,771 ± 0,105  ns/op
StringConcat.slow               100  avgt   15  11,963 ± 0,212  ns/op
StringConcat.slow               400  avgt   15  26,104 ± 0,202  ns/op  << !
StringConcat.slow              1000  avgt   15  26,108 ± 0,436  ns/op  << !

プロファイル汚染のその他の例»

私はそれをバグとも「適切な行動」とも呼ぶことはできません。これは、HotSpotでダイナミックアダプティブコンパイルを実装する方法です。


1
パンギンでなければ他に誰...グラールC2に同じ病気があるか知っていますか?
ユージーン

1

少しは無関係ですが、Java 9とJEP 280以降:文字列の連結を示す文字列の連結はinvokedynamic、ではなくで行われるようになりましたStringBuilderこの記事では、Java 8とJava 9のバイトコードの違いを示します。

新しいJavaバージョンでベンチマークを再実行しても問題が示されない場合はjavac、コンパイラーが新しいメカニズムを使用しているため、バグはほとんどありません。新しいバージョンでこのような大幅な変更があった場合に、Java 8の動作に飛び込むことが有益かどうかはわかりません。


1
これはコンパイラの問題であり、関連する問題ではない可能性が高いことに同意しjavacます。javacバイトコードを生成し、高度な最適化を行いません。私は同じベンチマークを実行して-XX:TieredStopAtLevel=1この出力を受け取りました: Benchmark Mode Cnt Score Error Units BrokenConcatenationBenchmark.fast avgt 25 74,677 ? 2,961 ns/op BrokenConcatenationBenchmark.slow avgt 25 69,316 ? 1,239 ns/op したがって、両方の方法をあまり最適化しないと、同じ結果が得られますが、コードがC2コンパイルされたときにのみ問題が明らかになります。
セルゲイTsypanov

1
今のStringBuilderはinvokedynamicのといないで行われるだけである間違っていますランタイムに連結の方法を選択するように通知するinvokedynamicだけで、6つの戦略のうち5つ(デフォルトを含む)は引き続きを使用します。StringBuilder
ユージーン

@Eugeneこれを指摘してくれてありがとう。戦略とは、StringConcatFactory.Strategy列挙型を意味しますか?
Karol Dowbecki

@KarolDowbecki正確に。
ユージーン
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.