Що відбувається при конкатенації рядків через оператор +
У Java оператор + є єдиним перевантаженим оператором для посилальних типів. Коли хоча б один з операндів оператора + має тип String, Java виконує операцію конкатенації (склеюван...
🟢 Junior Level
У Java оператор + є єдиним перевантаженим оператором для посилальних типів. Коли хоча б один з операндів оператора + має тип String, Java виконує операцію конкатенації (склеювання) рядків.
Оскільки клас String є незмінним (immutable), оператор + ніколи не змінює наявні рядки, а завжди конструює та повертає новий об’єкт String у пам’яті купи (Heap).
Суть у 30 секундах
Поведінка оператора + кардинально відрізняється залежно від того, коли відомі значення операндів:
- Під час компіляції (константи): Якщо складаються рядкові літерали або
final-константи (наприклад,"A" + "B"), компілятор склеює їх заздалегідь на етапі компіляції (Constant Folding). У байткоді зберігається вже готова рядок"AB", і в рантаймі процесор не витрачає час на склеювання. - Під час виконання (динамічні змінні): Якщо хоча б один операнд — змінна (
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):
- Якщо хоча б один з операндів бінарного оператора
+має типString, другий операнд автоматично перетворюється на рядок. - Примітивні типи конвертуються у їхнє рядкове представлення (наприклад,
42$\to$"42"). - Для посилальних об’єктів викликається метод
String.valueOf(obj). Якщо посилання дорівнюєnull, підставляється літерал"null". - Оператор
+є лівоасоціативним (обчислюється зліва направо) з однаковим пріоритетом додавання чисел та конкатенації рядків.
// Пастка пріоритету:
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 не застосовується.
Типові помилки при конкатенації
- Конкатенація рядків у циклах (
+=): Кожна ітерація циклу генерує нову алокацію рядка та копіювання всіх раніше накопичених символів. Складність алгоритму за часом та пам’яттю зростає до $O(N^2)$. - Випадкове перетворення
nullна рядок:"User ID: " + user.getId()приuser.getId() == nullповерне"User ID: null". Це маскує помилку відсутності даних і може потрапити до бази даних або зовнішнього API. - Конкатенація в логуванні без перевірки рівня:
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:
- HotSpot викликає Bootstrap-метод
StringConcatFactory. - Фабрика аналізує типи аргументів (
String,intтощо) і генерує оптимальнийMethodHandle(через внутрішній генератор байткодуInnerClassLambdaMetafactoryабо прямий доступ доbyte[] value). - Точка виклику зв’язується (CallSite Linkage) із цим методом. При всіх наступних викликах машинний код виконується напряму без накладних витрат рефлексії чи BSM.
Стратегії конкатенації HotSpot
JVM підтримує кілька стратегій генерації коду (керуються прапорцем -Djava.lang.invoke.stringConcat=<strategy>):
INLINE_SIZED_EXACT(стратегія за замовчуванням):- Обчислює точну довжину підсумкового масиву байтів на основі довжин усіх аргументів до виконання алокації.
- Виділяє рівно один масив
byte[]потрібного розміру (з урахуванням кодування Latin-1/UTF-16 завдяки Compact Strings). - Копіює дані аргументів безпосередньо в цей масив без проміжних буферів.
- Створює об’єкт
String, упаковуючи підготовлений масив без повторного копіювання (через package-private конструкторString(byte[] value, byte coder)).
METHOD_HANDLE_INLINE_SIZED_EXACT: Побудова ланцюжка через комбінаториMethodHandle.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 відпрацьовує заново:
- Обчислює розмір двох поточних операндів.
- Виділяє новий масив байтів.
- Створює новий об’єкт
String. Таким чином,invokedynamicоптимізує лише атомарні вирази склеювання кількох змінних за один крок, але безсилий проти накопичення рядків у циклі.
4 Tricky Questions
1. Що виведе код System.out.println("Result: " + 1 + 2 * 3) і як компілятор розбирає пріоритет?
Відповідь:
Виведе: "Result: 16".
Оператор множення * має вищий пріоритет, ніж додавання/конкатенація +. Тому спочатку обчислюється вираз 2 * 3 = 6. Далі залишаються оператори додавання з однаковим пріоритетом, які виконуються суворо зліва направо:
"Result: " + 1перетворюється на рядковий вираз"Result: 1"."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):
- Точний розрахунок розміру пам’яті: Фабрика на льоту підсумовує точні довжини всіх 5 змінних і визначає підсумковий
coder(Latin-1 або UTF-16). - Нульовий оверхед на переалокацію: Виділяється рівно один масив
byte[]необхідного розміру. РучнийStringBuilderз дефолтним конструктором (16 символів) гарантовано потребував би розширення та копіювання масиву при довжині рядка понад 16 символів. - Відсутність проміжного об’єкта: Не алокується сам інстанс
StringBuilderу купі (економія 24 байтів заголовка та полів об’єкта). - Відсутність подвійного копіювання: Захищений конструктор пакета
java.langдозволяє створитиStringбезпосередньо із заготовленого масиву байтів, тоді якStringBuilder.toString()зобов’язаний робити захисну копію масиву черезArrays.copyOfRange().
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«Оператор
+для рядків у Java конструює новий об’єктString. Для рядкових літералів та compile-time констант компілятор виконує злиття на етапі збірки (Constant Folding), записуючи готовий результат у пул констант. Для змінних у Java 9+ генерується інструкціяinvokedynamic(StringConcatFactory), яка обчислює точну довжину, виділяє один масив байтів і створює рядок швидше за ручнийStringBuilder. Проте в циклах оператор+категорично заборонений, оскільки на кожній ітерації виділяється новий об’єкт, призводячи до квадратичної складності $O(N^2)$».
Ключові тези для Senior-інтерв’ю
- JLS §15.28 Constant Folding: Згортання констант відбувається лише для літералів та
finalзмінних, ініціалізованих константами. - JEP 280 (Java 9+): Заміна
StringBuilderнаinvokedynamic(makeConcatWithConstants). - Стратегія
INLINE_SIZED_EXACT: Попередній розрахунок довжини $\to$ алокація масивуbyte[]точного розміру $\to$ прямий конструкторStringбез захисного копіювання. - Null-safety оператора
+: Не кидає NPE, перетворюючиnullна рядок"null"черезString.valueOf(). - Обмеження
invokedynamic: Не оптимізує цикли. Для циклів завжди використовуєтьсяStringBuilderіз явним зазначеннямcapacity.
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ Стверджувати, що «компілятор у Java 17 замінює
+наStringBuilder» (у Java 9+ використовуєтьсяinvokedynamic). - ❌ Вважати, що компілятор оптимізує
+=всередині циклів (цикли не оптимізуються, складність залишається $O(N^2)$). - ❌ Думати, що
final String s = getVal();згортається під час компіляції (виклики методів не є константними виразами часу компіляції). - ❌ Вважати, що
+мутує початковий рядок (рядки абсолютно імутабельні).