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.