Java 8: Class.getName () rallenta la catena di concatenazione delle stringhe


13

Di recente ho riscontrato un problema relativo alla concatenazione di stringhe. Questo benchmark lo riassume:

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

Su JDK 1.8.0_222 (OpenJDK 64-Bit Server VM, 25.222-b10) ho i seguenti risultati:

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

Sembra un problema simile a JDK-8043677 , in cui un'espressione con effetti collaterali interrompe l'ottimizzazione della nuova StringBuilder.append().append().toString()catena. Ma il codice di per Class.getName()sé non sembra avere effetti collaterali:

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

L'unica cosa sospetta qui è una chiamata al metodo nativo che avviene in realtà solo una volta e il suo risultato viene memorizzato nella cache nel campo della classe. Nel mio benchmark l'ho esplicitamente memorizzato nella cache nel metodo di installazione.

Mi aspettavo che il predittore di ramo scoprisse che ad ogni invocazione di benchmark il valore effettivo di this.name non è mai nullo e ottimizza l'intera espressione.

Tuttavia, mentre per l' BrokenConcatenationBenchmark.fast()ho questo:

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

cioè il compilatore è in grado di incorporare tutto, BrokenConcatenationBenchmark.slow()perché è diverso:

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

Quindi la domanda è se questo è il comportamento appropriato della JVM o del bug del compilatore?

Sto ponendo la domanda perché alcuni dei progetti utilizzano ancora Java 8 e se non verrà risolto su nessuno degli aggiornamenti di versione, per me è ragionevole sollevare Class.getName()manualmente le chiamate dagli hot spot.

PS Negli ultimi JDK (11, 13, 14-eap) il problema non viene riprodotto.


Hai un effetto collaterale lì - il compito di this.name.
RealSkeptic,

@RealSkeptic l'assegnazione avviene una sola volta alla prima invocazione Class.getName()e nel setUp()metodo, non nel corpo di quello di riferimento.
Sergey Tsypanov,

Risposte:


7

HotSpot JVM raccoglie le statistiche di esecuzione per bytecode. Se lo stesso codice viene eseguito in contesti diversi, il profilo dei risultati aggregherà le statistiche di tutti i contesti. Questo effetto è noto come inquinamento del profilo .

Class.getName()è ovviamente chiamato non solo dal tuo codice di riferimento. Prima che JIT inizi a compilare il benchmark, sa già che la seguente condizione è Class.getName()stata soddisfatta più volte:

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

Almeno abbastanza volte per trattare questo ramo statisticamente importante. Quindi, JIT non ha escluso questo ramo dalla compilazione e quindi non ha potuto ottimizzare il concatenamento delle stringhe a causa di possibili effetti collaterali.

Non è nemmeno necessario che sia una chiamata di metodo nativa. Anche solo una normale assegnazione di campo è considerata un effetto collaterale.

Ecco un esempio di come l'inquinamento del profilo possa danneggiare ulteriori ottimizzazioni.

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

Questa è sostanzialmente la versione modificata del tuo benchmark che simula l'inquinamento del getName()profilo. A seconda del numero di getName()chiamate preliminari su un nuovo oggetto, l'ulteriore esecuzione della concatenazione di stringhe può variare notevolmente:

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

Altri esempi di inquinamento del profilo »

Non posso chiamarlo né un bug né un "comportamento appropriato". Ecco come viene implementata la compilazione adattiva dinamica in HotSpot.


1
chi altro se non Pangin ... ti capita di sapere se Graal C2 ha la stessa malattia?
Eugene il

1

Leggermente non correlato, ma dal momento che Java 9 e JEP 280: Indicare la concatenazione di stringhe, la concatenazione di stringhe ora viene eseguita con invokedynamice non StringBuilder. Questo articolo mostra le differenze nel bytecode tra Java 8 e Java 9.

Se il benchmark viene eseguito nuovamente sulla versione Java più recente non mostra il problema, è probabile che non ci siano bug javacperché il compilatore ora utilizza un nuovo meccanismo. Non sono sicuro se tuffarsi nel comportamento di Java 8 sia utile se ci sono cambiamenti sostanziali nelle versioni più recenti.


1
Concordo sul fatto che probabilmente si tratterà di un problema del compilatore, non di un problema correlato javac. javacgenera bytecode e non esegue ottimizzazioni sofisticate. Ho eseguito lo stesso benchmark con -XX:TieredStopAtLevel=1e ho ricevuto questo risultato: 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 quindi quando non ottimizziamo molto entrambi i metodi producono gli stessi risultati, il problema si rivela solo quando il codice viene compilato in C2.
Sergey Tsypanov,

1
ora è fatto con invokedynamic e non StringBuilder è semplicemente sbagliato . invokedynamicindica solo al runtime di scegliere come eseguire la concatenazione e 5 strategie su 6 (incluso il valore predefinito) continuano a essere utilizzate StringBuilder.
Eugene il

@Eugene grazie per averlo segnalato. Quando dici strategie intendi StringConcatFactory.Strategyenum?
Karol Dowbecki,

@KarolDowbecki esattamente.
Eugene,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.