Подсказки производительности строки

Подсказки производительности строки

**

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.

**