Почему StringBuilder # append (int) быстрее в Java 7, чем в Java 8?

При исследовании небольших дебатов w.r.t. используя "" + n и Integer.toString(int), чтобы преобразовать целочисленный примитив в строку, я написал это JMH microbenchmark:

@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class IntStr {
    protected int counter;


    @GenerateMicroBenchmark
    public String integerToString() {
        return Integer.toString(this.counter++);
    }

    @GenerateMicroBenchmark
    public String stringBuilder0() {
        return new StringBuilder().append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder1() {
        return new StringBuilder().append("").append(this.counter++).toString();
    }

    @GenerateMicroBenchmark
    public String stringBuilder2() {
        return new StringBuilder().append("").append(Integer.toString(this.counter++)).toString();
    }

    @GenerateMicroBenchmark
    public String stringFormat() {
        return String.format("%d", this.counter++);
    }

    @Setup(Level.Iteration)
    public void prepareIteration() {
        this.counter = 0;
    }
}

Я запускал его с параметрами JMH по умолчанию с виртуальными машинами Java, которые существуют на моей машине Linux (современный Mageia 4 64-разрядный процессор Intel i7-3770, 32 ГБ оперативной памяти). Первым JVM был тот, который поставляется с Oracle JDK 8u5 64-бит:

java version "1.8.0_05"
Java(TM) SE Runtime Environment (build 1.8.0_05-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.5-b02, mixed mode)

С этим JVM я получил почти то, что ожидал:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32317.048      698.703   ops/ms
b.IntStr.stringBuilder0     thrpt        20    28129.499      421.520   ops/ms
b.IntStr.stringBuilder1     thrpt        20    28106.692     1117.958   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20066.939     1052.937   ops/ms
b.IntStr.stringFormat       thrpt        20     2346.452       37.422   ops/ms

т.е. использование класса StringBuilder происходит медленнее из-за дополнительных накладных расходов при создании объекта StringBuilder и добавления пустой строки. Использование String.format(String, ...) еще медленнее, на порядок или около того.

Компилятор, предоставляемый дистрибутивом, с другой стороны, основан на OpenJDK 1.7:

java version "1.7.0_55"
OpenJDK Runtime Environment (mageia-2.4.7.1.mga4-x86_64 u55-b13)
OpenJDK 64-Bit Server VM (build 24.51-b03, mixed mode)

Результаты здесь были интересными:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    31249.306      881.125   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39486.857      663.766   ops/ms
b.IntStr.stringBuilder1     thrpt        20    41072.058      484.353   ops/ms
b.IntStr.stringBuilder2     thrpt        20    20513.913      466.130   ops/ms
b.IntStr.stringFormat       thrpt        20     2068.471       44.964   ops/ms

Почему StringBuilder.append(int) появляется намного быстрее с этим JVM? Глядя на исходный код класса StringBuilder, не обнаружил ничего особо интересного - этот метод почти идентичен Integer#toString(int). Интересно отметить, что добавление результата Integer.toString(int) (микрообъект stringBuilder2) не кажется более быстрым.

Является ли это несоответствие производительности проблеме с тестируемым жгутом? Или мой OpenJDK JVM содержит оптимизацию, которая повлияет на этот конкретный код (анти) -паттерн?

EDIT:

Для более прямого сравнения я установил Oracle JDK 1.7u55:

java version "1.7.0_55"
Java(TM) SE Runtime Environment (build 1.7.0_55-b13)
Java HotSpot(TM) 64-Bit Server VM (build 24.55-b03, mixed mode)

Результаты аналогичны результатам OpenJDK:

Benchmark                    Mode   Samples         Mean   Mean error    Units
b.IntStr.integerToString    thrpt        20    32502.493      501.928   ops/ms
b.IntStr.stringBuilder0     thrpt        20    39592.174      428.967   ops/ms
b.IntStr.stringBuilder1     thrpt        20    40978.633      544.236   ops/ms

Похоже, что это более общая проблема Java 7 vs Java 8. Возможно, у Java 7 была более агрессивная оптимизация строк?

РЕДАКТИРОВАТЬ 2:

Для полноты здесь приведены связанные с строкой параметры VM для обоих этих JVM:

Для Oracle JDK 8u5:

$ /usr/java/default/bin/java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}
     intx PerfMaxStringConstLength                  = 1024            {product}
     bool PrintStringTableStatistics                = false           {product}
    uintx StringTableSize                           = 60013           {product}

Для OpenJDK 1.7:

$ java -XX:+PrintFlagsFinal 2>/dev/null | grep String
     bool OptimizeStringConcat                      = true            {C2 product}        
     intx PerfMaxStringConstLength                  = 1024            {product}           
     bool PrintStringTableStatistics                = false           {product}           
    uintx StringTableSize                           = 60013           {product}           
     bool UseStringCache                            = false           {product}   

Опция UseStringCache была удалена на Java 8 без замены, поэтому я сомневаюсь, что это имеет значение. Остальные параметры имеют одинаковые настройки.

ИЗМЕНИТЬ 3:

Сравнительное сравнение исходного кода классов AbstractStringBuilder, StringBuilder и Integer из файла src.zip не показывает ничего примечательного. Помимо большого количества изменений в косметике и документации, Integer теперь имеет некоторую поддержку целых чисел без знака, а StringBuilder был немного реорганизован для совместного использования большего количества кода с помощью StringBuffer. Ни одно из этих изменений не влияет на пути кода, используемые StringBuilder#append(int), хотя я, возможно, что-то пропустил.

Сравнение кода сборки, сгенерированного для IntStr#integerToString() и IntStr#stringBuilder0(), гораздо интереснее. Базовая компоновка кода, сгенерированного для IntStr#integerToString(), была одинаковой для обоих JVM, хотя Oracle JDK 8u5 казалась более агрессивной w.r.t. вставляя некоторые вызовы в код Integer#toString(int). Была ясная переписка с исходным кодом Java, даже для тех, кто с минимальным опытом сборки.

Код сборки для IntStr#stringBuilder0(), однако, был радикально иным. Код, созданный Oracle JDK 8u5, снова был напрямую связан с исходным кодом Java - я мог легко распознать один и тот же макет. Наоборот, код, созданный OpenJDK 7, был почти неузнаваем для неподготовленного глаза (например, у меня). Вызов new StringBuilder(), казалось бы, был удален, как и создание массива в конструкторе StringBuilder. Кроме того, плагин дизассемблера не смог предоставить столько ссылок на исходный код, как в JDK 8.

Я предполагаю, что это либо результат более агрессивного перехода оптимизации в OpenJDK 7, либо, скорее, результат ввода рукописного низкоуровневого кода для определенных операций StringBuilder. Я не уверен, почему эта оптимизация не выполняется в моей реализации JVM 8 или почему в JVM 7 не были реализованы те же оптимизации для Integer#toString(int). Наверное, кто-то, кто знаком с соответствующими частями исходного кода JRE, должен будет ответить на эти вопросы...

Ответ 1

TL; DR: Побочные эффекты в append, по-видимому, нарушают оптимизацию StringConcat.

Очень хороший анализ в исходном вопросе и обновлениях!

