Комментарии 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();
ещё на этапе компиляции исходного кода? Что мешает такому преобразованию?
Вы имеете ввиду неэквивалентность байт-кода? Поведение обоих методов, насколько я понимаю, одинаковое:
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();
}
Первое, что бросается в глаза — не указана 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 не знаю просто из общих рассуждений)
Как ухудшить производительность, улучшая её