🔤 Раздел 12 · Вопрос #7

Что происходит при конкатенации строк через оператор +

В Java оператор + является единственным перегруженным оператором для ссылочных типов. Когда хотя бы один из операндов оператора + является объектом String, Java выполняет операц...


🟢 Junior Level

В Java оператор + является единственным перегруженным оператором для ссылочных типов. Когда хотя бы один из операндов оператора + является объектом String, Java выполняет операцию конкатенации (склеивания) строк.

Поскольку класс String является неизменяемым (immutable), оператор + никогда не изменяет существующие строки, а всегда конструирует и возвращает новый объект String в памяти кучи (Heap).

Суть в 30 секундах

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

  1. Во время компиляции (константы): Если складываются строковые литералы или final-константы (например, "A" + "B"), компилятор склеивает их заранее на этапе компиляции (Constant Folding). В байткоде оказывается готовая строка "AB", и в рантайме процессор не тратит время на склеивание.
  2. Во время выполнения (динамические переменные): Если хотя бы один операнд — переменная (str1 + str2), склеивание происходит в рантайме. Начиная с Java 9, компилятор транслирует такое выражение в инструкцию invokedynamic, вызывающую высокооптимизированную фабрику строк StringConcatFactory (до Java 9 компилятор создавал new StringBuilder().append(...)).
// 1. Константная конкатенация (Compile-Time)
String s1 = "Hello " + "World"; // В .class запишется сразу "Hello World"

// 2. Динамическая конкатенация (Runtime)
String name = "Alice";
String greeting = "Hello, " + name + "!"; // Вычисляется в рантайме через invokedynamic

// 3. Катастрофическая ошибка: использование + в цикле
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i; // АНТИПАТТЕРН: 1000 аллокаций новых объектов String в памяти!
}

// 4. Правильное решение для циклов:
StringBuilder sb = new StringBuilder(1000 * 4);
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String fastResult = sb.toString();

🟡 Middle Level

Правила приведения типов и приоритет операций

Согласно спецификации языка Java (JLS §15.18.1):

  1. Если хотя бы один из операндов бинарного оператора + имеет тип String, второй операнд автоматически преобразуется в строку.
  2. Примитивные типы конвертируются в их строковое представление (например, 42 $\to$ "42").
  3. Для ссылочных объектов вызывается метод String.valueOf(obj). Если ссылка равна null, подставляется литерал "null".
  4. Оператор + левоассоциативен (выполняется слева направо) с одинаковым приоритетом сложения чисел и конкатенации строк.
// Ловушка приоритета:
System.out.println("Sum: " + 10 + 20);   // "Sum: 1020" (сначала "Sum: 10", затем "Sum: 10" + 20)
System.out.println("Sum: " + (10 + 20)); // "Sum: 30" (скобки вычисляются первыми)
System.out.println(10 + 20 + " is sum"); // "30 is sum" (10 + 20 = 30, затем 30 + " is sum")

Свертка констант (Compile-time Constant Folding — JLS §15.28)

Если операнды являются константными выражениями времени компиляции (Compile-Time Constants), вычисление происходит целиком внутри javac:

final String prefix = "user_";
final int id = 42;
String key = prefix + id; // Превращается в LDC "user_42" в пуле констант!

Важное условие: Переменная должна быть помечена final и инициализирована константным выражением. Если final переменная инициализируется результатом вызова метода (final String a = getPrefix();), оптимизация Constant Folding не применяется.

Типичные ошибки при конкатенации

  1. Конкатенация строк в циклах (+=): Каждая итерация цикла генерирует новую аллокацию строки и копирование всех ранее накопленных символов. Сложность алгоритма по времени и памяти возрастает до $O(N^2)$.
  2. Случайное преобразование null в строку: "User ID: " + user.getId() при user.getId() == null вернет "User ID: null". Это маскирует ошибку отсутствия данных и может попасть в базу данных или внешний API.
  3. Конкатенация в логировании без проверки уровня: logger.debug("Processing order: " + order.getDetails()); Выражение конкатенации вычислится всегда, даже если уровень DEBUG в приложении отключен! Правильно использовать параметризованные шаблоны SLF4J: logger.debug("Processing order: {}", order.getDetails());.

🔴 Senior Level

Эволюция байткода: от StringBuilder к invokedynamic (JEP 280)

До Java 9 компилятор жестко преобразовывал каждую строчку конкатенации в байткод создания экземпляра StringBuilder:

