@BenchmarkMode(Mode.Throughput)
@Fork(1)
@State(Scope.Thread)
@Warmup(iterations = 10, time = 1, batchSize = 1000)
@Measurement(iterations = 40, time = 1, batchSize = 1000)
public class Chaining {
private String a1 = "111111111111111111111111";
private String a2 = "222222222222222222222222";
private String a3 = "333333333333333333333333";
@Benchmark
public String typicalChaining() {
return new StringBuilder().append(a1).append(a2).append(a3).toString();
}
@Benchmark
public String noChaining() {
StringBuilder sb = new StringBuilder();
sb.append(a1);
sb.append(a2);
sb.append(a3);
return sb.toString();
}
}
Результат:
Benchmark Mode Cnt Score Error Units
Chaining.noChaining thrpt 40 8408.703 ± 214.582 ops/s
Chaining.typicalChaining thrpt 40 35830.907 ± 1277.455 ops/s
Итого, конкатеницая через цепочку вызовов
sb.append().append()
в 4 раза быстрее… Автор из статьи выше утверждает, что разница связана с тем, что в случае цепочки вызовов генерируется меньше байткода и, соответственно, он выполняется быстрее. Ну что ж, давайте проверим.
Разница в байткоде?
Гипотезу можно легко проверить без углубления в байт код — создадим типичный UriBuilder:
public class UriBuilder {
private String schema;
private String host;
private String path;
public UriBuilder setSchema(String schema) {
this.schema = schema;
return this;
}
...
@Override
public String toString() {
return schema + "://" + host + path;
}
}
И повторим бенчмарк:
@BenchmarkMode(Mode.Throughput)
@Fork(1)
@State(Scope.Thread)
@Warmup(iterations = 10, time = 1, batchSize = 1000)
@Measurement(iterations = 40, time = 1, batchSize = 1000)
public class UriBuilderChaining {
private String host = "host";
private String schema = "http";
private String path = "/123/123/123";
@Benchmark
public String chaining() {
return new UriBuilder().setSchema(schema).setHost(host).setPath(path).toString();
}
@Benchmark
public String noChaining() {
UriBuilder uriBuilder = new UriBuilder();
uriBuilder.setSchema(schema);
uriBuilder.setHost(host);
uriBuilder.setPath(path);
return uriBuilder.toString();
}
}
Если причина действительно в количестве байткода, то даже на такой тяжеловесной операции как конкатенация строк, мы должны увидеть разницу.
Результат:
Benchmark Mode Cnt Score Error Units
UriBuilderChaining.chaining thrpt 40 35797.519 ± 2051.165 ops/s
UriBuilderChaining.noChaining thrpt 40 36080.534 ± 1962.470 ops/s
Хм… Разница на уровне погрешности. Значит количество байткода тут ни при чем. Так как аномалия проявляется со
StringBuilder
и append()
, то наверное это как-то связано с известной JVM опцией +XX:OptimizeStringConcat
. Давайте проверим. Повторим самый первый тест, но с отключенной опцией. В JMH через аннотации сделать это можно так:
@Fork(value = 1, jvmArgsAppend = "-XX:-OptimizeStringConcat")
Повторяем первый тест:
Benchmark Mode Cnt Score Error Units
Chaining.noChaining thrpt 40 7598.743 ± 554.192 ops/s
Chaining.typicalChaining thrpt 40 7946.422 ± 313.967 ops/s
Бинго!
Так как соединение строк через
x + y
довольно частая операция в любом приложении — Hotspot JVM находит new StringBuilder().append(x).append(y).toString()
паттерны в байткоде и заменяет их на оптимизированный машинний код, обходясь без создания промежуточных объектов. К сожалению, эта оптимизация не применяется к
sb.append(x); sb.append(y);
. Разница на больших строках может быть на порядок.Выводы
Используйте паттерн «цепочка вызовов» (method chaining), где это возможно. Во-первых, в случае
StringBuilder
это поможет JIT заоптимизировать конкатенацию строк. Во-вторых, так генерируется меньше байт кода и это действительно может помочь заинлайнить Ваш метод в некоторых случаях.Вопрос на SO
Связанный доклад с грязными подробностями от Шипилева.
Комментарии (12)
AlexTheLost
13.06.2017 15:26В новой версии JVM у вас может что-то работать уже иначе. И вообще непонятно зачем так "загоняться" по поводу микрооптимизаций JVM. Возможно если вы уперлись в производительность и дальше возможный выход только оптимизации такого рода, а я думаю в подавляющей массе проектов подобное не возникнет, нужно посмотреть в сторону других инструментов/языков.
p.s.
В общем спасибо за статью, то что написал не является целью её критики или утверждения что статья не нужна.)doom369
13.06.2017 15:37+1В новой версии JVM у вас может что-то работать уже иначе.
Может. Но в девятке пока все по прежнему.
И вообще непонятно зачем так «загоняться» по поводу микрооптимизаций JVM.
Чтобы уменьшить стоимость железа? У нас, например, лоад в 10_000 рек-сек.
imanushin
13.06.2017 16:55+2И вообще непонятно зачем так "загоняться" по поводу микрооптимизаций JVM.
А по мне так прекрасная информация. Думаю, разработчикам Котлина очень даже пригодится (см. новый топик).
И если писать твой кодогенератор, то тоже пригодится.
И так ясно, что эта информация не для случая, когда код руками создается.
Iqorek
13.06.2017 15:27+1Зная что "" + "" это синтаксический сахар от new StringBuilder().append("").append("").toString(), а так же, что потом эта конструкция оптимизируется и статические части «сшиваются» на этапе компиляции, вывод очевиден, но привести доказательства подозрений, я бы не смог. Спасибо.
Sirikid
14.06.2017 12:29А вы видели какой байткод генерируется для fluent builders? Они и стековые VM похоже просто созданы друг для друга :^)
Байткодpublic static java.lang.String chaining(java.lang.String, java.lang.String, java.lang.String); descriptor: (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=3 0: new #10 // class UriBuilder 3: dup 4: invokespecial #11 // Method "<init>":()V 7: aload_0 8: invokevirtual #12 // Method setSchema:(Ljava/lang/String;)LUriBuilder; 11: aload_1 12: invokevirtual #13 // Method setHost:(Ljava/lang/String;)LUriBuilder; 15: aload_2 16: invokevirtual #14 // Method setPath:(Ljava/lang/String;)LUriBuilder; 19: invokevirtual #15 // Method toString:()Ljava/lang/String; 22: areturn public static java.lang.String noChaining(java.lang.String, java.lang.String, java.lang.String); descriptor: (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=3 0: new #10 // class UriBuilder 3: dup 4: invokespecial #11 // Method "<init>":()V 7: astore_3 8: aload_3 9: aload_0 10: invokevirtual #12 // Method setSchema:(Ljava/lang/String;)LUriBuilder; 13: pop 14: aload_3 15: aload_1 16: invokevirtual #13 // Method setHost:(Ljava/lang/String;)LUriBuilder; 19: pop 20: aload_3
shybovycha
14.06.2017 12:35+1Для любопытных я сгенерировал байткод для указанного сниппета (под заголовком статьи): https://gist.github.com/shybovycha/9431c5b82c826ced439490c100bfb093
doom369
14.06.2017 23:32В конкатенации можно сразу возвращать
return a1 + a2 + a3;
.
shybovycha
16.06.2017 06:30Я надеялся что компилятор сделает это за меня — это ведь настолько очевидная оптимизация!
Поэтому я создал два метода (по сути — добавил "оптимизированную" версию в существующий класс) чтобы это проверить:
public class Chaining { private String a1 = "111111111111111111111111"; private String a2 = "222222222222222222222222"; private String a3 = "333333333333333333333333"; public String typicalChaining() { return new StringBuilder().append(a1).append(a2).append(a3).toString(); } public String noChaining() { StringBuilder sb = new StringBuilder(); sb.append(a1); sb.append(a2); sb.append(a3); return sb.toString(); } public String concatenationWithExtraVar() { String result = a1 + a2 + a3; return result; } public String concatenationWithoutExtraVar() { return a1 + a2 + a3; } }
Метод с дополнительной переменной мы уже видели:
public java.lang.String concatenationWithExtraVar(); Code: 0: new #8 // class java/lang/StringBuilder 3: dup 4: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V 7: aload_0 8: getfield #3 // Field a1:Ljava/lang/String; 11: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 14: aload_0 15: getfield #5 // Field a2:Ljava/lang/String; 18: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 21: aload_0 22: getfield #7 // Field a3:Ljava/lang/String; 25: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 28: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 31: astore_1 32: aload_1 33: areturn
А вот когда я увидел байткод метода без дополнительной переменной, я был несколько удивлен:
public java.lang.String concatenationWithoutExtraVar(); Code: 0: new #8 // class java/lang/StringBuilder 3: dup 4: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V 7: aload_0 8: getfield #3 // Field a1:Ljava/lang/String; 11: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 14: aload_0 15: getfield #5 // Field a2:Ljava/lang/String; 18: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 21: aload_0 22: getfield #7 // Field a3:Ljava/lang/String; 25: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 28: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 31: areturn
Отличие едва заметно — различны только последние три строки, но тем не менее, компилятор Java, похоже, не потрудился сделать это самостоятельно (по умолчанию).
Основываясь на моих небольших похождениях по StackOverflow, компилятор Java не оптимизирует (или очень незначительно оптимизирует) байткод; вместо него это делает JIT.
Поэтому я пошел дальше и сгенерировал ассемблерный выхлоп JIT. Но на его расковыривание уйдет больше времени, чем обеденный перерыв на работе =)
imanushin
20.06.2017 12:40+2Соответствующий тикет для Kotlin: https://youtrack.jetbrains.com/issue/KT-18558
Nakosika
С этими оптимизациями на jvm сплошная мистика. Недавно тестировал вызов функций по ссылке, так скорость отличалась на порядок из-за способа, которым в массив те же самые ссылки складывал — если делал это в цикле то быстрее, а если каждый элемент индивидуально то в десять раз медленнее.