🔤 Розділ 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(); згортається під час компіляції (виклики методів не є константними виразами часу компіляції).
  • ❌ Вважати, що + мутує початковий рядок (рядки абсолютно імутабельні).

Посилання на пов’язані теми