Pull to refresh

Comments 11

Для меня остаётся непонятным, почему интринсик не сработал при обращении к StringBuilder.append(String)
А это и не интринсик. По крайней мере, сам по себе. Только лишь в составе выражений вида
new StringBuilder().append()...append().toString();

JIT компилятор распознаёт подобные цепочки и транслирует их как единое целое. Называется OptimizeStringConcat. Про это уже писали и на Stack Overflow, и на Хабре.

Спасибо за ссылку, я упустил эту особенность. Интересно, почему тогда javac не превращает


StringBuilder sb = new StringBuilder();
sb.append(a1);
sb.append(a2);
sb.append(a3);
sb.toString();

в


new StringBuilder().append(a1).append(a2).append(a3).toString();

ещё на этапе компиляции исходного кода? Что мешает такому преобразованию?

Во-первых, это неэквивалентное преобразование. Во-вторых, javac — прямолинейный компилятор, оптимизации — не его задача.

Вы имеете ввиду неэквивалентность байт-кода? Поведение обоих методов, насколько я понимаю, одинаковое:


String foo(String a1, String a2, String a3) {
  StringBuilder sb = new StringBuilder();
  sb.append(a1);
  sb.append(a2);
  sb.append(a3);
  return sb.toString();
}

String _foo(String a1, String a2, String a3) {
  return new StringBuilder()
          .append(a1)
          .append(a2)
          .append(a3)
          .toString();
}
Не совсем. Например, в первом случае есть переменная sb, которую можно найти по имени и посмотреть значение в дебаггере на середине выражения. А ещё код StringBuilder может быть инструментирован и вернуть внезапно не this.

Спасибо! Уже не первый год работаю, но не устаю удивляться, как много тонкостей стоит вроде бы за простым кодом.

Первое, что бросается в глаза — не указана capacity в конструкторе StringBuilder. Она известна, и если её указать, для выходных строк длиной >16 память под StringBuilder не будет выделяться лишний раз, и будет меньше нагрузка на gc.
С хитрыми оптимизациями JIT, возможно, это не будет иметь значения, но, мне кажется, стоит попробовать.

Действительно, передав размер конечной строки сразу в конструктор StringBuilder-а можно выделить память только один раз и ровно столько, сколько нужно. Но это почти не даёт прироста.
Представьте такой код:


public class ToHexStringConverter {

  private static final char[] HEX_CHARS = {
          '0', '1', '2', '3',
          '4', '5', '6', '7',
          '8', '9', 'A', 'B',
          'C', 'D', 'E', 'F'
  };

  public String toHexString(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
      int temp = (int) b & 0xFF;
      sb.append(HEX_CHARS[temp / 16]);
      sb.append(HEX_CHARS[temp % 16]);
    }
    return sb.toString();
  }

  public String patched_toHexString(byte[] bytes) {
    StringBuilder sb = new StringBuilder(bytes.length * 2);
    for (byte b : bytes) {
      int temp = (int) b & 0xFF;
      sb.append(HEX_CHARS[temp / 16]);
      sb.append(HEX_CHARS[temp % 16]);
    }
    return sb.toString();
  }
}

Здесь оба метода преобразовывают входной массив байт в его шестнадцатеричное представление. Первый метод исходный, второй — улучшенный. Смысл улучшения в том, что мы используем известный размер массива, а также тот факт, что каждый байт соответствует двум знакам, добавляемым к StringBuilder-у, для передачи ёмкости в конструктор.


Но увы, это не даёт значимого прироста. Возьмём 20 Мб и скормим обоим методам:


@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(jvmArgsAppend = {"-XX:+UseParallelGC", "-Xms2g", "-Xmx2g"})
public class SizedStringBuilderBenchmark {

  private byte[] bytes;
  private ToHexStringConverter converter;

  @Setup
  public void init() {
    bytes = new byte[1024 * 1024 * 20];
    converter = new ToHexStringConverter();
    ThreadLocalRandom.current().nextBytes(bytes);
  }

  @Benchmark
  public String original() {
    return converter.toHexString(bytes);
  }

  @Benchmark
  public String patched() {
    return converter.patched_toHexString(bytes);
  }
}

На выходе имеем


Benchmark                        Mode  Cnt          Score   Error   Units

original                         avgt   25        124,766 ± 1,610   ms/op
patched                          avgt   25        113,763 ± 3,432   ms/op

original:·gc.alloc.rate.norm     avgt   25  192938425,434 ± 0,886    B/op
patched:·gc.alloc.rate.norm      avgt   25   83886183,845 ± 1,341    B/op

Действительно, выигрыш по памяти более чем двукратный, но разница во времени не столь велика.


Конкретно в этом случае ощутимый прирост даст выбрасывание StringBuilder-a:


public class ToHexStringConverter {

  private static final char[] HEX_CHARS = {
          '0', '1', '2', '3',
          '4', '5', '6', '7',
          '8', '9', 'A', 'B',
          'C', 'D', 'E', 'F'
  };

  //...

  public String chars_toHexString(byte[] bytes) {
    char[] result = new char[bytes.length * 2];
    int idx = 0;
    for (byte b : bytes) {
      int temp = (int) b & 0xFF;
      result[idx++] = HEX_CHARS[temp / 16];
      result[idx++] = HEX_CHARS[temp % 16];
    }
    return new String(result);
  }
}

