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 (64-битная виртуальная машина OpenJDK, 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();

Единственная подозрительная вещь здесь - это вызов нативного метода, который на самом деле происходит только один раз, и его результат кэшируется в поле класса. В моем тесте я явно кэшировал его в методе установки.

Я ожидал, что предиктор ветвей выяснит, что при каждом вызове эталонного значения фактическое значение this.name никогда не будет нулевым, и оптимизирует все выражение.

Однако пока для 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()методе, а не в теле тестируемого.
Сергей

Ответы:

7

HotSpot JVM собирает статистику выполнения по байт-коду. Если один и тот же код выполняется в разных контекстах, профиль результатов будет агрегировать статистику по всем контекстам. Этот эффект известен как загрязнение профиля .

Class.getName()очевидно вызывается не только из вашего кода теста. Перед тем, как JIT начинает компилировать тест, он уже знает, что следующее условие in Class.getName()было выполнено несколько раз:

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

По крайней мере, достаточно раз, чтобы рассматривать эту ветвь статистически важно. Таким образом, JIT не исключил эту ветку из компиляции и, следовательно, не смог оптимизировать concat строки из-за возможного побочного эффекта.

Это даже не должно быть вызовом нативного метода. Простое назначение поля также считается побочным эффектом.

Вот пример того, как загрязнение профиля может нанести вред дальнейшей оптимизации.

@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 реализована динамическая адаптивная компиляция.

apangin
источник
1
Кто еще, если не Пангин ... Вы случайно не знаете, есть ли у Graal C2 такая же болезнь?
Евгений
1

Немного несвязанный, но после Java 9 и JEP 280: Indify String Concatenation теперь конкатенация строк выполняется с использованием, 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.
Сергей
1
теперь сделано с invokedynamic, а не StringBuilder просто неправильно . invokedynamicтолько указывает среде выполнения выбрать способ конкатенации, и 5 из 6 стратегий (включая стандартную) по-прежнему используются StringBuilder.
Евгений
@ Евгений, спасибо, что указал на это. Когда вы говорите, стратегии, вы имеете в виду StringConcatFactory.Strategyenum?
Кароль Доубеки
@KarolDowbecki точно.
Евгений