Бэкграунд
Я раньше считал, что 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).
Разбираемся с результатами
Итак, давайте посмотрим на полученные результаты. Тесты 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() в следующих ситуациях:
- Одиночный вызов во время инициализации.
- При обработке исключений (которые сами по себе связаны с большими накладными расходами).
- В других случаях, когда операция вызывается редко.
Измеряем производительность String.format() в Java
Бэкграунд Я раньше считал, что JDK в целом хорошо оптимизирована, и если в JDK есть простой способ решения какой-то задачи, то он вполне подойдет для большинства ситуаций и будет работать хорошо. Но я...
habr.com