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

Чому String є незмінним (незмінюваним)?

Кожна модифікація (concat, replace, substring) створює новий об'єкт — це генерує GC pressure. У low-latency системах для гарячих шляхів використовують StringBuilder або byte[] н...

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

🟢 Junior Level

Незмінність означає, що після створення об’єкта String його вміст не можна змінити. Жоден метод не може поміняти символи всередині рядка — замість цього створюється новий рядок.

Чому це важливо:

  1. Безпека — паролі та шляхи до файлів не можна підмінити
  2. Потокобезпека — рядки можна передавати між потоками без синхронізації
  3. String Pool — можливий лише тому, що рядки не можна змінювати

Приклад:

String s = "Hello";
s.toUpperCase();       // Створюється НОВИЙ рядок "HELLO"
System.out.println(s); // Виведе "Hello" — оригінал не змінився!

Проста аналогія: Рядок — як надрукована книга. Ви не можете змінити текст у вже надрукованій книзі — замість цього потрібно надрукувати нову.


🟡 Middle Level

Ціна незмінності

Кожна модифікація (concat, replace, substring) створює новий об’єкт — це генерує GC pressure. У low-latency системах для гарячих шляхів використовують StringBuilder або byte[] напряму.

Як це реалізовано в JDK

  1. final клас: public final class String — не можна успадкувати і перевизначити методи
  2. final поле: private final byte[] value (Java 9+) — посилання на масив не може бути змінене
  3. Немає мутаторів: Жодного методу типу setCharAt(), що змінює вміст
  4. Захисне копіювання: Методи substring(), replace(), concat() повертають нові об’єкти

Де використовується на практиці

  • Ключі HashMap/HashSet: hashCode() кешується при першому виклику і ніколи не змінюється
  • Параметри безпеки: шляхи до файлів, URL, credentials — не можна змінити після перевірки
  • Багатопотоковість: рядки передаються між потоками без будь-яких блокувань

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

  1. Помилка: Очікування, що replace() змінить рядок на місці Рішення: Запам’ятовуйте результат: s = s.replace("a", "b");

  2. Помилка: Конкатенація в циклі через + Рішення: Використовуйте StringBuilder — кожна конкатенація створює новий об’єкт

Порівняння з альтернативами

| Тип | Незмінний | Потокобезпечний | Для модифікацій | | ————- | ———— | —————– | ——————– | | String | Так | Так | Ні | | StringBuilder | Ні | Ні | Так (один потік) | | StringBuffer | Ні | Так (synchronized)| Так (багато потоків) |


🔴 Senior Level

Internal Implementation

// OpenJDK — структура String (Java 9+)
public final class String implements Serializable, Comparable<String>, CharSequence {
    @Stable
    private final byte[] value;  // COMPACT STRINGS: byte[] замість char[]
    private final byte coder;    // LATIN1=0 або UTF16=1
    private int hash;            // Кеш hashCode, 0 за замовчуванням
    // ...
}

Анотація @Stable (JVM intrinsic) повідомляє JIT-компілятору, що поле value не буде змінене після конструктора. Це дозволяє агресивні оптимізації: constant folding, escape analysis.

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

Чому не mutable:

  1. Security: Рядки використовуються в ClassLoader, SecurityManager, мережевих підключеннях, SQL-запитах. Якби рядки були мутабельними:
    // Зловмисник міг би:
    checkAccess("/admin/config");  // Перевірка пройшла
    // ... змінити рядок у пам'яті ...
    readFile("/admin/config");     // Читання іншого файлу!
    
  2. String Pool: Якби рядки були мутабельними, зміна "Hello" в одному місці змінила б усі посилання на цей літерал у всьому додатку.

Якби рядки були мутабельними, то зміна s1 змінила б і s2, тому що вони посилаються на один об’єкт у пулі. Незмінність гарантує, що один рядок у пулі безпечний для всіх, хто на нього посилається.

