Java 8: Class.getName () diminui a cadeia de concatenação de String

13

Recentemente, encontrei um problema relacionado à concatenação de String. Este benchmark resume:

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

No JDK 1.8.0_222 (VM do servidor OpenJDK de 64 bits, 25.222-b10), obtive os seguintes resultados:

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

Parece um problema semelhante ao JDK-8043677 , em que uma expressão com efeito colateral interrompe a otimização da nova StringBuilder.append().append().toString()cadeia. Mas o código por Class.getName()si só não parece ter efeitos colaterais:

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

A única coisa suspeita aqui é uma chamada ao método nativo que acontece de fato apenas uma vez e seu resultado é armazenado em cache no campo da classe. No meu benchmark, eu o coloquei em cache explicitamente no método de instalação.

Eu esperava que o preditor de ramificação descobrisse que, a cada chamada de referência, o valor real de this.name nunca é nulo e otimiza toda a expressão.

No entanto, enquanto BrokenConcatenationBenchmark.fast()eu tenho o seguinte:

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

ou seja, o compilador é capaz de alinhar tudo, BrokenConcatenationBenchmark.slow()pois é diferente:

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

Portanto, a questão é se esse é o comportamento apropriado da JVM ou do bug do compilador?

Estou fazendo a pergunta, porque alguns dos projetos ainda estão usando o Java 8 e, se não for corrigido em nenhuma atualização de versão, é razoável içar chamadas Class.getName()manualmente a partir de pontos de acesso.

PS Nos JDKs mais recentes (11, 13, 14-eap), o problema não é reproduzido.

Sergey Tsypanov
fonte
Você tem um efeito colateral lá - a atribuição a this.name.
RealSkeptic /
@RealSkeptic, a atribuição acontece apenas uma vez na primeira chamada Class.getName()e no setUp()método, não no corpo da referência.
Sergey Tsypanov 03/12/19

Respostas:

7

A JVM do HotSpot coleta estatísticas de execução por bytecode. Se o mesmo código for executado em contextos diferentes, o perfil do resultado agregará estatísticas de todos os contextos. Esse efeito é conhecido como poluição do perfil .

Class.getName()é obviamente chamado não apenas a partir do seu código de referência. Antes de o JIT começar a compilar o benchmark, ele já sabe que a seguinte condição Class.getName()foi atendida várias vezes:

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

Pelo menos, tempos suficientes para tratar este ramo estatisticamente importante. Portanto, o JIT não excluiu esse ramo da compilação e, portanto, não pôde otimizar a concatração de cadeias devido a um possível efeito colateral.

Isso nem precisa ser uma chamada de método nativa. Apenas uma atribuição de campo regular também é considerada um efeito colateral.

Aqui está um exemplo de como a poluição do perfil pode prejudicar outras otimizações.

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

Esta é basicamente a versão modificada do seu benchmark que simula a poluição do getName()perfil. Dependendo do número de getName()chamadas preliminares em um objeto novo, o desempenho adicional da concatenação de cadeias de caracteres pode diferir drasticamente:

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

Mais exemplos de poluição de perfil »

Não posso chamá-lo de bug ou de "comportamento apropriado". É assim que a compilação adaptativa dinâmica é implementada no HotSpot.

apangin
fonte
11
quem mais senão Pangin ... você sabe se Graal C2 tem a mesma doença?
Eugene
1

Ligeiramente não relacionado, mas desde o Java 9 e o JEP 280: Indicar concatenação de cadeias, a concatenação de cadeias agora é feita com invokedynamice não StringBuilder. Este artigo mostra as diferenças no bytecode entre Java 8 e Java 9.

Se o benchmark executado novamente na versão mais recente do Java não mostrar o problema, provavelmente não haverá nenhum bug javacporque o compilador agora usa um novo mecanismo. Não tenho certeza se mergulhar no comportamento do Java 8 é benéfico se houver uma mudança substancial nas versões mais recentes.

Karol Dowbecki
fonte
11
Concordo que é provável que seja um problema do compilador, mas não um relacionado javac. javacgera bytecode e não faz nenhuma otimização sofisticada. Eu executei o mesmo benchmark -XX:TieredStopAtLevel=1e recebi esta saída: 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 Portanto, quando não otimizamos muito, os dois métodos produzem os mesmos resultados, o problema se revela apenas quando o código é compilado em C2.
Sergey Tsypanov 03/12/19
11
agora é feito com invokedynamic e não StringBuilder está simplesmente errado . invokedynamicdiz apenas ao tempo de execução para escolher como fazer a concatenação e 5 de 6 estratégias (incluindo o padrão) ainda usam StringBuilder.
187 Eugene
@ Eugene obrigado por apontar isso. Quando você diz estratégias, você quer dizer StringConcatFactory.Strategyenum?
Karol Dowbecki
@KarolDowbecki exatamente.
Eugene