Для полноты ниже приведены несколько пропущенных шагов:

  • Просмотрите -XX:+PrintInlining для 7u55 и 8u5. В 7u55 вы увидите что-то вроде этого:

     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)
       @ 18   java.lang.StringBuilder::append (8 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
    

    ... и в 8u5:

     @ 16   org.sample.IntStr::inlineSideEffect (25 bytes)   force inline by CompilerOracle
       @ 4   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)
       @ 18   java.lang.StringBuilder::append (8 bytes)   inline (hot)
         @ 2   java.lang.AbstractStringBuilder::append (62 bytes)   already compiled into a big method
       @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot)
         @ 13   java.lang.String::<init> (62 bytes)   inline (hot)
           @ 1   java.lang.Object::<init> (1 bytes)   inline (hot)
           @ 55   java.util.Arrays::copyOfRange (63 bytes)   inline (hot)
             @ 54   java.lang.Math::min (11 bytes)   (intrinsic)
             @ 57   java.lang.System::arraycopy (0 bytes)   (intrinsic)
    

    Возможно, вы заметили, что версия 7u55 является более мелкой, и похоже, что после методов StringBuilder ничего не вызывается - это хорошее указание на то, что оптимизация строк действует. Действительно, если вы запустите 7u55 с -XX:-OptimizeStringConcat, подколлеры снова появятся, а производительность упадет до уровня 8u5.

  • ОК, поэтому нам нужно выяснить, почему 8u5 не делает ту же оптимизацию. Grep http://hg.openjdk.java.net/jdk9/jdk9/hotspot для "StringBuilder", чтобы выяснить, где VM обрабатывает оптимизацию StringConcat; это приведет вас в src/share/vm/opto/stringopts.cpp

  • hg log src/share/vm/opto/stringopts.cpp, чтобы выяснить последние изменения там. Один из кандидатов:

    changeset:   5493:90abdd727e64
    user:        iveresov
    date:        Wed Oct 16 11:13:15 2013 -0700
    summary:     8009303: Tiered: incorrect results in VM tests stringconcat...
    
  • Посмотрите на темы обзора в списках рассылки OpenJDK (достаточно просто для Google для сводки изменений): http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2013-October/012084.html

  • Spot "Оптимизация оптимизации строк String сворачивает шаблон [...] в одно выделение строки и формирует результат напрямую. Все возможные ошибки, которые могут произойти в оптимизированном коде, перезапускают этот шаблон с самого начала ( начиная с выделения StringBuffer). Это означает, что весь шаблон должен иметь побочный эффект." Eureka?

  • Выпишите сравнительный тест:

    @Fork(5)
    @Warmup(iterations = 5)
    @Measurement(iterations = 5)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @State(Scope.Benchmark)
    public class IntStr {
        private int counter;
    
        @GenerateMicroBenchmark
        public String inlineSideEffect() {
            return new StringBuilder().append(counter++).toString();
        }
    
        @GenerateMicroBenchmark
        public String spliceSideEffect() {
            int cnt = counter++;
            return new StringBuilder().append(cnt).toString();
        }
    }
    
  • Измерьте его на JDK 7u55, увидев ту же производительность для встроенных/сращенных побочных эффектов:

    Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       65.460        1.747    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       64.414        1.323    ns/op
    
  • Измерьте его на JDK 8u5, увидев ухудшение производительности с помощью встроенного эффекта:

    Benchmark                       Mode   Samples         Mean   Mean error    Units
    o.s.IntStr.inlineSideEffect     avgt        25       84.953        2.274    ns/op
    o.s.IntStr.spliceSideEffect     avgt        25       65.386        1.194    ns/op
    
  • Отправьте отчет об ошибке (https://bugs.openjdk.java.net/browse/JDK-8043677), чтобы обсудить это поведение с парнями VM. Обоснование оригинального исправления является прочным, но интересно, если мы можем/должны вернуть эту оптимизацию в некоторых тривиальных случаях, подобных этим.

  • ???

  • PROFIT.

И да, я должен опубликовать результаты для теста, который перемещает приращение из цепочки StringBuilder, делая это перед всей цепочкой. Кроме того, переключается на среднее время и ns/op. Это JDK 7u55:

Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.805        1.093    ns/op
o.s.IntStr.stringBuilder0      avgt        25      128.284        6.797    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.524        3.116    ns/op
o.s.IntStr.stringBuilder2      avgt        25      254.384        9.204    ns/op
o.s.IntStr.stringFormat        avgt        25     2302.501      103.032    ns/op

И это 8u5:

Benchmark                      Mode   Samples         Mean   Mean error    Units
o.s.IntStr.integerToString     avgt        25      153.032        3.295    ns/op
o.s.IntStr.stringBuilder0      avgt        25      127.796        1.158    ns/op
o.s.IntStr.stringBuilder1      avgt        25      131.585        1.137    ns/op
o.s.IntStr.stringBuilder2      avgt        25      250.980        2.773    ns/op
o.s.IntStr.stringFormat        avgt        25     2123.706       25.105    ns/op

stringFormat на самом деле немного быстрее в 8u5, и все остальные тесты одинаковы. Это затвердевает гипотеза об обрыве побочного эффекта в цепях SB у главного виновника в исходном вопросе.

Ответ 2

Я думаю, что это связано с флагом CompileThreshold, который управляет, когда код байта компилируется в машинный код JIT.

Oracle JDK имеет значение по умолчанию 10 000 в качестве документа в http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html.

Где OpenJDK Я не смог найти последний документ этого флага; но некоторые потоки почты предлагают гораздо более низкий порог: http://mail.openjdk.java.net/pipermail/hotspot-compiler-dev/2010-November/004239.html

Кроме того, попробуйте включить/выключить флаги Oracle JDK, например -XX:+UseCompressedStrings и -XX:+OptimizeStringConcat. Я не уверен, что эти флаги по умолчанию включены в OpenJDK. Может кто-нибудь может предложить.

Один эксперимент, который вы можете сделать, - это, во-первых, запустить программу много раз, скажем, 30 000 циклов, сделать System.gc(), а затем попытаться посмотреть на производительность. Я считаю, что они дадут то же самое.

И я полагаю, что ваша настройка GC тоже. В противном случае вы выделяете много объектов, и GC вполне может быть основной частью вашего времени выполнения.