String s1 = "Hello";
String s2 = "Hello";
// s1 і s2 — один об'єкт. Якби s1 можна було змінити, s2 теж змінився б.
  1. HashMap stability: Мутабельний ключ у HashMap — втрата даних при зміні hashCode.

  2. Thread safety: Незмінні об’єкти thread-safe by design. Жодних race conditions, жодних volatile, жодних блокувань.

Edge Cases

  1. Рефлексія: До Java 9 можна було змінити value через Field.setAccessible(true). У Java 9 з’явився модульний доступ (JEP 261), а з Java 16 (JEP 396) strong encapsulation увімкнено за замовчуванням — --add-opens потрібен для доступу до внутрішніх полів.

  2. StringBuilder під капотом: String.format(), String.join() internally використовують mutable буфери, але результат — завжди незмінний String.

  3. String concat через invokedynamic (Java 9+): StringConcatFactory генерує ефективний код, але результат все одно незмінний.

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

  • Алокація: Новий String (Java 9+, Latin1, 10 символів) ≈ 48 байт (об’єкт + byte[])
  • GC: Short-lived об’єкти рядків ефективно обробляються Young Gen (Eden → Survivor → смерть)
  • Кеш hashCode: Обчислюється ліниво один раз, потім O(1) доступ

Production Experience

Сценарій: Логування у високонавантаженому сервісі (100K req/sec):

  • Кожен рядок логу створює 3-5 тимчасових String об’єктів
  • Без Compact Strings: 500MB/sec алокацій → Minor GC кожні 200ms
  • З Compact Strings (Java 9+): 250MB/sec → Minor GC кожні 400ms
  • Рішення: структуроване логування (byte[] напряму у logging framework)

Monitoring

// JOL — реальний розмір String
System.out.println(GraphLayout.parseInstance("Hello").toPrintable());
// java.lang.String object internals:
// OFFSET  SIZE  TYPE     DESCRIPTION
// 0       12             (object header)
// 12      4     byte[]   String.value
// 16      1     byte     String.coder
// ...
// Total: 24 bytes + array size

Best Practices для Highload

  • Для частих модифікацій: StringBuilder з попередньо вказаним capacity
  • Для міжпотокового обміну: String (thread-safe без блокувань)
  • Для парсингу великих текстів: працюйте з byte[]/CharBuffer напряму, конвертуйте в String лише коли потрібно
  • Розгляньте byte[] + coder патерн для ultra-low-latency систем

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

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

  • Незмінність = після створення вміст не можна змінити, усі модифікації створюють новий об’єкт
  • Реалізація: final клас, final byte[] value, @Stable анотація, немає мутаторів
  • String Pool можливий лише завдяки незмінності — інакше зміна одного рядка торкнулася б усіх посилань
  • Thread-safe by design — жодних блокувань, race conditions неможливі
  • Безпека: рядки використовуються в SecurityManager, ClassLoader, SQL-запитах — mutable рядок = уразливість
  • Ключі HashMap: hashCode() кешується і ніколи не змінюється

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

  • Чому String immutable? — 4 причини: (1) String Pool — неможливий для mutable рядків, (2) Thread safety — без блокувань, (3) Security — шляхи, URL, credentials не можна підмінити, (4) HashMap stability — hashCode не змінюється.
  • Яка ціна незмінності? — GC pressure при модифікаціях. Кожна конкатенація/replace створює новий об’єкт.
  • Чи можна змінити String через рефлексію? — Технічно так, але це порушення контракту, ламає String Pool, HashMap, JIT-оптимізації.
  • Що використовувати для модифікацій?StringBuilder (один потік), StringBuffer (кілька потоків), byte[] (low-level).

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

  • ❌ “replace() змінює рядок на місці” — повертає новий рядок
  • ❌ “String immutable означає не можна створити новий рядок” — можна, оригінал не змінюється
  • ❌ “String thread-safe тому що final клас” — thread-safe тому що стан не змінюється
  • ❌ “Рефлексія — нормальний спосіб змінити String” — це антипатерн, порушує контракт

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

  • [[5. Коли використовувати StringBuilder vs StringBuffer]]
  • [[1. Як працює String Pool]]
  • [[21. Чи можна змінити вміст String через рефлексію]]