Всем привет, к прошлой статье о наследии StringBuffer в комментариях оставили интересную ссылку. В этой статье есть интересный бенчмарк, который я изменил для придания большей драматичности:
@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
Связанный доклад с грязными подробностями от Шипилева.
Автор: Дмитрий Думанский