Статьи

Оптимальный метод для объединения строк в Java

Недавно мне задали этот вопрос — плохо ли для производительности использовать оператор + для объединения строк в Java?

Это заставило меня задуматься о различных способах объединения строк в Java и о том, как они будут работать друг против друга. Вот методы, которые я собираюсь исследовать:

  1. Использование оператора +
  2. Использование StringBuilder
  3. Использование StringBuffer
  4. Использование String.concat()
  5. Использование String.join (новое в Java8)

Я также экспериментировал с String.format() но это настолько ужасно медленно, что я пока оставлю это в этом посте.

Прежде чем идти дальше, мы должны выделить два варианта использования:

  1. Объединение двух строк в один вызов, например, в сообщении журнала. Поскольку это только один звонок, вы могли бы подумать, что производительность вряд ли является проблемой, но результаты все еще интересны и проливают свет на эту тему.
  2. Объединение двух строк в цикле. Здесь производительность гораздо больше проблем, особенно если ваши петли большие.

Мои первоначальные мысли и вопросы были следующими:

  1. Оператор + реализован с помощью StringBuilder, поэтому, по крайней мере, в случае объединения двух строк он должен давать результаты, аналогичные StringBuilder. Что именно происходит под одеялом?
  2. StringBuilder должен быть наиболее эффективным методом, в конце концов, класс был разработан для самой цели объединения строк и заменяет StringBuffer. Но каковы затраты на создание StringBuilder по сравнению с String.concat ()?
  3. StringBuffer был исходным классом для объединения строк — к сожалению, его методы синхронизированы. Синхронизация действительно не требуется, и впоследствии она была заменена StringBuilder, который не синхронизирован. Вопрос в том, оптимизирует ли JIT синхронизацию?
  4. String.concat () должен хорошо работать для 2 строк, но хорошо ли он работает в цикле?
  5. String.join () обладает большей функциональностью, чем StringBuilder. Как это влияет на производительность, если мы указываем ему присоединяться к строкам с помощью пустого разделителя?

Первый вопрос, который я хотел убрать, был о том, как работает оператор +. Я всегда понимал, что он использует StringBuilder под крышками, но чтобы доказать это, нам нужно изучить байт-код.

В наши дни проще всего смотреть на байт-код с помощью JITWatch, который является действительно отличным инструментом, созданным для понимания того, как JIT компилирует ваш код. Он имеет отличный вид, где вы можете просматривать свой исходный код рядом с байтовым кодом (также машинный код, если вы хотите перейти на этот уровень).

Снимок экрана 2015-02-17 в 17.27.46

Вот байт-код для действительно простого метода plus2 (), и мы видим, что действительно в строке 6 создается StringBuilder и добавляет переменные a (строка 14) и b (строка 18).

Я подумал, что было бы интересно сравнить это с использованием StringBuffer вручную, поэтому я создаю другой метод build2 () с результатами ниже.

Снимок экрана 2015-02-17 в 17.31.37

Сгенерированный здесь байт-код не так компактен, как метод plus (). StringBuilder хранится в кэше переменных (строка 13), а не просто остается в стеке. Я не уверен, почему это должно быть, но JIT мог бы что-то с этим сделать, нам нужно будет посмотреть, как выглядит время.

В любом случае было бы очень удивительно, если бы результаты объединения двух строк с оператором плюс и StringBuilder были существенно различны.

Я написал небольшой тест JMH, чтобы определить, как выполняются различные методы. Давайте сначала посмотрим на тест двух строк. Смотрите код ниже:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package org.sample;
 
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
 
import java.util.UUID;
import java.util.concurrent.TimeUnit;
 
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Thread)
public class LoopStringsBenchmark {
 
    private String[] strings;
 
    @Setup
    public void setupTest(){
        strings = new String[100];
        for(int i = 0; i<100; i++) {
            strings[i] = UUID.randomUUID().toString().substring(0, 10);
        }
    }
 
    @Benchmark
    public void testPlus(Blackhole bh) {
        String combined = "";
        for(String s : strings) {
            combined = combined + s;
        }
        bh.consume(combined);
    }
 
    @Benchmark
    public void testStringBuilder(Blackhole bh) {
        StringBuilder sb = new StringBuilder();
        for(String s : strings) {
            sb.append(s);
        }
        bh.consume(sb.toString());
    }
 
    @Benchmark
    public void testStringBuffer(Blackhole bh) {
        StringBuffer sb = new StringBuffer();
        for(String s : strings) {
            sb.append(s);
        }
        bh.consume(sb.toString());
    }
 
    @Benchmark
    public void testStringJoiner(Blackhole bh) {
        bh.consume(String.join("", strings));
    }
 
    @Benchmark
    public void testStringConcat(Blackhole bh) {
        String combined = "";
        for(String s : strings) {
            combined.concat(s);
        }
        bh.consume(combined);
    }
}

Результаты выглядят так:

Экран + выстрел + 2015-02-17 + на + 17.41.26

Явным победителем здесь является String.concat (). Не удивительно, так как не нужно платить за производительность создания StringBuilder / StringBuffer для каждого вызова. Тем не менее, он должен создавать новую строку каждый раз (что будет важно позже), но для очень простого случая объединения двух укусов это происходит быстрее.

Другой момент заключается в том, что, как мы и ожидали, plus и StringBuilder эквивалентны, несмотря на полученный дополнительный байтовый код. StringBuffer лишь незначительно медленнее, чем StringBuilder, который интересен и показывает, что JIT должен делать что-то магическое, чтобы оптимизировать синхронизацию.

Следующий тест создает массив из 100 строк по 10 символов в каждой. Тест сравнивает, сколько времени требуется различным методам для объединения 100 строк. Смотрите код ниже:

На этот раз результаты выглядят совершенно иначе:

Снимок экрана 2015-02-17 в 17.54.37

Здесь метод плюс действительно страдает. Затраты на создание StringBuilder каждый раз, когда вы идете по кругу, наносят урон. Вы можете увидеть это ясно в байт-коде:

Снимок экрана 2015-02-17 в 17.59.46

Вы можете видеть, что новый StringBuilder создается (строка 30) каждый раз, когда выполняется цикл. Можно утверждать, что JIT должен определить это и быть в состоянии оптимизировать, но это не так, и использование + становится очень медленным.

Снова StringBuilder и StringBuffer работают точно так же, но на этот раз они оба быстрее, чем String.concat (). Цена, которую String.concat () платит за создание новой строки на каждой итерации цикла, в конечном итоге повышается, и StringBuilder становится более эффективным.

String.join () работает довольно хорошо, учитывая все дополнительные функции, которые вы можете добавить к этому методу, но, как и ожидалось, для чистой конкатенации это не лучший вариант.

Резюме

Если вы объединяете строки в одну строку кода, я бы использовал оператор +, так как он наиболее читабелен, а производительность на самом деле не имеет большого значения для одного вызова. Также остерегайтесь String.concat (), так как вам почти наверняка потребуется выполнить проверку на ноль, которая не нужна для других методов.

Когда вы объединяете строки в цикле, вы должны использовать StringBuilder. Вы можете использовать StringBuffer, но я не обязательно буду доверять JIT при любых обстоятельствах, чтобы оптимизировать синхронизацию так же эффективно, как в тесте.

Все мои результаты были достигнуты с использованием JMH, и они приходят с обычным предупреждением о вреде для здоровья .

Ссылка: Оптимальный метод объединения строк в Java от нашего партнера по JCG Дэниела Шая из блога Rational Java .