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 (OpenJDK 64-бітний сервер VM, 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();

Єдина підозріла річ - це заклик до рідного методу, який насправді відбувається лише один раз, і його результат зберігається в полі класу. У моєму еталоні я явно кешував його методом настройки.

Я очікував, що передбачувач гілки зрозуміє, що при кожному виклику еталону фактичне значення цього імені ніколи не буває нульовим і оптимізує весь вираз.

Однак, поки для 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 почне компілювати еталонний показник, він уже знає, що наступна умова в системі Class.getName()виконується кілька разів:

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

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

Це навіть не потрібно, щоб бути власним викликом методу. Просто регулярне призначення поля також вважається побічним ефектом.

Ось приклад того, як забруднення профілю може зашкодити подальшим оптимізаціям.

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


1
хто ще, як не Пангін ... чи не знаєш ти, чи має Graal C2 таку ж хворобу?
Євген

1

Трохи не пов'язані між собою, але оскільки Java 9 та JEP 280: Позначте об'єднання рядків, конкатенація рядків тепер робиться за допомогою, 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 Отже, коли ми не дуже оптимізуємо обидва методи, даємо однакові результати, проблема виявляється лише тоді, коли код отримає С2-компіляцію.
Сергій

1
тепер робиться з викликом динаміки, а не StringBuilder просто неправильно . invokedynamicлише вказує на час виконання, щоб вибрати, як зробити конкатенацію, і 5 із 6 стратегій (включаючи стандартну) все ще використовують StringBuilder.
Євген

@Eugene дякую, що вказали на це. Коли ви говорите про стратегії, ви маєте на увазі StringConcatFactory.Strategyперелік?
Karol Dowbecki

@KarolDowbecki точно.
Євген
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.