// Исходный Java-код:
String msg = "User: " + name + " age: " + age;

// Байткод в Java 5–8:
new StringBuilder()
    .append("User: ")
    .append(name)
    .append(" age: ")
    .append(age)
    .toString();

Недостатки подхода Java 8:

  • Лишние аллокации: Конструктор по умолчанию new StringBuilder() выделяет емкость в 16 символов. При превышении происходят 1–2 переаллокации массива и копирование данных.
  • Жесткая фиксация в байткоде: Стратегия конкатенации зашита в скомпилированный .class. Невозможно оптимизировать рантайм без перекомпиляции всех библиотек мира.

Начиная с Java 9 (JEP 280: Indify String Concatenation), компилятор генерирует инструкцию invokedynamic:

0: aload_1                         // Загрузка переменной name
1: iload_2                         // Загрузка переменной age
2: invokedynamic #7, 0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;

В таблице атрибутов класса создается Bootstrap Method (BSM), указывающий на java.lang.invoke.StringConcatFactory.makeConcatWithConstants.

Архитектура StringConcatFactory и рецепты конкатенации

Bootstrap-метод принимает статический «рецепт» конкатенации (Recipe String):

  • Символ \1 обозначает динамический аргумент, передаваемый в рантайме.
  • Символ \2 обозначает константу из пула констант.

Для выражения "User: " + name + " age: " + age рецепт выглядит как: \(\text{Recipe} = \text{"User: \textbackslash 1 age: \textbackslash 1"}\)

При первом выполнении инструкции invokedynamic:

  1. HotSpot вызывает Bootstrap-метод StringConcatFactory.
  2. Фабрика анализирует типы аргументов (String, int и т.д.) и генерирует оптимальный MethodHandle (через внутренний генератор байткода InnerClassLambdaMetafactory или прямой доступ к byte[] value).
  3. Точка вызова связывается (CallSite Linkage) с этим методом. При всех последующих вызовах машинный код исполняется напрямую без накладных расходов рефлексии или BSM.

Стратегии конкатенации HotSpot

JVM поддерживает несколько стратегий генерации кода (управляются флагом -Djava.lang.invoke.stringConcat=<strategy>):

  1. INLINE_SIZED_EXACT (стратегия по умолчанию):
    • Вычисляет точную длину итогового массива байтов на основе длин всех аргументов до выполнения аллокации.
    • Выделяет ровно один массив byte[] нужного размера (с учетом кодировки Latin-1/UTF-16 благодаря Compact Strings).
    • Копирует данные аргументов непосредственно в этот массив без промежуточных буферов.
    • Создает объект String, упаковывая подготовленный массив без повторного копирования (через package-private конструктор String(byte[] value, byte coder)).
  2. METHOD_HANDLE_INLINE_SIZED_EXACT: Построение цепочки через комбинаторы MethodHandle.
  3. BC_SB (Bytecode StringBuilder): Генерация байткода со StringBuilder (fallback-режим).
[invokedynamic call]
        │
        ▼
[StringConcatFactory] ──► Вычисляет точную длину: len = "User: ".length() + name.length() + intLen(age)
        │
        ▼
[Прямая аллокация byte[len]] ──► Без resize, без промежуточного StringBuilder!
        │
        ▼
[new String(bytes, coder)] ──► Защищенный конструктор без повторного копирования

Почему invokedynamic не оптимизирует циклы?

Конструкция for (...) { s += x; } компилируется в invokedynamic внутри тела цикла. На каждой итерации точка вызова invokedynamic отрабатывает заново:

  1. Вычисляет размер двух текущих операндов.
  2. Выделяет новый массив байтов.
  3. Создает новый объект String. Таким образом, invokedynamic оптимизирует только атомарные выражения склеивания нескольких переменных за один шаг, но бессилен против накопления строк в цикле.

4 Tricky Questions

1. Что выведет код System.out.println("Result: " + 1 + 2 * 3) и как компилятор разбирает приоритет?

Ответ: Выведет: "Result: 16". Оператор умножения * имеет более высокий приоритет, чем сложение/конкатенация +. Поэтому сначала вычисляется выражение 2 * 3 = 6. Далее остаются операторы сложения с одинаковым приоритетом, которые выполняются строго слева направо:

  1. "Result: " + 1 превращается в строковое выражение "Result: 1".
  2. "Result: 1" + 6 превращается в итоговую строку "Result: 16".

2. Оптимизируется ли конкатенация final переменных через Constant Folding, если значение одной из них вычисляется методом?

