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

Комментарии (12)


  1. Nakosika
    13.06.2017 15:21
    +1

    С этими оптимизациями на jvm сплошная мистика. Недавно тестировал вызов функций по ссылке, так скорость отличалась на порядок из-за способа, которым в массив те же самые ссылки складывал — если делал это в цикле то быстрее, а если каждый элемент индивидуально то в десять раз медленнее.


  1. AlexTheLost
    13.06.2017 15:26

    В новой версии JVM у вас может что-то работать уже иначе. И вообще непонятно зачем так "загоняться" по поводу микрооптимизаций JVM. Возможно если вы уперлись в производительность и дальше возможный выход только оптимизации такого рода, а я думаю в подавляющей массе проектов подобное не возникнет, нужно посмотреть в сторону других инструментов/языков.
    p.s.
    В общем спасибо за статью, то что написал не является целью её критики или утверждения что статья не нужна.)


    1. doom369
      13.06.2017 15:37
      +1

      В новой версии JVM у вас может что-то работать уже иначе.

      Может. Но в девятке пока все по прежнему.

      И вообще непонятно зачем так «загоняться» по поводу микрооптимизаций JVM.

      Чтобы уменьшить стоимость железа? У нас, например, лоад в 10_000 рек-сек.


    1. imanushin
      13.06.2017 16:55
      +2

      И вообще непонятно зачем так "загоняться" по поводу микрооптимизаций JVM.

      А по мне так прекрасная информация. Думаю, разработчикам Котлина очень даже пригодится (см. новый топик).


      И если писать твой кодогенератор, то тоже пригодится.


      И так ясно, что эта информация не для случая, когда код руками создается.


  1. Iqorek
    13.06.2017 15:27
    +1

    Зная что "" + "" это синтаксический сахар от new StringBuilder().append("").append("").toString(), а так же, что потом эта конструкция оптимизируется и статические части «сшиваются» на этапе компиляции, вывод очевиден, но привести доказательства подозрений, я бы не смог. Спасибо.


  1. 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


  1. shybovycha
    14.06.2017 12:35
    +1

    Для любопытных я сгенерировал байткод для указанного сниппета (под заголовком статьи): https://gist.github.com/shybovycha/9431c5b82c826ced439490c100bfb093


    1. doom369
      14.06.2017 23:32

      В конкатенации можно сразу возвращать
      return a1 + a2 + a3;
      .


      1. 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. Но на его расковыривание уйдет больше времени, чем обеденный перерыв на работе =)


  1. imanushin
    20.06.2017 12:40
    +2

    Соответствующий тикет для Kotlin: https://youtrack.jetbrains.com/issue/KT-18558


  1. 23derevo
    20.06.2017 17:17

    Замените плиз ссылку на доклад Шипилёва на эту: https://youtu.be/SZFe3m1DV1A

    Тут гораздо лучше записано, и, к тому же, по-русски.


    1. doom369
      20.06.2017 17:24

      Fixed.