Почему 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 вполне может быть основной частью вашего времени выполнения.