public class Test {
    public static final String A = "Hello";
    public static final String B = getSuffix();
    public static final String C = A + B;
    
    private static String getSuffix() { return " World"; }
}

Ответ: Для переменной C оптимизация Constant Folding не применится. Согласно JLS §15.28, константным выражением времени компиляции признается только то выражение, которое состоит исключительно из литералов, примитивных операторов и других константных переменных. Вызов метода getSuffix() не является константным выражением времени компиляции (даже если метод возвращает литерал). Следовательно, значение B не известно компилятору javac. Конкатенация A + B будет скомпилирована в инструкцию invokedynamic и выполнена в рантайме при инициализации класса <clinit>.

3. Почему ((String) null) + "abc" работает корректно, а ((String) null).concat("abc") выбрасывает NullPointerException?

Ответ:

  • Оператор + транслируется в рантайме через спецификацию JLS, где к ссылочным аргументам применяется метод String.valueOf(obj). Внутри String.valueOf(Object obj) содержится проверка: return (obj == null) ? "null" : obj.toString(). Поэтому ((String) null) + "abc" преобразуется в "null" + "abc" $\to$ "nullabc".
  • Метод .concat(String str) является обычным методом экземпляра. Вызов любого метода на null-ссылке (null.method()) приводит к разыменованию нулевого указателя в байткоде (invokevirtual), что вызывает немедленный выброс NullPointerException. Кроме того, сам метод concat проверяет аргумент: если передать str.concat(null), будет выброшен NPE.

4. Почему конкатенация пяти переменных через + в Java 17 работает быстрее и расходует меньше памяти, чем ручное создание new StringBuilder().append(...)?

Ответ: Потому что Java 17 использует стратегию StringConcatFactory (JEP 280):

  1. Точный расчет размера памяти: Фабрика на лету суммирует точные длины всех 5 переменных и определяет итоговый coder (Latin-1 или UTF-16).
  2. Нулевой оверхед на переаллокацию: Выделяется ровно один массив byte[] необходимого размера. Ручной StringBuilder с дефолтным конструктором (16 символов) гарантированно потребовал бы расширения и копирования массива при длине строки более 16 символов.
  3. Отсутствие промежуточного объекта: Не аллоцируется сам инстанс StringBuilder в куче (экономия 24 байт заголовка и полей объекта).
  4. Отсутствие двойного копирования: Защищенный конструктор пакета java.lang позволяет создать String напрямую из заготовленного массива байт, тогда как StringBuilder.toString() обязан делать защитную копию массива через Arrays.copyOfRange().

🎯 Шпаргалка для интервью

30-секундный ответ

«Оператор + для строк в Java конструирует новый объект String. Для строковых литералов и compile-time констант компилятор выполняет слияние на этапе сборки (Constant Folding), записывая готовый результат в пул констант. Для переменных в Java 9+ генерируется инструкция invokedynamic (StringConcatFactory), которая вычисляет точную длину, выделяет один массив байт и создает строку быстрее, чем ручной StringBuilder. Однако в циклах оператор + категорически запрещен, так как на каждой итерации выделяется новый объект, приводя к квадратичной сложности $O(N^2)$».

Ключевые тезисы для Senior-интервью

  1. JLS §15.28 Constant Folding: Свертка констант происходит только для литералов и final переменных, инициализированных константами.
  2. JEP 280 (Java 9+): Замена StringBuilder на invokedynamic (makeConcatWithConstants).
  3. Стратегия INLINE_SIZED_EXACT: Предварительный расчет длины $\to$ аллокация массива byte[] точного размера $\to$ прямой конструктор String без защитного копирования.
  4. Null-safety оператора +: Не бросает NPE, преобразуя null в строку "null" через String.valueOf().
  5. Ограничение invokedynamic: Не оптимизирует циклы. Для циклов всегда используется StringBuilder с явным указанием capacity.

Красные флаги (Чего говорить категорически нельзя)

  • ❌ Утверждать, что «компилятор в Java 17 заменяет + на StringBuilder» (в Java 9+ используется invokedynamic).
  • ❌ Считать, что компилятор оптимизирует += внутри циклов (циклы не оптимизируются, сложность остается $O(N^2)$).
  • ❌ Думать, что final String s = getVal(); сворачивается во время компиляции (вызовы методов не являются константными выражениями времени компиляции).
  • ❌ Полагать, что + мутирует исходную строку (строки абсолютно иммутабельны).

Связанные вопросы