БЛОГ AXIOM JDK
Загрузка...

Эффективный Java-код или конкатенация строк? Разбираем популярную задачу и современные оптимизации JVM

Узнайте, как конкатенация строк влияет на производительность JVM даже в простом коде.

10 мин чтения
Эффективный Java-код или конкатенация строк? Разбираем популярную задачу и современные оптимизации JVM

Практически каждый Java-разработчик хотя бы раз сталкивался с вопросом: как правильно объединять строки? Несмотря на кажущуюся простоту, вокруг этой темы до сих пор существует множество мифов. Самый распространённый из них — утверждение, что оператор + всегда менее эффективен, чем StringBuilder.

Это действительно было близко к истине во времена Java 8 и более ранних версий. Однако начиная с Java 9 механизм конкатенации строк был существенно переработан. Современные версии JDK используют новые возможности JVM, благодаря чему многие старые рекомендации больше нельзя считать универсальными.

В этой статье разберём:

  • как JVM обрабатывает объединение строк;
  • что изменилось после Java 9 благодаря JEP 280;
  • когда оператор + работает эффективно;
  • в каких случаях действительно нужен StringBuilder;
  • как современные оптимизации JIT-компилятора влияют на производительность;
  • какие рекомендации актуальны для Java 21, Java 25 LTS и Java 26;
  • как всё это работает в Axiom JDK.

Исходная задача

Рассмотрим небольшой пример.

Java
public class StringConcatExample {

    public static void main(String[] args) {

        String firstName = "John";
        String lastName = "Doe";

        String fullName = firstName + " " + lastName;

        System.out.println(fullName);
    }

}

Вопрос кажется простым.

Что произойдёт внутри JVM?

Многие разработчики отвечают:

Компилятор создаст объект StringBuilder, выполнит несколько вызовов append(), затем вызовет toString().

Раньше это действительно было так. Но сегодня ответ уже не совсем верный.


Почему вокруг конкатенации строк появилось столько мифов

Большинство статей по Java были написаны более десяти лет назад.

В них можно встретить рекомендации вроде:

  • никогда не используйте оператор +;
  • всегда применяйте StringBuilder;
  • компилятор всегда преобразует + в StringBuilder.

Проблема в том, что подобные советы относятся главным образом к Java 8 и более ранним версиям.

Современные JVM используют совершенно другой механизм оптимизации, поэтому переносить старые рекомендации на Java 21, Java 25 LTS или Java 26 уже нельзя.


Почему строки неизменяемы

Класс String является неизменяемым (immutable).

После создания объекта его содержимое уже нельзя изменить.

Например:

String text = "Java";

text += " 25";

На первый взгляд кажется, что строка изменилась.

На самом деле происходит следующее:

  1. создаётся новый объект;
  2. копируется содержимое первой строки;
  3. добавляется новое значение;
  4. переменная начинает ссылаться на новый объект.

Исходная строка остаётся неизменной.

Именно поэтому большое количество операций объединения строк потенциально приводит к дополнительным выделениям памяти и увеличению нагрузки на сборщик мусора.


Как это работало в Java 8

До Java 9 компилятор действительно преобразовывал оператор + примерно в следующий код:

Java
String fullName =
        new StringBuilder()
                .append(firstName)
                .append(" ")
                .append(lastName)
                .toString();

Поэтому многие книги советовали писать StringBuilder вручную.

На тот момент такая рекомендация была вполне оправданной.


Что изменилось после Java 9

Начиная с Java 9 был реализован JEP 280 — Indify String Concatenation.

Главная идея изменения заключалась в том, чтобы перестать жёстко генерировать цепочку вызовов StringBuilder.

Теперь компилятор использует инструкцию invokedynamic, а сама логика объединения строк передаётся фабрике StringConcatFactory.

Это позволяет JVM самостоятельно выбирать наиболее эффективный способ конкатенации для конкретной ситуации.

В результате современные версии Java получили более гибкий и производительный механизм работы со строками.


Что такое StringConcatFactory

StringConcatFactory — это специальный механизм внутри JDK, отвечающий за создание оптимальной реализации объединения строк во время выполнения программы.

Вместо заранее сгенерированного кода JVM может подобрать наиболее эффективный вариант с учётом:

  • количества объединяемых строк;
  • их типов;
  • использования констант;
  • особенностей текущей JVM;
  • результатов JIT-компиляции.

Именно поэтому сегодня нельзя однозначно утверждать, что оператор + всегда создаёт StringBuilder.

Во многих случаях этого вообще не происходит.


