Цепочка вызовов append(x).append(y) в StringBuilder работает быстрее чем типичные sb.append(x); sb.append(y)

в 6:50, , рубрики: java, method chaining, OptimizeStringConcat, performance, stringbuilder

Всем привет, к прошлой статье о наследии 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
Связанный доклад с грязными подробностями от Шипилева.

Автор: Дмитрий Думанский

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js