Берём тот же замер:


@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(jvmArgsAppend = {"-XX:+UseParallelGC", "-Xms2g", "-Xmx2g"})
public class SizedStringBuilderBenchmark {

  private byte[] bytes;
  private ToHexStringConverter converter;

  @Setup
  public void init() {
    bytes = new byte[1024 * 1024 * 20];
    converter = new ToHexStringConverter();
    ThreadLocalRandom.current().nextBytes(bytes);
  }

  @Benchmark
  public String original() {
    return converter.toHexString(bytes);
  }

  @Benchmark
  public String patched() {
    return converter.patched_toHexString(bytes);
  }

  @Benchmark
  public String chars() {
    return converter.chars_toHexString(bytes);
  }

}

И вот тут получаем почти 4-х кратный прирост по времени:


Benchmark                        Mode  Cnt          Score   Error   Units

original                         avgt   25        124,766 ± 1,610   ms/op
patched                          avgt   25        113,763 ± 3,432   ms/op
chars                            avgt   25         32,367 ± 0,656   ms/op

original:·gc.alloc.rate.norm     avgt   25  192938425,434 ± 0,886    B/op
patched:·gc.alloc.rate.norm      avgt   25   83886183,845 ± 1,341    B/op
chars:·gc.alloc.rate.norm        avgt   25  125829182,781 ± 0,242    B/op

Оригинальная ваша задача


код
public String appendBounds(Data data) {
  int beginIndex = data.beginIndex;
  int endIndex = data.endIndex;

  return new StringBuilder()
          .append('L')
          .append(data.str, beginIndex, endIndex)
          .append(';')
          .toString();
}

отличается от тестируемой в сообщении тем, что в "оригинале" происходит лишь копирование данных, без доступа к массиву (HEX_CHARS) по 2 раза на каждый символ. Полагаю, в "оригинале" соотношение скоростей в вариантах с переданным capacity и без будет несколько больше.

Вы правы, передача размера в StringBuilder даёт неплохой прирост в данном случае


@Benchmark
public String appendBoundsSized(Data data) {
  int beginIndex = data.beginIndex;
  int endIndex = data.endIndex;

  return new StringBuilder(endIndex - beginIndex + 2)
          .append('L')
          .append(data.str, beginIndex, endIndex)
          .append(';')
          .toString();
}

Вывод


Benchmark                             length nonLatin   Score   rror Units
appendBounds                              10     true    41,3 ±  0,9 ns/op
appendBounds                             100     true   143,6 ±  8,1 ns/op
appendBounds                            1000     true  1206,5 ± 48,7 ns/op

appendBoundsSized                         10     true    42,6 ±  0,7 ns/op
appendBoundsSized                        100     true   116,2 ± 17,1 ns/op
appendBoundsSized                       1000     true   880,9 ± 33,7 ns/op

appendBounds                              10    false    28,4 ±  0,2 ns/op
appendBounds                             100    false    99,0 ±  3,9 ns/op
appendBounds                            1000    false   663,3 ± 44,5 ns/op

appendBoundsSized                         10    false    29,5 ±  0,9 ns/op
appendBoundsSized                        100    false    68,7 ±  3,9 ns/op
appendBoundsSized                       1000    false   485,6 ± 11,2 ns/op

appendBounds:·gc.alloc.rate.norm          10     true   200,0 ±  0,0  B/op
appendBounds:·gc.alloc.rate.norm         100     true  1192,0 ±  0,0  B/op
appendBounds:·gc.alloc.rate.norm        1000     true 10200,0 ±  0,0  B/op

appendBoundsSized:·gc.alloc.rate.norm     10     true   192,0 ±  0,0  B/op
appendBoundsSized:·gc.alloc.rate.norm    100     true   736,0 ±  0,0  B/op
appendBoundsSized:·gc.alloc.rate.norm   1000     true  6144,0 ±  0,0  B/op

appendBounds:·gc.alloc.rate.norm          10    false   112,0 ±  0,0  B/op
appendBounds:·gc.alloc.rate.norm         100    false   544,0 ±  0,0  B/op
appendBounds:·gc.alloc.rate.norm        1000    false  4152,0 ±  0,0  B/op

appendBoundsSized:·gc.alloc.rate.norm     10    false   112,0 ±  0,0  B/op
appendBoundsSized:·gc.alloc.rate.norm    100    false   288,0 ±  0,0  B/op
appendBoundsSized:·gc.alloc.rate.norm   1000    false  2096,0 ±  0,0  B/op

Интересно, что в этом случае (малое количество вызовов StringBuilder.append) точное выделение памяти даёт очень хороший прирост, чего не скажешь о случае, когда вызовов StringBuilder.append значительно больше.

Мне кажется что даже в этом примере если вы будете не один 10 мб массив конвертировать, а по очереди 10000 массивов по 1 кб разница уже будет заметна. Потому что стрингбилдер скорее всего когда кончается буффер увеличивает буфер в 2 раза в итоге при конвертации 10 мб новых выделений памяти происходит всего несколько раз (java не знаю просто из общих рассуждений)

Sign up to leave a comment.

Articles