Питання 8 · Розділ 12

Як компілятор Java оптимізує конкатенацію рядків?

Компілятор застосовує дві оптимізації: constant folding (склеювання літералів на етапі компіляції) та генерацію ефективного байт-коду для змінних (StringBuilder у Java 8, invoke...

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Компілятор застосовує дві оптимізації: constant folding (склеювання літералів на етапі компіляції) та генерацію ефективного байт-коду для змінних (StringBuilder у Java 8, invokedynamic у Java 9+). Якщо він бачить, що рядки відомі заздалегідь, він склеює їх ще до запуску програми.

Приклад:

String msg = "Hello" + " " + "World";
// Компілятор перетворює це на:
String msg = "Hello World";
// Жодної роботи під час виконання — рядок вже готовий!

Якщо рядки — змінні, компілятор використовує StringBuilder (у старих версіях) або спеціальну фабрику StringConcatFactory (у нових), щоб зробити операцію ефективною.

Просте правило: Для 2-3 рядків в одному рядку коду використовуйте +. Для циклу — StringBuilder.


🟡 Middle Level

Compile-time: Constant Folding

Компілятор (javac) обчислює рядкові вирази на етапі збирання, якщо всі операнди — літерали або static final константи:

public static final String PREFIX = "LOG:";
String msg = PREFIX + " " + "Start";
// У байт-коді: String msg = "LOG: Start";

Але це НЕ працює зі звичайними змінними:

String prefix = "LOG:"; // НЕ final!
String msg = prefix + " Start";
// У байт-коді: runtime конкатенація

Java 5-8: StringBuilder

У цей період компілятор замінював кожен вираз a + b + c на:

new StringBuilder().append(a).append(b).append(c).toString()

Мінус: байт-код “жорстко зашитий”. Якщо з’явиться кращий спосіб — потрібна перекомпіляція.

Java 9+: invokedynamic (JEP 280)

Компілятор більше не генерує StringBuilder. Замість цього — invokedynamic:

invokedynamic makeConcatWithConstants:(...)String

При першому виконанні JVM викликає bootstrap-метод StringConcatFactory.makeConcatWithConstants(), який генерує оптимальний код для даної конкретної конкатенації.

Типові помилки

  1. Помилка: Думати, що компілятор оптимізує цикли Рішення: Оптимізація працює лише в межах одного виразу

  2. Помилка: null + рядок без перевірки Рішення: Результат — "null...". Використовуйте тернарний оператор або Objects.toString()


🔴 Senior Level

Internal Implementation — StringConcatFactory

StringConcatFactory (java.lang.invoke) — серце конкатенації у Java 9+. Bootstrap-метод:

public static CallSite makeConcatWithConstants(MethodHandles.Lookup lookup,
                                                String name,
                                                MethodType concatType,
                                                MethodRecipe recipe,
                                                byte[] constants) {
    // recipe — "рецепт" конкатенації: які аргументи і в якому порядку з'єднувати.
    // constants — константи, що вбудовуються між аргументами.
    // Обирає стратегію:
    // 1. MH_LF — MethodHandle-based
    // 2. BC_SB — Bytecode StringBuilder
    // 3. BH_SB — Bytecode StringBuilder compact
    // Генерує оптимальний код для даної сигнатури
}

Стратегії: | Стратегія | Механізм | Коли обирається | | ———– | ——————————— | ————————— | | MH_LF | MethodHandle з inline lambda | Few arguments, simple types | | BC_SB | Генерація байт-коду StringBuilder | Many arguments, mixed types | | BH_SB | Compact bytecode | Default fallback |

Переваги invokedynamic перед StringBuilder codegen

  1. Adaptivity: Різні версії JVM можуть використовувати різні стратегії без перекомпіляції .class

  2. Zero intermediate allocations: JVM знає всі аргументи заздалегідь і може виділити byte[] потрібного розміру одразу:
    // Замість: new StringBuilder(16).append(a).append(b).append(c).toString()
    // Генерується: прямий розрахунок розміру → byte[resultSize] → fill → new String
    
  3. Compactness: Байт-код класів став меншим (немає роздутого StringBuilder-коду)

  4. Type-aware конвертація: Для примітивів (int, long) генерується пряма конвертація без проміжного об’єкта String.valueOf().

Архітектурні Trade-offs

Плюси:

  • Future-proof: нові стратегії JVM без перекомпіляції
  • Менше алокацій → менше GC pressure
  • Компактніший байт-код

Мінуси:

  • Bootstrap overhead: перший виклик ~1-5μs (JIT компіляція згенерованого коду)
  • Складніше профілювати: динамічно згенерований код не видно у декомпіляторі

Edge Cases

  1. null handling: StringConcatFactory вбудовує перевірку на null. Результат: "text: null" замість NPE.

  2. Constant folding + invokedynamic: Якщо частина аргументів — константи, StringConcatFactory отримує їх як baked-in constants у recipe, уникаючи передачі як аргументів.

  3. Inlining: Після bootstrap JIT може inline-ити згенерований код, прибираючи віртуальний виклик.

Продуктивність

| Сценарій | Java 8 (StringBuilder) | Java 9+ (invokedynamic) | Improvement | | ———————- | ———————- | ———————— | ————- | | 2 String args | ~25ns | ~10ns | 2.5x | | 5 mixed args | ~60ns | ~25ns | 2.4x | | 10 String args | ~120ns | ~45ns | 2.7x | | First call (bootstrap) | N/A | +3-5μs | One-time cost |

Production Experience

Сценарій: REST API серіалізація (JSON ключі + значення) — 200K викликів/сек:

  • Java 8: 200K new StringBuilder() → ~40ms CPU overhead
  • Java 9+: invokedynamic з прямою алокацією → ~15ms
  • З -XX:+UseStringDeduplication: додатково -10% heap від рядків, що дублюються

Monitoring

# JIT-компіляція StringConcatFactory
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+LogCompilation ...

# JOL для аналізу алокацій
GraphLayout.parseInstance(result).toFootprint()

Best Practices для Highload

  • Для простих конкатенацій: + — JVM оптимізує краще ручного StringBuilder
  • У циклах: лише StringBuilder (або StringBuilder поза циклом)
  • Для форматування: String.format() повільніший (парсить шаблон). Альтернативи: StringBuilder, MessageFormat, або StringTemplate (Java 21+, preview)
  • Якщо конкатенація може не знадобитися (lazy logging): оберніть в if (log.isDebugEnabled())

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • Constant folding: "A" + "B" і static final константи склеюються на етапі компіляції
  • Java 5-8: a + b + cnew StringBuilder().append(a).append(b).append(c).toString()
  • Java 9+: invokedynamic + StringConcatFactory — JVM обирає стратегію у рантаймі без перекомпіляції
  • Стратегії: MH_LF (MethodHandle), BC_SB (Bytecode StringBuilder), BH_SB (Compact)
  • invokedynamic дає zero intermediate allocations — JVM знає розмір і алокує byte[] одразу
  • Bootstrap overhead першого виклику: ~1-5μs, потім JIT інлайнить

Часті уточнюючі запитання:

  • Чому invokedynamic кращий за StringBuilder codegen? — (1) Adaptivity — JVM може міняти стратегію без перекомпіляції, (2) Менше алокацій — прямий розрахунок розміру, (3) Компактніший байт-код, (4) Type-aware для примітивів.
  • Чи працює constant folding для звичайних змінних? — Ні, лише для літералів і static final констант. String x = "A"; x + "B" — не constant folding.
  • Який overhead у bootstrap invokedynamic? — ~1-5μs при першому виклику. JIT потім інлайнить згенерований код.
  • Чи оптимізує компілятор null + "text"? — Так, результат "nulltext". StringConcatFactory вбудовує null check.

Червоні прапорці (НЕ говорити):

  • ❌ “Компілятор оптимізує + у циклі” — ні, лише один вираз
  • ❌ “invokedynamic — це те саме що StringBuilder” — адаптивний, менше алокацій, future-proof
  • ❌ “Constant folding працює для будь-яких змінних” — лише для static final
  • ❌ “Bootstrap overhead відбувається щоразу” — лише при першому виклику, потім JIT інлайнить

Пов’язані теми:

  • [[7. Що відбувається при конкатенації рядків через оператор +]]
  • [[5. Коли використовувати StringBuilder vs StringBuffer]]
  • [[19. Що таке compact strings в Java 9+]]