Java 8: Class.getName () spowalnia łańcuch konkatenacji ciągów

13

Ostatnio natknąłem się na problem dotyczący konkatenacji ciągów. Ten test porównawczy podsumowuje:

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

Na JDK 1.8.0_222 (VM 64-bitowego serwera OpenJDK, 25.222-b10) mam następujące wyniki:

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

Wygląda to na problem podobny do JDK-8043677 , w którym wyrażenie wywołujące efekt uboczny przerywa optymalizację nowego StringBuilder.append().append().toString()łańcucha. Ale sam kod Class.getName()nie wydaje się mieć żadnych skutków ubocznych:

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

Jedyną podejrzaną rzeczą jest tutaj wywołanie metody natywnej, które dzieje się tylko raz, a jej wynik jest buforowany w polu klasy. W moim teście wyraźnie zapisałem to w pamięci podręcznej podczas instalacji.

Oczekiwałem, że predyktor gałęzi zorientuje się, że przy każdym wywołaniu testu porównawczego rzeczywista wartość this.name nigdy nie jest zerowa i optymalizuje całe wyrażenie.

Jednak dla BrokenConcatenationBenchmark.fast()mam to:

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

tzn. kompilator jest w stanie wstawić wszystko, BrokenConcatenationBenchmark.slow()ponieważ jest inaczej:

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

Pytanie brzmi więc, czy jest to właściwe zachowanie błędu JVM czy kompilatora?

Zadaję to pytanie, ponieważ niektóre projekty nadal używają języka Java 8 i jeśli nie zostanie to naprawione w żadnej z aktualizacji wydania, rozsądne jest, aby Class.getName()ręcznie wywoływać połączenia z gorących punktów.

PS W najnowszych pakietach JDK (11, 13, 14-eap) problem nie jest odtwarzany.

Siergiej Tsypanov
źródło
Masz tam efekt uboczny - przypisanie do this.name.
RealSkeptic,
@RealSkeptic przypisanie następuje tylko raz przy pierwszym wywołaniu metody Class.getName()i w setUp()metodzie, a nie w treści testu porównawczego.
Sergey Tsypanov,

Odpowiedzi:

7

HotSpot JVM zbiera statystyki wykonania według kodu bajtowego. Jeśli ten sam kod jest uruchamiany w różnych kontekstach, profil wynikowy agreguje statystyki ze wszystkich kontekstów. Ten efekt jest znany jako zanieczyszczenie profilu .

Class.getName()jest oczywiście wywoływane nie tylko z kodu testu porównawczego. Zanim JIT rozpocznie kompilację testu porównawczego, wie już, że następujący warunek Class.getName()został spełniony wiele razy:

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

Przynajmniej tyle razy, aby potraktować tę gałąź statystycznie ważną. Tak więc JIT nie wykluczył tej gałęzi z kompilacji, a zatem nie mógł zoptymalizować konkatku ciągu ze względu na możliwy efekt uboczny.

Nie musi to być nawet natywne wywołanie metody. Zwykłe przypisanie do pola jest również uważane za efekt uboczny.

Oto przykład, w jaki sposób zanieczyszczenie profilu może zaszkodzić dalszym optymalizacjom.

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

Jest to w zasadzie zmodyfikowana wersja twojego testu porównawczego, która symuluje zanieczyszczenie getName() profilu. W zależności od liczby wstępnych getName()wywołań nowego obiektu dalsza wydajność konkatenacji łańcuchów może się znacznie różnić:

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

Więcej przykładów zanieczyszczenia profilu »

Nie mogę tego nazwać ani błędem, ani „odpowiednim zachowaniem”. Tak właśnie implementowana jest dynamiczna kompilacja adaptacyjna w HotSpot.

apangin
źródło
1
Kto jeszcze, jeśli nie Pangin ... Czy wiesz, że Graal C2 ma tę samą chorobę?
Eugene
1

Nieznacznie niezwiązane, ale od Java 9 i JEP 280: Indify String Concatenation łączenie łańcuchów jest teraz wykonywane za pomocą invokedynamici nie StringBuilder. Ten artykuł pokazano różnice w kodzie bajtowym między Javą 8 a Javą 9.

Jeśli test porównawczy uruchomiony ponownie w nowszej wersji Java nie pokazuje problemu, najprawdopodobniej nie ma błędu, javacponieważ kompilator używa teraz nowego mechanizmu. Nie jestem pewien, czy zanurzenie się w zachowanie Java 8 jest korzystne, jeśli nastąpiła tak znacząca zmiana w nowszych wersjach.

Karol Dowbecki
źródło
1
Zgadzam się, że jest to prawdopodobnie problem kompilatora, ale nie związany z tym javac. javacgeneruje kod bajtowy i nie dokonuje żadnych wyrafinowanych optymalizacji. Uruchomiłem ten sam test porównawczy -XX:TieredStopAtLevel=1i otrzymałem takie dane wyjściowe: 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 więc gdy nie zoptymalizujemy wiele, obie metody dają takie same wyniki, problem ujawnia się dopiero po skompilowaniu kodu C2.
Sergey Tsypanov,
1
jest teraz wykonywana przy użyciu invokedynamic i nie StringBuilder jest po prostu zły . invokedynamicinformuje tylko środowisko wykonawcze, aby zdecydowało, jak przeprowadzić konkatenację, a 5 z 6 strategii (w tym domyślnych) nadal używa StringBuilder.
Eugene
@Eugene dziękuje za zwrócenie na to uwagi. Kiedy mówisz strategie, masz na myśli StringConcatFactory.Strategyenum?
Karol Dowbecki,
@KarolDowbecki dokładnie.
Eugene