Подсказки производительности строки
**
1. Вступление
В этом руководствеwe’re going to focus on the performance aspect of the Java String API.
Мы углубимся в операции создания, преобразования и измененияString, чтобы проанализировать доступные варианты и сравнить их эффективность.
Предложения, которые мы собираемся внести, не обязательно подходят для каждого приложения. Но, безусловно, мы собираемся показать, как выиграть в производительности, когда время работы приложения критично.
2. Построение новой строки
Как вы знаете, в Java строки являются неизменяемыми. Поэтому каждый раз, когда мы конструируем или объединяем объектString, Java создает новыйString –, это может быть особенно затратно, если выполняется в цикле.
2.1. Использование конструктора
В большинстве случаевwe should avoid creating Strings using the constructor unless we know what are we doing.
Давайте сначала создадим объект sobjectnewString внутри цикла, используя конструкторnew String(), а затем оператор=.
Чтобы написать наш тест, мы будем использовать инструментJMH (Java Microbenchmark Harness).
Наша конфигурация:
@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Measurement(batchSize = 10000, iterations = 10)
@Warmup(batchSize = 10000, iterations = 10)
public class StringPerformance {
}
Здесь мы используем режимSingeShotTime, в котором метод запускается только один раз. Поскольку мы хотим измерить производительность операцийString внутри цикла, для этого доступна аннотация@Measurement.
Важно знать, чтоbenchmarking loops directly in our tests may skew the results because of various optimizations applied by JVM.
Поэтому мы вычисляем только одну операцию и позволяем JMH позаботиться о цикле. Короче говоря, JMH выполняет итерации, используя параметрbatchSize.
А теперь давайте добавим первый микротест:
@Benchmark
public String benchmarkStringConstructor() {
return new String("example");
}
@Benchmark
public String benchmarkStringLiteral() {
return "example";
}
В первом тесте новый объект создается на каждой итерации. Во втором тесте объект создается только один раз. Для остальных итераций тот же объект возвращается из пула константString’s.
Давайте запустим тесты с количеством итераций цикла= 1,000,000 и посмотрим на результаты:
Benchmark Mode Cnt Score Error Units
benchmarkStringConstructor ss 10 16.089 ± 3.355 ms/op
benchmarkStringLiteral ss 10 9.523 ± 3.331 ms/op
По значениямScore ясно видно, что разница значительная.
2.2. + Оператор
Давайте посмотрим на пример динамической конкатенацииString:
@State(Scope.Thread)
public static class StringPerformanceHints {
String result = "";
String example = "example";
}
@Benchmark
public String benchmarkStringDynamicConcat() {
return result + example;
}
В наших результатах мы хотим видеть среднее время выполнения. Формат выходного числа установлен в миллисекундах:
Benchmark 1000 10,000
benchmarkStringDynamicConcat 47.331 4370.411
А теперь давайте проанализируем результаты. Как мы видим, добавление элементов1000 вstate.result занимает47.331 миллисекунд. Следовательно, при увеличении количества итераций в 10 раз время выполнения увеличивается до4370.441 миллисекунд.
Таким образом, время выполнения растет квадратично. Следовательно, сложность динамической конкатенации в цикле из n итераций составляетO(n^2).
2.3. String.concat()с
Еще один способ связатьStrings - использовать методconcat():
@Benchmark
public String benchmarkStringConcat() {
return result.concat(example);
}
Выходная единица времени - миллисекунда, количество итераций - 100 000. Таблица результатов выглядит так:
Benchmark Mode Cnt Score Error Units
benchmarkStringConcat ss 10 3403.146 ± 852.520 ms/op
2.4. String.format()с
Другой способ создания строк - использование методаString.format(). Under the hood, it uses regular expressions to parse the input.
Напишем тестовый пример JMH:
String formatString = "hello %s, nice to meet you";
@Benchmark
public String benchmarkStringFormat_s() {
return String.format(formatString, example);
}
После этого мы запускаем его и видим результаты:
Number of Iterations 10,000 100,000 1,000,000
benchmarkStringFormat_s 17.181 140.456 1636.279 ms/op
Хотя код сString.format() выглядит более чистым и читаемым, мы не выигрываем здесь с точки зрения производительности.
2.5. StringBuilder иStringBuffer
У нас уже естьwrite-up, объясняющийStringBuffer иStringBuilder. Поэтому здесь мы покажем только дополнительную информацию об их работе. StringBuilder использует массив с изменяемым размером и индекс, который указывает позицию последней ячейки, используемой в массиве. Когда массив заполнен, он удваивает свой размер и копирует все символы в новый массив.
Учитывая, что изменение размера происходит нечасто,we can consider each append() operation as O(1) constant time. С учетом этого весь процесс имеет сложностьO(n) .
После изменения и запуска теста динамической конкатенации дляStringBuffer иStringBuilder, we получаем:
Benchmark Mode Cnt Score Error Units
benchmarkStringBuffer ss 10 1.409 ± 1.665 ms/op
benchmarkStringBuilder ss 10 1.200 ± 0.648 ms/op
Хотя разница в оценке невелика, мы можем заметитьthat StringBuilder works faster.
К счастью, в простых случаях нам не нужноStringBuilder, чтобы поставить одинString на другой. Иногдаstatic concatenation with + can actually replace StringBuilder. Under the hood, the latest Java compilers will call the StringBuilder.append() to concatenate strings.
Это означает значительный выигрыш в производительности.
3. Коммунальные операции
3.1. StringUtils.replace() противString.replace()
Интересно знать, чтоApache Commons version for replacing the String does way better than the String’s own replace() method. Ответ на эту разницу лежит в их реализации. String.replace() использует шаблон регулярного выражения для соответствияString.
Напротив,StringUtils.replace() широко используетindexOf(), что быстрее.
Пришло время тестовых тестов:
@Benchmark
public String benchmarkStringReplace() {
return longString.replace("average", " average !!!");
}
@Benchmark
public String benchmarkStringUtilsReplace() {
return StringUtils.replace(longString, "average", " average !!!");
}
УстановивbatchSize равным 100000, мы представляем результаты:
Benchmark Mode Cnt Score Error Units
benchmarkStringReplace ss 10 6.233 ± 2.922 ms/op
benchmarkStringUtilsReplace ss 10 5.355 ± 2.497 ms/op
Хотя разница между числами не слишком велика,StringUtils.replace() имеет лучший результат. Конечно, числа и разрыв между ними могут варьироваться в зависимости от таких параметров, как количество итераций, длина строки и даже версия JDK.
В последних версиях 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()с
Прежде чем мы начнем, будет полезно проверить строкуsplitting methods, доступную в Java.
Когда необходимо разделить строку с помощью разделителя, первая функция, которая приходит нам в голову, обычно -String.split(regex). Однако это приводит к серьезным проблемам с производительностью, поскольку принимает аргумент регулярного выражения. В качестве альтернативы мы можем использовать классStringTokenizer, чтобы разбить строку на токены.
Другой вариант - API GuavaSplitter. Наконец, старый добрыйindexOf() также доступен для повышения производительности нашего приложения, если нам не нужны функциональные возможности регулярных выражений.
Пришло время написать тесты производительности для параметраString.split():
String emptyString = " ";
@Benchmark
public String [] benchmarkStringSplit() {
return longString.split(emptyString);
}
Pattern.split():
@Benchmark
public String [] benchmarkStringSplitPattern() {
return spacePattern.split(longString, 0);
}
StringTokenizer:
List stringTokenizer = new ArrayList<>();
@Benchmark
public List benchmarkStringTokenizer() {
StringTokenizer st = new StringTokenizer(longString);
while (st.hasMoreTokens()) {
stringTokenizer.add(st.nextToken());
}
return stringTokenizer;
}
String.indexOf():
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;
}
Splitter гуавы:
@Benchmark
public List benchmarkGuavaSplitter() {
return Splitter.on(" ").trimResults()
.omitEmptyStrings()
.splitToList(longString);
}
Наконец, мы запускаем и сравниваем результаты дляbatchSize = 100,000:
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
Как видим, худшую производительность имеет методbenchmarkStringSplitPattern, где мы используем классPattern. В результате мы можем узнать, что использование класса регулярных выражений с методомsplit() может многократно приводить к потере производительности.
Аналогичноwe notice that the fastest results are providing examples with the use of indexOf() and split().
3.3. Преобразование вString
В этом разделе мы собираемся измерить оценки преобразования строк во время выполнения. Чтобы быть более конкретным, мы рассмотрим метод конкатенацииInteger.toString():
int sampleNumber = 100;
@Benchmark
public String benchmarkIntegerToString() {
return Integer.toString(sampleNumber);
}
String.valueOf():
@Benchmark
public String benchmarkStringValueOf() {
return String.valueOf(sampleNumber);
}
[some integer value] + “”:
@Benchmark
public String benchmarkStringConvertPlus() {
return sampleNumber + "";
}
String.format():
String formatDigit = "%d";
@Benchmark
public String benchmarkStringFormat_d() {
return String.format(formatDigit, sampleNumber);
}
После запуска тестов мы увидим результат дляbatchSize = 10,000:
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
Проанализировав результаты, видим, чтоthe test for Integer.toString() has the best score of 0.953 milliseconds. Напротив, преобразование с использованиемString.format(“%d”) имеет худшую производительность.
Это логично, поскольку синтаксический анализ форматаString - дорогостоящая операция.
3.4. Сравнение строк
Давайте оценим различные способы сравненияStrings.. Количество итераций составляет100,000.
Вот наши тесты производительности для операцииString.equals():
@Benchmark
public boolean benchmarkStringEquals() {
return longString.equals(example);
}
String.equalsIgnoreCase():
@Benchmark
public boolean benchmarkStringEqualsIgnoreCase() {
return longString.equalsIgnoreCase(example);
}
String.matches():
@Benchmark
public boolean benchmarkStringMatches() {
return longString.matches(example);
}
String.compareTo():
@Benchmark
public int benchmarkStringCompareTo() {
return longString.compareTo(example);
}
После этого мы запускаем тесты и отображаем результаты:
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
Как всегда, цифры говорят сами за себя. matches() занимает больше всего времени, так как использует регулярное выражение для сравнения равенства.
Напротив,the equals() and equalsIgnoreCase() are the best choices.
3.5. String.matches() противPrecompiled Pattern
Теперь давайте отдельно рассмотрим шаблоныString.matches() иMatcher.matches() . Первый принимает регулярное выражение в качестве аргумента и компилирует его перед выполнением.
Таким образом, каждый раз, когда мы вызываемString.matches(), он компилируетPattern:
@Benchmark
public boolean benchmarkStringMatches() {
return longString.matches(example);
}
Второй метод повторно использует объектPattern:
Pattern longPattern = Pattern.compile(longString);
@Benchmark
public boolean benchmarkPrecompiledMatches() {
return longPattern.matcher(example).matches();
}
А теперь результаты:
Benchmark Mode Cnt Score Error Units
benchmarkPrecompiledMatches ss 10 29.594 ± 12.784 ms/op
benchmarkStringMatches ss 10 106.821 ± 46.963 ms/op
Как мы видим, сопоставление с предварительно скомпилированным регулярным выражением работает примерно в три раза быстрее.
3.6. Проверка длины
Наконец, давайте сравним методString.isEmpty():
@Benchmark
public boolean benchmarkStringIsEmpty() {
return longString.isEmpty();
}
и методString.length():
@Benchmark
public boolean benchmarkStringLengthZero() {
return emptyString.length() == 0;
}
Сначала мы вызываем их поlongString = “Hello example, I am a bit longer than other Strings in average” String..batchSize - это10,000:
Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.295 ± 0.277 ms/op
benchmarkStringLengthZero ss 10 0.472 ± 0.840 ms/op
После этого давайте установим пустую строкуlongString = “” и снова запустим тесты:
Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.245 ± 0.362 ms/op
benchmarkStringLengthZero ss 10 0.351 ± 0.473 ms/op
Как мы заметили, методыbenchmarkStringLengthZero() иbenchmarkStringIsEmpty() в обоих случаях имеют примерно одинаковую оценку. Однако вызовisEmpty() works faster than checking if the string’s length is zero.
4. Дедупликация строк
Начиная с JDK 8, функция дедупликации строк доступна для устранения потребления памяти. Проще говоря,this tool is looking for the strings with the same or duplicate contents to store one copy of each distinct string value into the String pool.
В настоящее время существует два способа обработки дубликатовString:
-
используяString.intern() вручную
-
включение дедупликации строк
Рассмотрим подробнее каждый вариант.
4.1. String.intern()с
Прежде чем забегать вперед, будет полезно прочитать о ручном интернировании в нашихwrite-up. With String.intern() we can manually set the reference of the String object inside of the global String pool.
Затем JVM может использовать возврат ссылки, когда это необходимо. С точки зрения производительности, наше приложение может получить огромную выгоду от повторного использования строковых ссылок из постоянного пула.
Важно знать, чтоJVM String pool isn’t local for the thread. Each String that we add to the pool, is available to other threads as well.
Однако есть и серьезные недостатки:
-
для правильной поддержки нашего приложения нам может потребоваться установить параметр JVM-XX:StringTableSize для увеличения размера пула. JVM нуждается в перезагрузке, чтобы увеличить размер пула
-
calling String.intern() manually is time-consuming. Он растет в линейном алгоритме времени со сложностьюO(n)
-
дополнительноfrequent calls on long String objects may cause memory problems
Чтобы получить некоторые подтвержденные цифры, давайте проведем тест производительности:
@Benchmark
public String benchmarkStringIntern() {
return example.intern();
}
Кроме того, выходные результаты в миллисекундах:
Benchmark 1000 10,000 100,000 1,000,000
benchmarkStringIntern 0.433 2.243 19.996 204.373
Заголовки столбцов здесь представляют собой разные значенияiterations от1000 до1,000,000. Для каждого номера итерации у нас есть оценка производительности теста. Как мы заметили, количество очков резко увеличивается в дополнение к количеству итераций.
4.2. Автоматическое включение дедупликации
Во-первых,this option is a part of the G1 garbage collector. По умолчанию эта функция отключена. Поэтому нам нужно включить его с помощью следующей команды:
-XX:+UseG1GC -XX:+UseStringDeduplication
Важно отметить, чтоenabling this option doesn’t guarantee that String deduplication will happen. Кроме того, он не обрабатывает молодыеStrings.. Для управления минимальным возрастом обработкиStrings, XX:StringDeduplicationAgeThreshold=3 доступна опция JVM. Здесь3 - параметр по умолчанию.
5. Резюме
В этом руководстве мы пытаемся дать несколько советов, как более эффективно использовать строки в повседневной жизни кодирования.
В результатеwe can highlight some suggestions in order to boost our application performance:
-
when concatenating strings, the StringBuilder is the most convenient option, что приходит на ум. Однако с небольшими строками операция+ имеет почти такую же производительность. Под капотом компилятор Java может использовать классStringBuilder для уменьшения количества строковых объектов.
-
для преобразования значения в строку[some type].toString() (например,Integer.toString()) работает быстрее, чемString.valueOf(). Поскольку эта разница несущественна, мы можем свободно использоватьString.valueOf(), чтобы не иметь зависимости от типа входного значения.
-
когда дело доходит до сравнения строк, пока ничто не сравнится сString.equals()
-
ДедупликацияString повышает производительность в больших многопоточных приложениях. Но чрезмерное использованиеString.intern() может вызвать серьезные утечки памяти, замедляя работу приложения.
-
for splitting the strings we should use indexOf() to win in performance. Однако в некоторых некритических случаях функцияString.split() может подойти.
-
ИспользованиеPattern.match() значительно улучшает производительность
-
String.isEmpty() быстрее, чем String.length() ==0
Кроме того,keep in mind that the numbers we present here are just JMH benchmark results - поэтому вы всегда должны тестировать в рамках своей собственной системы и среды выполнения, чтобы определить влияние таких оптимизаций.
Наконец, как всегда, код, использованный во время обсуждения, можно найтиover on GitHub.
**