Измеряем производительность String.format() в Java

Kate

Administrator
Команда форума

Бэкграунд​

Я раньше считал, что 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. В других случаях, когда операция вызывается редко.
И избегаю использования в критических участках кода, включая циклы.

 
Сверху