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

Чому String є незмінним

Будь-яка операція, яка візуально «модифікує» рядок (concat(), replace(), substring(), toUpperCase()), насправді не змінює початковий рядок. Вона завжди створює та повертає абсол...


🟢 Junior Level

Імутабельність (Immutability, незмінність) класу String означає, що після того, як об’єкт рядка був створений у пам’яті JVM, його внутрішній стан і символи не можуть бути змінені за жодних обставин.

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

Будь-яка операція, яка візуально «модифікує» рядок (concat(), replace(), substring(), toUpperCase()), насправді не змінює початковий рядок. Вона завжди створює та повертає абсолютно новий об’єкт String у пам’яті з новим вмістом!

String original = "hello";

// Спроба "змінити" рядок:
original.toUpperCase(); 

// Початковий рядок залишився без змін:
System.out.println(original); // Виведе: "hello"

// Щоб зберегти результат, потрібно присвоїти посилання на новий об'єкт:
String upper = original.toUpperCase();
System.out.println(upper);    // Виведе: "HELLO"

4 фундаментальні причини, чому String зроблено незмінним

  1. String Pool (Пул рядків): Можливість повторного використання рядків заощаджує десятки мегабайтів RAM. Якби рядки були мутабельними, зміна рядка s1 = "Hello" в одному модулі негайно зіпсувала б дані в усіх інших місцях застосунку, що посилаються на цей самий літерал.
  2. Безпека (Security): Рядки використовуються як аргументи для підключення до баз даних (логіни/паролі), мережевих URL, імен завантажуваних класів (ClassLoader) та шляхів до файлів на диску. Незмінність гарантує, що перевірені параметри не можуть бути підмінені іншим потоком.
  3. Потокобезпека (Thread Safety): Незмінні об’єкти абсолютно потокобезпечні «з коробки» (Thread-safe by design). Рядок можна вільно читати із сотень паралельних потоків без будь-якої синхронізації та блокувань.
  4. Кешування хеш-коду (HashMap/HashSet): Оскільки символи рядка не змінюються, його hashCode() обчислюється рівно один раз при першому зверненні та кешується в об’єкті. Це робить String ідеальним і надшвидким ключем для хеш-таблиць.

🟡 Middle Level

Як незмінність реалізована в коді JDK

У вихідному коді OpenJDK незмінність забезпечується поєднанням суворих мовних конструкцій:

// java.lang.String (OpenJDK 17/21)
public final class String implements Serializable, Comparable<String>, CharSequence {

    @Stable
    private final byte[] value; // 1. Приватний і final масив байтів
    private final byte coder;   // 2. Кодування (LATIN1 або UTF16)
    private int hash;           // 3. Кешований хеш-код
    private boolean hashIsZero; // 4. Прапорець для рядків з hash == 0

    // Відсутні будь-які методи-мутатори (немає setCharAt, append тощо)
}
  1. final клас: Забороняє створення підкласів (extends String). Жоден сторонній код не може перевизначити методи рядка та впровадити мутабельну поведінку.
  2. private final byte[] value: Посилання на масив символів не може бути переприсвоєне після виходу з конструктора, а private приховує прямий доступ до байтів.
  3. Відсутність мутаторів: У класі String немає жодного методу, що змінює дані «за місцем» (in-place).
  4. Захисне копіювання (Defensive Copying): Усі методи модифікації (substring, replace, concat) створюють нові масиви байтів і повертають нові екземпляри String.

Зворотний бік медалі: Навантаження на Garbage Collector

Головна ціна незмінності — високий Garbage Allocation Rate при частих операціях конкатенації:

// АНТИПАТЕРН: катастрофічне виділення тимчасових об'єктів у купі:
String result = "";
for (int i = 0; i < 10_000; i++) {
    result += i; // Кожна ітерація створює новий об'єкт String і масив байтів!
}

При такому циклі створюється ~10 000 проміжних рядкових об’єктів, які миттєво засмічують Eden Space і викликають безперервні паузи збирача сміття. Для масових модифікацій обов’язково використовуються мутабельні буфери: StringBuilder (в одному потоці) або StringBuffer (багатопотоковий).


🔴 Senior Level

Атака TOCTOU (Time-of-Check to Time-of-Use) та безпека JVM

Критична вразливість, яка виникла б у Java за умови мутабельності рядків:

// Сценарій вразливості TOCTOU, якби String був мутабельним:
public void openSecureFile(String path) {
    // 1. Time-of-Check: перевірка прав доступу
    if (!securityManager.isAccessAllowed(path)) { // path = "/data/public/report.txt"
        throw new SecurityException("Доступ заборонено");
    }

    // <-- У цей момент паралельний потік зловмисника викликає: path.setValue("/etc/passwd")!

    // 2. Time-of-Use: відкриття файлу
    fileSystem.open(path); // Відкривається захищений системний файл в обхід перевірки!
}

Завдяки імутабельності String посилання path гарантовано вказує на незмінний контент, і атака підміни стану між перевіркою та використанням (TOCTOU) фізично неможлива.

Анотація @Stable та оптимізації JIT C2 Compiler

Зверніть увагу на анотацію @Stable над полем private final byte[] value у вихідному коді String:

  • Звичайне поле final в Java захищає посилання на масив, але не елементи самого масиву (байти в масиві теоретично можна змінити).
  • Спеціальна внутрішня анотація HotSpot @Stable повідомляє JIT-компілятору (C2):

    «Елементи цього масиву байтів ніколи не зміняться після ініціалізації».

  • Це дозволяє компілятору C2 застосовувати агресивні низькорівневі оптимізації: згортання констант у машинному коді (Constant Folding), інлайнінг операцій charAt(), векторизацію циклів через інструкції SIMD (AVX-512) та усунення перевірок меж масиву (Escape Analysis & Bounds Check Elimination).

