Бэкграунд

Я раньше считал, что 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() в следующих ситуациях:

  1. Одиночный вызов во время инициализации.

  2. При обработке исключений (которые сами по себе связаны с большими накладными расходами).

  3. В других случаях, когда операция вызывается редко.

И избегаю использования в критических участках кода, включая циклы.


Перевод статьи подготовлен в преддверии старта курса Java Developer. Professional.

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


  1. Throwable
    28.09.2021 10:27
    +2

    Блин, мне интуитивно казалось, что String.format() как-то связан с нативной сишной реализацией, а посему должен работать быстрее. Для сравнения можно было бы еще добавить форматирование с MessageFormat.


  1. tmk826
    03.10.2021 00:22

    Так сравнивать надо что то типа String.format("%4s %4d %4.2f",...). Любой инструмент который используется не по назначению даёт отрицательный результат.