Роль invokedynamic

Инструкция invokedynamic появилась ещё в Java 7, однако именно после Java 9 она стала активно использоваться для конкатенации строк.

Её основная задача — перенести принятие решений с этапа компиляции на этап выполнения программы.

Это даёт JVM дополнительные возможности оптимизации.

Например:

  • использовать различные стратегии объединения строк;
  • уменьшать количество временных объектов;
  • быстрее адаптироваться к конкретной нагрузке;
  • учитывать результаты профилирования кода во время выполнения.

Благодаря этому современные JVM способны выполнять конкатенацию значительно эффективнее, чем это было возможно при использовании фиксированного шаблона с StringBuilder.


Почему оператор "+" больше не является "медленным"

Рассмотрим обычный пример.

String message = firstName + " " + lastName;

Во многих старых статьях такой код называют плохой практикой.

На самом деле для современных версий Java это абсолютно нормальный вариант.

Если объединяется небольшое количество строк, JVM обычно выполняет подобный код максимально эффективно.

Более того, ручное использование StringBuilder в подобных ситуациях нередко делает код менее читаемым, не обеспечивая заметного выигрыша в производительности.

Поэтому современные рекомендации Oracle и сообщества Java-разработчиков значительно отличаются от тех, что были актуальны десять лет назад.


Когда компилятор может оптимизировать ещё сильнее

Если объединяются строковые литералы, оптимизация выполняется ещё раньше — на этапе компиляции.

Например:

String version = "Java" + " " + "25";

Компилятор превратит этот код в:

String version = "Java 25";

Никакой конкатенации во время выполнения программы уже не произойдёт.

Подобная оптимизация называется constant folding и позволяет полностью исключить дополнительные вычисления.


Всегда ли оператор "+" является лучшим выбором?

Нет.

Важно понимать, что современные оптимизации не отменяют фундаментальных особенностей класса String.

Если объединение выполняется многократно — например, внутри цикла, — создание большого количества временных объектов может стать причиной лишних выделений памяти и увеличения нагрузки на сборщик мусора.

Именно в таких ситуациях StringBuilder остаётся предпочтительным решением.

Рассмотрим этот случай подробнее в следующей части статьи.


Что изменилось для разработчиков

Если раньше можно было встретить универсальное правило:

«Всегда используйте StringBuilder вместо оператора +»

то сегодня более корректной рекомендацией будет следующая.

  • Используйте оператор + для небольшого количества операций объединения строк.
  • Используйте StringBuilder, если количество операций заранее неизвестно или выполняется многократная конкатенация в цикле.
  • Не пытайтесь преждевременно оптимизировать код без измерений.
  • Для оценки производительности используйте специализированные инструменты, например JMH, а не System.nanoTime().

Именно такой подход соответствует современным версиям Java и позволяет одновременно сохранить читаемость кода и получить высокую производительность.

Когда действительно нужен StringBuilder

Несмотря на значительные улучшения механизма конкатенации строк, появившиеся после Java 9, класс StringBuilder по-прежнему остаётся лучшим выбором в определённых сценариях.

Главное правило достаточно простое:

Если количество операций объединения строк заранее неизвестно или они выполняются многократно, используйте StringBuilder.

Наиболее распространённый пример — конкатенация строк внутри цикла.

Java
String result = "";

for (String value : values) {
    result += value;
}

На первый взгляд код выглядит компактным и понятным.

Однако при каждой итерации происходит создание нового объекта String.

Фактически JVM приходится выполнять следующие действия:

  1. создать новую строку;
  2. скопировать содержимое предыдущей строки;
  3. добавить очередной элемент;
  4. удалить предыдущий объект после работы сборщика мусора.

Если цикл выполняется тысячи или десятки тысяч раз, подобный подход приводит к значительным накладным расходам.

В такой ситуации гораздо эффективнее использовать StringBuilder.

Java
StringBuilder builder = new StringBuilder();

for (String value : values) {
    builder.append(value);
}

String result = builder.toString();

Здесь память выделяется значительно реже, а данные последовательно записываются во внутренний буфер.


Почему StringBuilder быстрее в циклах

Внутри StringBuilder используется изменяемый массив символов.

При добавлении новых данных не создаётся новый объект String. Вместо этого изменяется существующий буфер.

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

Это существенно уменьшает:

  • количество выделений памяти;
  • объём копирования данных;
  • нагрузку на сборщик мусора;
  • общее время выполнения программы.

Поэтому для циклов рекомендации практически не изменились даже после появления StringConcatFactory.


Как работает внутренний буфер

