Java 8: Class.getName () verlangsamt die Verkettungskette von Zeichenfolgen

13

Vor kurzem bin ich auf ein Problem mit der Verkettung von Zeichenfolgen gestoßen. Dieser Benchmark fasst es zusammen:

@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();
    }
  }
}

Unter JDK 1.8.0_222 (OpenJDK 64-Bit-Server-VM, 25.222-b10) habe ich die folgenden Ergebnisse:

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

Dies sieht nach einem ähnlichen Problem wie JDK-8043677 aus , bei dem ein Ausdruck mit Nebenwirkungen die Optimierung der neuen StringBuilder.append().append().toString()Kette unterbricht . Der Code Class.getName()selbst scheint jedoch keine Nebenwirkungen zu haben:

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();

Das einzig Verdächtige ist hier ein Aufruf der nativen Methode, der tatsächlich nur einmal vorkommt und dessen Ergebnis im Feld der Klasse zwischengespeichert wird. In meinem Benchmark habe ich es explizit in der Setup-Methode zwischengespeichert.

Ich habe erwartet, dass der Verzweigungsprädiktor herausfindet, dass bei jedem Benchmark-Aufruf der tatsächliche Wert von this.name niemals null ist, und den gesamten Ausdruck optimiert.

Während für das habe BrokenConcatenationBenchmark.fast()ich jedoch Folgendes:

@ 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)

dh der Compiler kann alles einbinden, denn BrokenConcatenationBenchmark.slow()es ist anders:

@ 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)

Die Frage ist also, ob dies ein angemessenes Verhalten des JVM- oder Compiler-Fehlers ist.

Ich stelle die Frage, weil einige der Projekte immer noch Java 8 verwenden und wenn es bei keinem der Release-Updates behoben werden kann, ist es für mich vernünftig, Anrufe Class.getName()manuell von Hotspots zu erheben.

PS Auf den neuesten JDKs (11, 13, 14-eap) wird das Problem nicht reproduziert.

Sergey Tsypanov
quelle
Sie haben dort einen Nebeneffekt - die Zuordnung zu this.name.
RealSkeptic
@RealSkeptic Die Zuweisung erfolgt nur einmal beim ersten Aufruf der Methode Class.getName()und in der setUp()Methode, nicht im Hauptteil der Benchmark-Methode.
Sergey Tsypanov

Antworten:

7

HotSpot JVM sammelt Ausführungsstatistiken pro Bytecode. Wenn derselbe Code in verschiedenen Kontexten ausgeführt wird, werden im Ergebnisprofil Statistiken aus allen Kontexten zusammengefasst. Dieser Effekt wird als Profilverschmutzung bezeichnet .

Class.getName()wird natürlich nicht nur von Ihrem Benchmark-Code aufgerufen. Bevor JIT mit dem Kompilieren des Benchmarks beginnt, weiß es bereits, dass die folgende Bedingung in Class.getName()mehrfach erfüllt wurde:

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

Zumindest genug Zeit, um diesen Zweig statistisch wichtig zu behandeln. Daher hat JIT diesen Zweig nicht von der Kompilierung ausgeschlossen und konnte daher die String-Konzentration aufgrund möglicher Nebenwirkungen nicht optimieren.

Dies muss nicht einmal ein nativer Methodenaufruf sein. Nur eine regelmäßige Feldzuweisung wird ebenfalls als Nebeneffekt angesehen.

Hier ist ein Beispiel, wie Profilverschmutzung weitere Optimierungen beeinträchtigen kann.

@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();
    }
}

Dies ist im Grunde die modifizierte Version Ihres Benchmarks, die die Verschmutzung des getName()Profils simuliert . Abhängig von der Anzahl der vorläufigen getName()Aufrufe eines neuen Objekts kann sich die weitere Leistung der Zeichenfolgenverkettung erheblich unterscheiden:

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  << !

Weitere Beispiele für Profilverschmutzung »

Ich kann es weder als Fehler noch als "angemessenes Verhalten" bezeichnen. So wird die dynamische adaptive Kompilierung in HotSpot implementiert.

Apangin
quelle
1
Wer sonst, wenn nicht Pangin ... wissen Sie zufällig, ob Graal C2 die gleiche Krankheit hat?
Eugene
1

Etwas unabhängig, aber seit Java 9 und JEP 280: Zeichenfolgenverkettung anzeigen Die Zeichenfolgenverkettung erfolgt jetzt mit invokedynamicund nicht mehr StringBuilder. Dieser Artikel zeigt die Unterschiede im Bytecode zwischen Java 8 und Java 9.

Wenn der Benchmark, der auf einer neueren Java-Version erneut ausgeführt wird, das Problem nicht anzeigt, liegt höchstwahrscheinlich kein Fehler vor, javacda der Compiler jetzt einen neuen Mechanismus verwendet. Ich bin mir nicht sicher, ob das Eintauchen in das Java 8-Verhalten von Vorteil ist, wenn sich die neueren Versionen so stark ändern.

Karol Dowbecki
quelle
1
Ich bin damit einverstanden, dass dies wahrscheinlich ein Compiler-Problem ist, das jedoch nicht damit zusammenhängt javac. javacgeneriert Bytecode und führt keine ausgeklügelten Optimierungen durch. Ich habe denselben Benchmark mit ausgeführt -XX:TieredStopAtLevel=1und diese Ausgabe erhalten: 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 Wenn wir also nicht viel optimieren und beide Methoden dieselben Ergebnisse erzielen, zeigt sich das Problem nur, wenn der Code C2-kompiliert wird.
Sergey Tsypanov
1
wird jetzt mit invokedynamic gemacht und nicht StringBuilder ist einfach falsch . invokedynamicWeist die Laufzeit nur an, zu entscheiden, wie die Verkettung durchgeführt werden soll, und 5 von 6 Strategien (einschließlich der Standardstrategie) werden weiterhin verwendet StringBuilder.
Eugene
@ Eugene danke für den Hinweis. Wenn Sie Strategien sagen, meinen Sie damit StringConcatFactory.StrategyAufzählung?
Karol Dowbecki
@ KarolDowbecki genau.
Eugene