1. Введение
В этом руководстве мы сосредоточимся на аспекте производительности Java String API.
Мы углубимся в операции создания, преобразования и модификации строк, чтобы проанализировать доступные варианты и сравнить их эффективность.
Предложения, которые мы собираемся сделать, не обязательно подходят для каждого приложения. Но, конечно же, мы собираемся показать, как выиграть в производительности, когда критично время работы приложения.
2. Создание новой строки
Как вы знаете, в Java строки неизменяемы. Таким образом, каждый раз, когда мы создаем или объединяем объект String, Java создает новую строку — это может быть особенно затратным, если делать это в цикле.
2.1. Использование конструктора
В большинстве случаев нам следует избегать создания строк с помощью конструктора, если мы не знаем, что делаем.
Давайте сначала создадим объект newString внутри цикла, используя конструктор new String(), а затем оператор =.
Для написания теста мы будем использовать инструмент JMH (Java Microbenchmark Harness).
Наша конфигурация:
Здесь мы используем режим SingeShotTime, который запускает метод только один раз. Поскольку мы хотим измерить производительность операций со строками внутри цикла, для этого доступна аннотация @Measurement.
@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Measurement(batchSize = 10000, iterations = 10)
@Warmup(batchSize = 10000, iterations = 10)
public class StringPerformance {
}
Важно знать, что циклы бенчмаркинга непосредственно в наших тестах могут исказить результаты из-за различных оптимизаций, применяемых JVM.
Итак, мы вычисляем только одну операцию и позволяем JMH позаботиться о цикле. Короче говоря, JMH выполняет итерации, используя параметр batchSize.
Теперь добавим первый микротест:
В первом тесте на каждой итерации создается новый объект. Во втором тесте объект создается только один раз. Для оставшихся итераций тот же объект возвращается из пула констант String.
@Benchmark
public String benchmarkStringConstructor() {
return new String("baeldung");
}
@Benchmark
public String benchmarkStringLiteral() {
return "baeldung";
}
Давайте запустим тесты с числом итераций цикла = 1 000 000 и посмотрим на результаты:
Из значений Score мы ясно видим, что разница значительна.
Benchmark Mode Cnt Score Error Units
benchmarkStringConstructor ss 10 16.089 ± 3.355 ms/op
benchmarkStringLiteral ss 10 9.523 ± 3.331 ms/op
2.2. + Оператор
Давайте посмотрим на пример динамической конкатенации строк:
В наших результатах мы хотим увидеть среднее время выполнения. Формат выходного числа установлен в миллисекундах:
@State(Scope.Thread)
public static class StringPerformanceHints {
String result = "";
String baeldung = "baeldung";
}
@Benchmark
public String benchmarkStringDynamicConcat() {
return result + baeldung;
}
Теперь давайте проанализируем результаты. Как мы видим, добавление 1000 элементов в state.result занимает 47,331 миллисекунды. Следовательно, при увеличении количества итераций в 10 раз время выполнения вырастает до 4370,441 миллисекунд.
Benchmark 1000 10,000
benchmarkStringDynamicConcat 47.331 4370.411
Таким образом, время выполнения увеличивается квадратично. Следовательно, сложность динамической конкатенации в цикле из n итераций составляет O(n^2).
2.3. String.concat()
Еще один способ объединения строк — использование метода concat():
Единица времени вывода — миллисекунда, количество итераций — 100 000. Таблица результатов выглядит так:
@Benchmark
public String benchmarkStringConcat() {
return result.concat(baeldung);
}
2.4. String.format()
Benchmark Mode Cnt Score Error Units
benchmarkStringConcat ss 10 3403.146 ± 852.520 ms/op
Другой способ создания строк — использование метода String.format(). Под капотом он использует регулярные выражения для анализа ввода.
Давайте напишем тестовый пример JMH:
После этого запустим его и посмотрим на результаты:
String formatString = "hello %s, nice to meet you";
@Benchmark
public String benchmarkStringFormat_s() {
return String.format(formatString, baeldung);
}
Хотя код с String.format() выглядит более чистым и читаемым, мы не т выиграть здесь с точки зрения производительности.
Number of Iterations 10,000 100,000 1,000,000
benchmarkStringFormat_s 17.181 140.456 1636.279 ms/op
2.5. StringBuilder и StringBuffer
У нас уже есть статья, объясняющая StringBuffer и StringBuilder. Поэтому здесь мы покажем только дополнительную информацию об их производительности. StringBuilder использует массив с изменяемым размером и индекс, указывающий положение последней ячейки, используемой в массиве. Когда массив заполнен, он увеличивается в два раза и копирует все символы в новый массив.
Принимая во внимание, что изменение размера происходит не очень часто, мы можем рассматривать каждую операцию append() как постоянное время O(1). С учетом этого весь процесс имеет сложность O(n).
После модификации и запуска теста динамической конкатенации для StringBuffer и StringBuilder мы получаем:
Хотя разница в баллах невелика, мы можем заметить, что StringBuilder работает быстрее.
Benchmark Mode Cnt Score Error Units
benchmarkStringBuffer ss 10 1.409 ± 1.665 ms/op
benchmarkStringBuilder ss 10 1.200 ± 0.648 ms/op
«К счастью, в простых случаях нам не нужен StringBuilder, чтобы соединить одну строку с другой. Иногда статическая конкатенация с + может фактически заменить StringBuilder. Под капотом последние компиляторы Java будут вызывать StringBuilder.append() для объединения строк.
Это означает значительный выигрыш в производительности.
3. Служебные операции
3.1. StringUtils.replace() vs String.replace()
Интересно знать, что версия Apache Commons для замены String работает намного лучше, чем собственный метод replace() String. Ответ на это различие лежит в их реализации. String.replace() использует шаблон регулярного выражения для соответствия String.
Напротив, StringUtils.replace() широко использует indexOf(), который работает быстрее.
Теперь пришло время тестов производительности:
Установив для параметра batchSize значение 100 000, мы представляем результаты:
@Benchmark
public String benchmarkStringReplace() {
return longString.replace("average", " average !!!");
}
@Benchmark
public String benchmarkStringUtilsReplace() {
return StringUtils.replace(longString, "average", " average !!!");
}
Хотя разница между числами не слишком велика, файл StringUtils. replace() имеет лучший результат. Конечно, числа и разрыв между ними могут варьироваться в зависимости от таких параметров, как количество итераций, длина строки и даже версия JDK.
Benchmark Mode Cnt Score Error Units
benchmarkStringReplace ss 10 6.233 ± 2.922 ms/op
benchmarkStringUtilsReplace ss 10 5.355 ± 2.497 ms/op
С последними версиями JDK 9+ (наши тесты выполняются на JDK 10) обе реализации имеют примерно одинаковые результаты. Теперь давайте понизим версию JDK до версии 8 и снова проведем тесты:
Разница в производительности теперь огромна и подтверждает теорию, которую мы обсуждали в начале.
Benchmark Mode Cnt Score Error Units
benchmarkStringReplace ss 10 48.061 ± 17.157 ms/op
benchmarkStringUtilsReplace ss 10 14.478 ± 5.752 ms/op
3.2. split()
Прежде чем мы начнем, будет полезно проверить методы разделения строк, доступные в Java.
Когда возникает необходимость разбить строку по разделителю, первая функция, которая приходит на ум, обычно это String.split(regex). Однако это приводит к серьезным проблемам с производительностью, поскольку принимает аргумент регулярного выражения. В качестве альтернативы мы можем использовать класс StringTokenizer, чтобы разбить строку на токены.
Другой вариант — API-интерфейс Splitter от Guava. Наконец, старый добрый indexOf() также доступен для повышения производительности нашего приложения, если нам не нужны функциональные возможности регулярных выражений.
Теперь пришло время написать тесты для опции String.split():
Pattern.split() :
String emptyString = " ";
@Benchmark
public String [] benchmarkStringSplit() {
return longString.split(emptyString);
}
StringTokenizer :
@Benchmark
public String [] benchmarkStringSplitPattern() {
return spacePattern.split(longString, 0);
}
String.indexOf( ) :
List stringTokenizer = new ArrayList<>();
@Benchmark
public List benchmarkStringTokenizer() {
StringTokenizer st = new StringTokenizer(longString);
while (st.hasMoreTokens()) {
stringTokenizer.add(st.nextToken());
}
return stringTokenizer;
}
Guava’s Splitter :
List stringSplit = new ArrayList<>();
@Benchmark
public List benchmarkStringIndexOf() {
int pos = 0, end;
while ((end = longString.indexOf(' ', pos)) >= 0) {
stringSplit.add(longString.substring(pos, end));
pos = end + 1;
}
return stringSplit;
}
Наконец, запускаем и сравниваем результаты для batchSize = 100,000:
@Benchmark
public List<String> benchmarkGuavaSplitter() {
return Splitter.on(" ").trimResults()
.omitEmptyStrings()
.splitToList(longString);
}
Как видим, наихудшую производительность имеет метод BenchmarkStringSplitPattern, где мы используем класс Pattern. В результате мы можем узнать, что использование класса регулярного выражения с методом split() может привести к многократной потере производительности.
Benchmark Mode Cnt Score Error Units
benchmarkGuavaSplitter ss 10 4.008 ± 1.836 ms/op
benchmarkStringIndexOf ss 10 1.144 ± 0.322 ms/op
benchmarkStringSplit ss 10 1.983 ± 1.075 ms/op
benchmarkStringSplitPattern ss 10 14.891 ± 5.678 ms/op
benchmarkStringTokenizer ss 10 2.277 ± 0.448 ms/op
Аналогично, мы заметили, что самые быстрые результаты дают примеры с использованием indexOf() и split().
3.3. Преобразование в строку
В этом разделе мы собираемся измерить показатели преобразования строки во время выполнения. Чтобы быть более конкретным, мы рассмотрим метод конкатенации Integer.toString():
String.valueOf() :
int sampleNumber = 100;
@Benchmark
public String benchmarkIntegerToString() {
return Integer.toString(sampleNumber);
}
[некоторое целочисленное значение] + \»œ\»: ~~ String.format() :
@Benchmark
public String benchmarkStringValueOf() {
return String.valueOf(sampleNumber);
}
После запуска тестов мы увидим вывод для batchSize = 10,000:
@Benchmark
public String benchmarkStringConvertPlus() {
return sampleNumber + "";
}
Проанализировав результаты, мы видим, что тест для Integer.toString () имеет лучший результат 0,953 миллисекунды. Напротив, преобразование, включающее String.format(“%d“), имеет наихудшую производительность.
String formatDigit = "%d";
@Benchmark
public String benchmarkStringFormat_d() {
return String.format(formatDigit, sampleNumber);
}
Это логично, потому что синтаксический анализ строки формата является дорогостоящей операцией.
Benchmark Mode Cnt Score Error Units
benchmarkIntegerToString ss 10 0.953 ± 0.707 ms/op
benchmarkStringConvertPlus ss 10 1.464 ± 1.670 ms/op
benchmarkStringFormat_d ss 10 15.656 ± 8.896 ms/op
benchmarkStringValueOf ss 10 2.847 ± 11.153 ms/op
3.4. Сравнение строк
Давайте оценим различные способы сравнения строк. Количество итераций составляет 100 000.
Вот наши тесты производительности для операции String.equals():
String.equalsIgnoreCase() :
String.matches() :
@Benchmark
public boolean benchmarkStringEquals() {
return longString.equals(baeldung);
}
String.compareTo () :
@Benchmark
public boolean benchmarkStringEqualsIgnoreCase() {
return longString.equalsIgnoreCase(baeldung);
}
После запускаем тесты и выводим результаты:
@Benchmark
public boolean benchmarkStringMatches() {
return longString.matches(baeldung);
}
Как всегда, цифры говорят сами за себя. Matches() занимает больше всего времени, так как использует регулярное выражение для сравнения равенства.
@Benchmark
public int benchmarkStringCompareTo() {
return longString.compareTo(baeldung);
}
Напротив, equals() и equalsIgnoreCase() являются лучшим выбором.
Benchmark Mode Cnt Score Error Units
benchmarkStringCompareTo ss 10 2.561 ± 0.899 ms/op
benchmarkStringEquals ss 10 1.712 ± 0.839 ms/op
benchmarkStringEqualsIgnoreCase ss 10 2.081 ± 1.221 ms/op
benchmarkStringMatches ss 10 118.364 ± 43.203 ms/op
3.5. String.matches() против предварительно скомпилированного шаблона
«Теперь давайте отдельно рассмотрим шаблоны String.matches() и Matcher.matches(). Первый принимает регулярное выражение в качестве аргумента и компилирует его перед выполнением.
Итак, каждый раз, когда мы вызываем String.matches(), он компилирует шаблон:
Второй метод повторно использует объект шаблона:
А теперь результаты:
@Benchmark
public boolean benchmarkStringMatches() {
return longString.matches(baeldung);
}
Как мы видим, сопоставление с предварительно скомпилированным регулярным выражением работает примерно в три раза быстрее.
Pattern longPattern = Pattern.compile(longString);
@Benchmark
public boolean benchmarkPrecompiledMatches() {
return longPattern.matcher(baeldung).matches();
}
3.6. Проверка длины
Benchmark Mode Cnt Score Error Units
benchmarkPrecompiledMatches ss 10 29.594 ± 12.784 ms/op
benchmarkStringMatches ss 10 106.821 ± 46.963 ms/op
Наконец, давайте сравним метод String.isEmpty():
и метод String.length():
Сначала мы вызываем их через longString = †«Здравствуйте, baeldung, в среднем я немного длиннее других строк». Строка. Пакетный размер равен 10 000:
@Benchmark
public boolean benchmarkStringIsEmpty() {
return longString.isEmpty();
}
После этого давайте установим пустую строку longString = «œ» и снова запустим тесты: случаи имеют примерно одинаковый балл. Однако вызов isEmpty() работает быстрее, чем проверка того, равна ли длина строки нулю.
@Benchmark
public boolean benchmarkStringLengthZero() {
return emptyString.length() == 0;
}
4. Дедупликация строк
Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.295 ± 0.277 ms/op
benchmarkStringLengthZero ss 10 0.472 ± 0.840 ms/op
Начиная с JDK 8, функция дедупликации строк доступна для устранения потребления памяти. Проще говоря, этот инструмент ищет строки с одинаковым или повторяющимся содержимым, чтобы сохранить одну копию каждого отдельного строкового значения в пуле строк.
Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.245 ± 0.362 ms/op
benchmarkStringLengthZero ss 10 0.351 ± 0.473 ms/op
В настоящее время существует два способа обработки дубликатов строк:
использование String.intern(), вручную включающее дедупликацию строк
Давайте подробнее рассмотрим каждый вариант.
4.1. String.intern()
-
Прежде чем двигаться вперед, будет полезно прочитать о ручном интернировании в нашей статье. С помощью String.intern() мы можем вручную установить ссылку на объект String внутри глобального пула строк.
Затем JVM может использовать возврат ссылки, когда это необходимо. С точки зрения производительности наше приложение может значительно выиграть от повторного использования строковых ссылок из пула констант.
Важно знать, что пул строк JVM не является локальным для потока. Каждая строка, которую мы добавляем в пул, также доступна для других потоков.
Однако есть и серьезные недостатки:
для правильной поддержки нашего приложения нам может потребоваться установить параметр -XX:StringTableSize JVM для увеличения размера пула. JVM требуется перезагрузка, чтобы увеличить размер пула. Вызов String.intern() вручную занимает много времени. Он растет в алгоритме линейного времени со сложностью O (n), кроме того, частые вызовы длинных объектов String могут вызвать проблемы с памятью
Чтобы получить некоторые проверенные цифры, давайте запустим тест:
Кроме того, вывод счет в миллисекундах:
-
Заголовки столбцов здесь представляют различные значения количества итераций от 1000 до 1 000 000. Для каждого номера итерации у нас есть оценка производительности теста. Как мы видим, оценка резко возрастает в дополнение к количеству итераций.
4.2. Включить дедупликацию автоматически
@Benchmark
public String benchmarkStringIntern() {
return baeldung.intern();
}
Во-первых, этот параметр является частью сборщика мусора G1. По умолчанию эта функция отключена. Поэтому нам нужно включить его с помощью следующей команды:
Benchmark 1000 10,000 100,000 1,000,000
benchmarkStringIntern 0.433 2.243 19.996 204.373
Важно отметить, что включение этой опции не гарантирует, что произойдет дедупликация строк. Кроме того, он не обрабатывает молодые строки. Для управления минимальным возрастом обработки строк доступна опция XX:StringDeduplicationAgeThreshold=3 JVM. Здесь 3 — параметр по умолчанию.
5. Резюме
В этом уроке мы пытаемся дать несколько советов по более эффективному использованию строк в нашей повседневной жизни кодирования.
-XX:+UseG1GC -XX:+UseStringDeduplication
В результате мы можем выделить некоторые предложения по повышению производительности нашего приложения:
«при объединении строк наиболее удобным вариантом, который приходит на ум, является StringBuilder. Однако с маленькими строками операция + имеет почти такую же производительность. Под капотом компилятор Java может использовать класс StringBuilder для уменьшения количества строковых объектов для преобразования значения в строку, [some type].toString() (например, Integer.toString()) работает быстрее, чем String. значение(). Поскольку эта разница незначительна, мы можем свободно использовать String.valueOf(), чтобы не иметь зависимости от типа входного значения, когда дело доходит до сравнения строк, ничто не сравнится с String.equals() до сих пор. Дедупликация строк улучшает производительность в больших , многопоточные приложения. Но чрезмерное использование String.intern() может привести к серьезным утечкам памяти, замедляя работу приложения для разделения строк, которые мы должны использовать indexOf() для повышения производительности. Однако в некоторых некритических случаях может подойти функция String.split(). Использование Pattern.match() значительно повышает производительность строки. String.isEmpty() быстрее, чем String.length() ==0
. Имейте в виду, что цифры, которые мы представляем здесь, являются просто результатами тестов JMH, поэтому вам всегда следует тестировать в рамках вашей собственной системы и среды выполнения, чтобы определить влияние таких оптимизаций.
Наконец, как всегда, код, использованный во время обсуждения, можно найти на GitHub.
-
»
Also, keep in mind that the numbers we present here are just JMH benchmark results – so you should always test in the scope of your own system and runtime to determine the impact of these kinds of optimizations.
Finally, as always, the code used during the discussion can be found over on GitHub.