Предположим, выполняется следующий код.

Java
StringBuilder builder = new StringBuilder();

builder.append("Java");
builder.append(" ");
builder.append("25");

Во время выполнения создаётся один объект StringBuilder, внутри которого размещается изменяемый массив символов.

Каждый вызов append() просто записывает новые символы в этот массив.

Лишь при вызове toString() создаётся итоговый объект String.

Это гораздо эффективнее последовательного создания множества промежуточных строк.


Стоит ли заранее задавать размер буфера

Если заранее известно приблизительное количество символов, имеет смысл указать начальную ёмкость.

Например:

StringBuilder builder = new StringBuilder(256);

Это уменьшает количество перераспределений памяти при увеличении внутреннего массива.

Особенно полезно это в случаях:

  • генерации JSON;
  • формирования XML;
  • построения SQL-запросов;
  • генерации HTML;
  • создания больших текстовых отчётов;
  • формирования логов.

Однако не стоит переоценивать этот приём.

Для большинства бизнес-приложений влияние будет незначительным, если только не выполняется обработка очень больших объёмов данных.


Когда использовать StringJoiner

Если необходимо объединить строки через разделитель, гораздо удобнее использовать StringJoiner.

Например:

Java
StringJoiner joiner = new StringJoiner(", ");

joiner.add("Java");
joiner.add("Kotlin");
joiner.add("Scala");

System.out.println(joiner);

Результат:

Java, Kotlin, Scala

Такой код читается значительно проще, чем ручное управление разделителями.


Когда использовать String.join()

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

Java
List<String> languages = List.of(
        "Java",
        "Kotlin",
        "Scala"
);

String result = String.join(", ", languages);

Получится тот же результат.

Подобный вариант более декларативен и лучше отражает намерения разработчика.


А что насчёт StringBuffer?

StringBuffer появился значительно раньше StringBuilder.

Главное отличие заключается в потокобезопасности.

Практически все методы StringBuffer синхронизированы.

Это позволяет нескольким потокам безопасно работать с одним экземпляром объекта.

Однако подобная безопасность достигается ценой дополнительной синхронизации.

Поэтому в большинстве современных приложений рекомендуется использовать:

  • StringBuilder — если объект используется одним потоком;
  • StringBuffer — только при реальной необходимости совместного доступа из нескольких потоков.

Сегодня случаи применения StringBuffer встречаются значительно реже, чем двадцать лет назад.


Когда подходит String.format()

Иногда читаемость важнее абсолютной производительности.

Например:

Java
String message = String.format(
        "Version: %s, Build: %d",
        version,
        buildNumber
);

Такой код легко читать и сопровождать.

Однако необходимо учитывать, что String.format() выполняет дополнительный разбор шаблона и обычно работает медленнее, чем оператор + или StringBuilder.

Поэтому использовать его внутри горячих участков кода не рекомендуется.


Сравнение основных способов объединения строк

СпособПроизводительностьЧитаемостьКогда использовать
+ВысокаяОтличнаяНесколько строк, обычный бизнес-код
StringBuilderОчень высокаяХорошаяЦиклы, генерация больших строк
StringJoinerВысокаяОтличнаяРабота с разделителями
String.join()ВысокаяОтличнаяМассивы и коллекции
String.format()Ниже среднейОтличнаяФорматирование сообщений
StringBufferНиже StringBuilderХорошаяМногопоточные сценарии

Как видно, универсального решения не существует. Выбор зависит от конкретной задачи.


Что генерирует современный компилятор

До Java 9 оператор + практически всегда преобразовывался в цепочку вызовов StringBuilder.

В современных версиях Java ситуация изменилась.

Если посмотреть байткод с помощью команды:

javap -c StringConcatExample

можно увидеть инструкцию invokedynamic.

Именно она связывает вызов с StringConcatFactory, которая во время выполнения выбирает наиболее подходящую стратегию объединения строк.

Такой подход позволяет JVM выполнять дополнительные оптимизации уже после запуска приложения, когда становятся известны реальные особенности нагрузки.


Как JIT-компилятор помогает ускорить конкатенацию

После запуска приложения код сначала интерпретируется.

Когда JVM обнаруживает часто выполняемые участки программы, они компилируются JIT-компилятором в машинный код.

При этом становятся доступны дополнительные оптимизации.

Например:

  • устранение лишних проверок;
  • встраивание методов (Inlining);
  • распространение констант;
  • оптимизация циклов;
  • удаление лишних объектов.

Поэтому производительность программы после прогрева JVM обычно заметно выше, чем сразу после запуска.

