Бэкграунд
Я раньше считал, что JDK в целом хорошо оптимизирована, и если в JDK есть простой способ решения какой-то задачи, то он вполне подойдет для большинства ситуаций и будет работать хорошо.
Но я обнаружил, что иногда некоторые классы или методы работают на удивление плохо. Знание таких аномалий полезно при работе с требовательным к производительности кодом.
В этом посте рассмотрим один из подобных кейсов: поразительно низкая производительность String.format()
при простой конкатенации строк.
Более конкретно…
Случай, на который я обратил внимание — это конкатенация строк. Например, получение составных ключей:
String key = String.format("%s.%s", keyspace, tableName);
Это эквивалентно следующему:
String key = keyspace + "." + tableName; // или
key = new StringBuilder().append(keyspace)
.append('.').append(tableName).toString();
Можно ожидать одинаковую производительность всех этих вариантов, учитывая, что, с точки зрения пользователя, они выполняют одну и ту же операцию.
Но у String.format() должны быть низкие накладные расходы?
Следует отметить, что мы рассматриваем очень простой пример. Возможности String.format()
намного шире — это очень мощный инструмент для различного форматирования строк. Здесь мы будем стрелять из пушки по воробьям.
Важно отметить, что при каждом вызове format()
происходит декодирование шаблона строки — это накладные расходы, или же JDK должен использовать какое-то хитрое кеширование. Но в отличие от чего-то вроде Pattern.compile()
для регулярных выражений, для форматированных строк препроцессинг отсутствует.
Учитывая это, разумно предположить, что в нашем простом случае с конкатенацией у String.format()
будут некоторые издержки.
Но действительно ли будут? Неужели разработчики JDK не совершили здесь еще один впечатляющий подвиг?
Позовем на помощь нашего друга JMH
Исходный код JMH-теста (StringConcatenation
) размещен в репозитории https://github.com/cowtowncoder/misc-micro-benchmarks.
Вы можете запустить тест следующим образом:
java -jar target/microbenchmarks.jar StringConcatenation
На своем компьютере (Mac Mini (2018) 3.2Ghz 6-core Intel Core i7) я получил следующие результаты:
m1_StringFormat thrpt 15 61337.088 ± 654.370 ops/s
m2_StringBuilder thrpt 15 2683849.107 ± 22092.481 ops/s
m3_StringBuilderPrealloc thrpt 15 2654994.965 ± 36881.162 ops/s
m4_ManualConcatenation thrpt 15 2700825.252 ± 27906.924 ops/s
Ваши цифры, конечно, будут немного отличаться.
Тест-кейсы
Прежде чем перейдем к анализу результатов, приведу описания тест-кейсов:
m1_StringFormat
: используетсяString.format("%s.%s", first, second)
.m2_StringBuilder
: конкатенация с использованиемStringBuilder
, как было показано ранее.m3_StringBuilderPrealloc
: то же самое, что и m2, но с вычислением оптимального начального размераStringBuilder
, чтобы избежать многократных повторных выделений памяти для буфера. Это попытка оптимизации m2.m4_ManualConcatenation
: использование оператора "+":String str = first+"."+second
; (который после компиляции должен стать аналогичным m2).
Во всех тестах конкатенируются пары строк в цикле с 32 итерациями. Тестовые строки довольно просты и могут быть не репрезентативны, но у меня нет цели воспроизвести конкретный кейс (хотя вам следует это сделать, если у вас есть конкретные данные!).
Разбираемся с результатами
Итак, давайте посмотрим на полученные результаты. Тесты m2, m3 и m4 дают примерно одинаковый результат: около 2,5 миллионов итераций в секунду. При 32 конкатенациях это составит около 80 миллионов конкатенаций в секунду (не забудьте также про различные накладные расходы, такие как сборка мусора). Неплохо.
Но в первом случае (использование String.format()
) дела обстоят намного хуже — только 62 000 итераций в секунду. Хотя это все еще почти 2 миллиона конкатенаций в секунду (что достаточно для большинства применений), но почти на два порядка медленнее, чем прямое или косвенное использование StringBuilder
.
То есть в нашем случае StringBuilder
более чем в 40 раз быстрее String.format()
. Скорее всего, это гораздо больше, чем большинство Java-разработчиков могло бы предположить.
Первые три случая интересны только тем, что m3 (предварительное выделение буфера в StringBuilder
) не быстрее, чем использование конструктора по умолчанию. С учетом разброса значений, результаты m2 и m3 перекрываются, поэтому неясно, что из них быстрее. По сути, их производительность практически идентична.
Это может быть связано с тем, что мы используем относительно небольшие строки, для которых размер буфера StringBuilder
по умолчанию (16) работает достаточно хорошо. Если бы строки были больше, то результаты для версии с предварительным распределением могли быть лучше, но, вероятно, ненамного.
Но что происходит внутри String.format()?
Можем ли мы выяснить, что происходит под капотом String.format()
? Это достаточно легко сделать с помощью async-profiler.
Для начала, чтобы JMH-тесты в классе выполнялись эффективно бесконечно (номинально в течение 1 часа вместо 5 секунд на форк), изменим параметры теста в StringConcatenation.java
.
// @Measurement(iterations = 5, time = 1)
@Measurement(iterations = 3600, time = 1)
Затем запустим "бесконечный" тест m1 с помощью:
java -jar target/microbenchmarks.jar StringConcatenation.m1
После запуска посмотрим id процесса (через top) и запустим профилирование в течение 30 секунд:
~/bin/async-profiler -e cpu -d 30 -f ~/profile-string-format.txt 67640
Мы указали, что хотим использовать профилирование процессора в течение 30 секунд, результаты записать в указанный файл в виде текста (есть и другие форматы, такие как json) и профилировать процесс Java с идентификатором 67640 (вам нужно будет использовать идентификатор вашего запущенного процесса).
Через 30 секунд мы получим файл с результатами (у меня 2592 строки) со сводкой в конце:
ns percent samples top
---------- ------- ------- ---
3100000000 10.97% 310 java.util.regex.Pattern$Start.match
2970000000 10.51% 297 java.util.regex.Pattern$GroupHead.match
2370000000 8.39% 237 java.util.Formatter.format
2110000000 7.47% 211 java.lang.AbstractStringBuilder.ensureCapacityInternal
1590000000 5.63% 159 jshort_disjoint_arraycopy
1420000000 5.02% 142 java.util.Formatter$FormatSpecifier.index
1340000000 4.74% 134 java.util.Formatter.parse
1270000000 4.49% 127 arrayof_jint_fill
1240000000 4.39% 124 java.util.regex.Pattern$BmpCharProperty.match
1100000000 3.89% 110 java.util.regex.Pattern.matcher
990000000 3.50% 99 java.util.Formatter$FormatSpecifier.width
980000000 3.47% 98 java.util.regex.Pattern$Branch.match
8
...
Глядя на две верхние записи, мы видим, что для декодирования шаблона "%s.%s" используются регулярные выражения (подготовительный этап, о котором я говорил). Третью запись понять немного сложно, но есть несколько других методов с упоминанием Pattern
, которые, вероятно, также используются для обработки шаблона. В сумме получается около 40% времени профилировщика.
Есть еще интересный момент: внутреннее перераспределение StringBuilder
по какой-то причине тоже занимает довольно много времени. Но это говорит о том, что больше времени действительно тратится на обработку шаблона строки.
Плохая новость в том, что, похоже, нет никакого способа сделать какой-то препроцессинг java.util.Formatter
, чтобы избежать повторного создания внутренних структур данных. Formatter
— класс, который фактически реализует функции форматирования. Все, что делает String.format()
— это new Formatter().Format(...).ToString()
. Если бы это было возможно, вероятно, все еще была значительная разница, но не в 40 раз.
Насколько это все важно?
Влияние на производительность, как всегда, зависит от вашего конкретного случая.
Что касается меня, то я использую String.format()
в следующих ситуациях:
Одиночный вызов во время инициализации.
При обработке исключений (которые сами по себе связаны с большими накладными расходами).
В других случаях, когда операция вызывается редко.
И избегаю использования в критических участках кода, включая циклы.
Перевод статьи подготовлен в преддверии старта курса Java Developer. Professional.
Комментарии (2)
tmk826
03.10.2021 00:22Так сравнивать надо что то типа String.format("%4s %4d %4.2f",...). Любой инструмент который используется не по назначению даёт отрицательный результат.
Throwable
Блин, мне интуитивно казалось, что String.format() как-то связан с нативной сишной реализацией, а посему должен работать быстрее. Для сравнения можно было бы еще добавить форматирование с MessageFormat.