Чому паролі не можна зберігати в String

Попри всі переваги безпеки, зберігати паролі, приватні ключі та токени в об’єктах String категорично заборонено:

  1. Об’єкт String незмінний — його не можна примусово затерти нулями в пам’яті після перевірки автентичності.
  2. Рядок із паролем перебуватиме в пам’яті купи (Heap) доти, доки його не збере Garbage Collector (а якщо рядок потрапив у пул або Old Gen — це можуть бути години або дні).
  3. Під час зняття аварійного дампу пам’яті (Core Dump / Heap Dump) зловмисник легко прочитає паролі відкритим текстом.
  4. Senior Рішення: Паролі завжди зберігають у масиві символів char[] або байтів byte[], які одразу після валідації примусово занулюються викликом Arrays.fill(password, '\0').

4 Tricky Questions

1. Як працює кешування хеш-коду в класі String, і чому для рядків із колізією hash == 0 у Java 13 додали поле hashIsZero?

Відповідь: Хеш-код рядка кешується ліниво в приватному полі private int hash;. При першому виклику hashCode() метод обчислює значення за формулою $s[0]\cdot 31^{n-1} + \dots + s[n-1]$ і зберігає в полі hash. При повторних викликах одразу повертається збережене число. Пастка з нулем: До Java 13 логіка перевірки виглядала так: if (hash == 0 && value.length > 0) { hash = computeHash(); }. Якщо рядок давав реальний хеш-код 0 (наприклад, рядок "DA" або порожній рядок), перевірка hash == 0 вважала, що хеш ще не обчислено, і при кожному виклику заново перераховувала хеш по всіх символах! Для усунення цієї деградації в Java 13 ввели булеве поле private boolean hashIsZero;, що сигналізує, що обчислений хеш дорівнює саме нулю, запобігаючи повторним обчисленням.

2. Що станеться в сучасних версіях Java (17/21+), якщо спробувати змінити поле value об’єкта String через Reflection API?

Відповідь: Код завершиться аварійним винятком java.lang.reflect.InaccessibleObjectException. Починаючи з Java 16/17 у межах ініціативи щодо суворої інкапсуляції нутрощів JDK (JEP 396 та JEP 403), доступ до приватних полів базових модулів через field.setAccessible(true) жорстко заблокований: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module. Оминути це можна лише явною передачею небезпечного аргументу JVM під час старту застосунку: --add-opens java.base/java.lang=ALL-UNNAMED.

3. Чому для створення рядків із модифікаціями використовують StringBuilder, а не StringBuffer, і коли StringBuffer усе ще може стати в пригоді?

Відповідь:

  • StringBuilder (з’явився в Java 5) несинхронізований і працює значно швидше, оскільки не витрачає процесорний час на захоплення блокувань монітора (synchronized). У 99.9% сценаріїв модифікація рядків відбувається локально всередині одного потоку (у тілі методу), тому синхронізація надлишкова.
  • StringBuffer — застарілий потокобезпечний клас, у якого всі ключові методи позначені ключовим словом synchronized. Він необхідний лише в рідкісних випадках, коли один і той самий мутабельний буфер розділяється між кількома конкуруючими потоками без зовнішньої синхронізації.

4. Чи гарантує специфікація Java Memory Model (JMM) потокобезпечну публікацію рядків без синхронізації?

Відповідь: Так, абсолютно гарантує! Згідно зі специфікацією JMM (JLS §17.5 Final Field Semantics), будь-які поля з модифікатором final мають особливі гарантії видимості при безпечній публікації:

  • Коли конструктор об’єкта String завершує виконання, процесор виставляє бар’єр пам’яті (StoreStore / LoadStore barrier), що гарантує, що всі записи у final byte[] value будуть фізично видимі будь-якому іншому потоку, який отримав посилання на об’єкт String.
  • Жоден потік у багатопотоковому середовищі ніколи не зможе побачити частково ініціалізований рядок або значення null у полі value навіть за відсутності зовнішньої синхронізації volatile або synchronized.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«Імутабельність String означає, що стан об’єкта не можна змінити після створення: будь-які методи модифікації повертають новий рядок. Вона реалізована через оголошення класу final, зберігання даних у private final byte[] value, відсутність мутаторів та захисне копіювання. Незмінність критично необхідна для роботи String Pool, гарантує потокобезпеку без блокувань (JLS §17.5), запобігає атакам безпеки (TOCTOU) при роботі з файлами та URL, а також дозволяє кешувати hashCode() для швидкої роботи в HashMap».

Чек-лист ключових понять

  1. Реалізація: final class, private final byte[] value, анотація @Stable, відсутність сетерів.
  2. Безпека (TOCTOU): Захист від підміни мережевих адрес та шляхів до файлів між валідацією та використанням.
  3. Зберігання паролів: Паролі не можна зберігати у String (неможливо затерти в купі), слід використовувати char[].
  4. Кешування хешу: hashCode() обчислюється один раз. Поле hashIsZero захищає від деградації при нульовому хеші.
  5. Гарантії JMM: Специфікація final-полів гарантує потокобезпечну публікацію рядків без м’ютексів.

Червоні прапорці (чого в жодному разі не можна говорити)

  • ❌ «Методи toUpperCase() або replace() змінюють сам рядок за місцем» (Вони завжди повертають новий об’єкт String).
  • ❌ «String є потокобезпечним, тому що його методи оголошені як synchronized» (У String немає жодного synchronized-методу; він потокобезпечний завдяки незмінності).
  • ❌ «Паролі краще зберігати у String, тому що String незмінний і надійний» (Це грубе порушення безпеки; паролі зберігають у char[] для наступного занулення).

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