Именно по этой причине измерять скорость выполнения отдельных операций с помощью одного запуска программы некорректно.


Escape Analysis — оптимизация, о которой часто забывают

Одной из наиболее интересных возможностей современной JVM является Escape Analysis.

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

Если объект используется только локально, JVM может:

  • вообще не размещать его в куче;
  • выделить память прямо в стеке;
  • полностью убрать создание объекта благодаря скалярной замене (Scalar Replacement).

В некоторых случаях это означает, что даже временный StringBuilder может не попасть в кучу, если компилятор докажет, что он не используется за пределами метода.

Именно поэтому в современных версиях Java ручные микрооптимизации далеко не всегда дают ожидаемый эффект.


Почему нельзя использовать System.nanoTime()

Многие разработчики пытаются сравнить производительность так:

Java
long start = System.nanoTime();

// тестируемый код

long end = System.nanoTime();

Подобный подход редко даёт достоверные результаты.

На время выполнения влияют:

  • прогрев JIT-компилятора;
  • оптимизации JVM;
  • работа сборщика мусора;
  • состояние процессора;
  • фоновая активность операционной системы;
  • кэширование данных.

Для корректных измерений следует использовать JMH (Java Microbenchmark Harness) — официальный инструмент для создания микробенчмарков, разработанный авторами OpenJDK. Он автоматически учитывает прогрев JVM, многократные итерации, влияние оптимизаций и позволяет получать воспроизводимые результаты.

Измеряем производительность правильно: JMH вместо System.nanoTime()

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

Главная причина заключается в том, что современные JVM активно оптимизируют выполняемый код. Если измерять производительность «вручную» с помощью System.nanoTime(), результаты могут оказаться случайными или вовсе не отражать реальную скорость выполнения.

Для создания корректных микробенчмарков в экосистеме Java используется JMH (Java Microbenchmark Harness) — официальный инструмент OpenJDK, разработанный авторами JVM.

JMH автоматически учитывает:

  • прогрев JIT-компилятора;
  • многократные итерации измерений;
  • влияние оптимизаций JVM;
  • устранение «мёртвого» кода (Dead Code Elimination);
  • влияние инлайнинга методов;
  • статистическую обработку результатов.

Именно поэтому практически все современные исследования производительности Java используют JMH.


Типичные ошибки при сравнении производительности

Рассмотрим пример, который часто встречается в блогах.

Java
long start = System.nanoTime();

String result = firstName + " " + lastName;

long end = System.nanoTime();

Подобный тест выглядит убедительно, но имеет сразу несколько серьёзных недостатков.

Во-первых, JIT-компилятор ещё не успел оптимизировать код.

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

В-третьих, отдельные операции могут занимать настолько мало времени, что на результат сильнее влияет работа процессора, планировщика задач или фоновых процессов операционной системы.

Поэтому выводы, основанные на подобных измерениях, нельзя считать достоверными.


Что показывают современные бенчмарки

Результаты различных исследований немного отличаются в зависимости от версии JVM и характера нагрузки, однако общая картина остаётся неизменной.

Для небольшого количества строк оператор + показывает производительность, практически не отличающуюся от ручного использования StringBuilder.

При многократной конкатенации внутри циклов ситуация меняется.

Количество создаваемых временных объектов начинает быстро расти, увеличивается объём выделяемой памяти, возрастает нагрузка на сборщик мусора, и StringBuilder становится значительно эффективнее.

Именно поэтому современные рекомендации звучат намного точнее, чем раньше:

Используйте оператор + там, где он делает код проще и понятнее. Переходите на StringBuilder только тогда, когда этого действительно требует характер алгоритма.


Практические рекомендации

Ниже приведены рекомендации, которые актуальны для Java 21, Java 25 LTS и Java 26.

Используйте оператор "+" для обычного кода

String fullName = firstName + " " + lastName;

Это наиболее читаемый вариант.

Современный компилятор и JVM самостоятельно выполнят необходимые оптимизации.


Используйте StringBuilder в циклах

Java
StringBuilder builder = new StringBuilder();

for (Order order : orders) {
    builder.append(order.getId())
           .append('\n');
}

Этот вариант уменьшает количество создаваемых объектов и снижает нагрузку на сборщик мусора.


Если известен примерный размер строки — задайте начальную ёмкость

StringBuilder builder = new StringBuilder(4096);

Это позволяет избежать повторного расширения внутреннего массива.


Для коллекций используйте String.join()

String result = String.join(", ", values);

Такой код проще читать и легче сопровождать.


Не используйте StringBuffer без необходимости

Потокобезопасность требует дополнительных затрат на синхронизацию.

Если объект используется только одним потоком, предпочтительнее применять StringBuilder.


Не злоупотребляйте String.format()

Форматирование делает код выразительным, но требует дополнительных вычислений.

Используйте его тогда, когда читаемость важнее максимальной производительности.


Распространённые ошибки разработчиков

Даже опытные разработчики иногда используют устаревшие рекомендации.

Наиболее типичные ошибки выглядят следующим образом.

Ошибка №1. Использовать StringBuilder везде

Java
StringBuilder builder = new StringBuilder();

builder.append(firstName);
builder.append(" ");
builder.append(lastName);

String fullName = builder.toString();

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


Ошибка №2. Использовать оператор "+" внутри больших циклов

Java
String text = "";

for (Item item : items) {
    text += item.getName();
}

Такой код создаёт большое количество временных объектов String и может существенно ухудшить производительность.


Ошибка №3. Оптимизировать без измерений

Даже если кажется, что одно решение должно работать быстрее другого, это необходимо подтвердить бенчмарком.

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


Как всё это работает в Axiom JDK

Все описанные выше механизмы применимы не только к OpenJDK, но и к Axiom JDK.

Поскольку Axiom JDK основан на актуальной кодовой базе OpenJDK, разработчики получают доступ ко всем современным возможностям платформы, включая:

  • использование invokedynamic при конкатенации строк;
  • механизм StringConcatFactory;
  • оптимизации JIT-компилятора HotSpot;
  • Escape Analysis;
  • современные алгоритмы сборки мусора;
  • оптимизации байткода, характерные для последних LTS-релизов Java.

Это означает, что рекомендации по работе со строками остаются одинаковыми независимо от того, разрабатывается ли приложение для международной инфраструктуры или для корпоративных систем, работающих на российских программных платформах.

Для организаций, использующих Astra Linux, РЕД ОС, Альт Linux и другие отечественные операционные системы, применение Axiom JDK позволяет использовать современную Java-платформу без изменения привычных подходов к разработке и оптимизации приложений.


Ответ на исходную задачу

Вернёмся к примеру из начала статьи.

String fullName = firstName + " " + lastName;

Какой вариант правильный?

Ответ зависит от версии Java.

До Java 8 компилятор действительно преобразовывал подобный код в цепочку вызовов StringBuilder.

Начиная с Java 9 ситуация изменилась. Вместо жёсткой генерации StringBuilder используется механизм invokedynamic и StringConcatFactory, позволяющий JVM выбирать оптимальную стратегию объединения строк во время выполнения программы.

Именно поэтому сегодня утверждение «оператор + всегда превращается в StringBuilder» уже нельзя считать технически корректным.


Что в итоге?

Конкатенация строк — один из тех аспектов Java, где рекомендации существенно изменились за последние годы. Современные версии платформы используют механизмы, которых ещё не существовало во времена Java 8, поэтому многие советы из старых книг и статей уже не отражают реального поведения JVM.

Для большинства сценариев оператор + остаётся простым, читаемым и эффективным решением. При интенсивной конкатенации внутри циклов или генерации больших текстовых данных предпочтение по-прежнему следует отдавать StringBuilder. Если же требуется объединить элементы коллекции, удобнее использовать String.join() или StringJoiner.

Главный вывод прост: не оптимизируйте код на основе устаревших рекомендаций. Используйте возможности современных версий Java, проверяйте производительность с помощью JMH и доверяйте оптимизациям JVM там, где они действительно работают.

Эти рекомендации полностью актуальны для Java 21, Java 25 LTS, Java 26 и Axiom JDK, позволяя писать одновременно читаемый, переносимый и производительный код как для классических серверных приложений, так и для решений, развёртываемых в инфраструктуре на базе российских операционных систем.

Сергей Лунегов

Сергей Лунегов

Директор по продуктам Axiom JDK

Похожие статьи

Java-разработка

Ускоряем сборку Java-проектов: как мы сделали javac быстрее в Axiom JDK Express

Сергей Лунегов

Ускоряем сборку Java-проектов: как мы сделали javac быстрее в Axiom JDK Express

Каждый Java-разработчик знает, сколько времени и ресурсов уходит на сборку Java-проектов. При использовании стандартных инструментов в разных проектах от 35% до 50% времени сборки тратится только на компиляцию.

Будьте в курсе мира Java

Релизы, патчи безопасности и советы для разработчиков — без лишнего шума