🔒 Розділ 13 · Питання #4

Чому клас String є незмінним

У Java клас java.lang.String спроєктований незмінним (Immutable) через фундаментальні архітектурні причини: 4. Кешування hashCode(): незмінний стан дозволяє обчислити геш-код рі...


🟢 Junior Level

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

У Java клас java.lang.String спроєктований незмінним (Immutable) через фундаментальні архітектурні причини:

  1. Економія пам’яті (String Pool): рядкові літерали з однаковим текстом розділяють один і той самий об’єкт у пам’яті (патерн Flyweight). Без незмінності зміна рядка в одному місці призвела б до непомітного псування даних в усіх інших частинах програми.
  2. Безпека (Security): рядки використовуються для передачі URL, шляхів до файлів, параметрів підключення до БД, мережевих сокетів та імен класів у ClassLoader. Незмінність запобігає атакам підміни даних.
  3. Потокобезпека (Thread Safety): рядки можна безпечно передавати між потоками без блокувань, volatile та ключового слова synchronized.
  4. Кешування hashCode(): незмінний стан дозволяє обчислити геш-код рівно один раз і закешувати його, забезпечуючи максимальну швидкість роботи в HashMap та HashSet.

Будь-який метод класу String, що візуально змінює рядок (concat(), replace(), toLowerCase(), substring()), насправді створює та повертає абсолютно новий об’єкт String у купі, залишаючи вихідний об’єкт недоторканим.


Практичний приклад

public class StringImmutabilityDemo {
    public static void main(String[] args) {
        String original = "Java";
        
        // Метод concat() повертає НОВИЙ рядок, вихідний не змінюється!
        original.concat(" 21");
        System.out.println("original: " + original); // Виведе "Java"

        // Щоб зберегти результат, необхідно явно переприсвоїти посилання:
        String modified = original.concat(" 21");
        System.out.println("modified: " + modified); // Виведе "Java 21"
    }
}

Наочна схема в пам’яті (Heap):

[Стек (Stack)]               [Купа (Heap)]
original  ───────►  [ String Object: "Java" ]
                                ▲
                                │ (не зачеплений!)
modified  ───────►  [ String Object: "Java 21" ]

🟡 Middle Level

1. Архітектура String Pool та економія пам’яті

Пул рядків (String Constant Pool) — спеціальна область у купі (Heap), що реалізує патерн Flyweight (Пристосуванець):

String s1 = "interview";
String s2 = "interview";

System.out.println(s1 == s2); // true — обидва посилання вказують на один і той самий об'єкт у пулі!

Якби String був мутабельним, виконання виклику s1.setCharAt(0, 'I') призвело б до того, що змінна s2 (а також будь-які системні бібліотеки, що завантажили цей рядковий літерал) раптово побачили б "Interview", що спричинило б катастрофічні важковловні помилки.


2. Захист від атак Time-of-Check to Time-of-Use (TOCTOU)

Рядки лежать в основі всієї підсистеми безпеки Java:

public void writeFile(String filePath, byte[] data) {
    // 1. Time-of-Check: валідація прав доступу
    if (!securityManager.isAccessAllowed(filePath)) {
        throw new SecurityException("Access denied to: " + filePath);
    }

    // Якби String був мутабельним, паралельний потік міг би 
    // змінити filePath на "/etc/shadow" прямо в цей мікромомент!

    // 2. Time-of-Use: фактичне виконання операції
    osFileSystem.write(filePath, data);
}

Завдяки незмінності filePath гарантовано вказує на той самий незмінний шлях, який пройшов перевірку безпеки. Аналогічно ClassLoader захищений від підміни імені завантажуваного класу (Class.forName(className)).


3. Кешування геш-коду для асоціативних колекцій

Усередині класу String геш-код кешується в приватному полі:

public final class String {
    private int hash; // За замовчуванням 0

    public int hashCode() {
        int h = hash;
        if (h == 0 && !isEmpty()) {
            h = isLatin1() ? StringLatin1.hashCode(value) 
                           : StringUTF16.hashCode(value);
            hash = h;
        }
        return h;
    }
}
  • Під час першого виклику hashCode() складність становить $O(N)$ (обчислюється за поліноміальним алгоритмом $s[0]\cdot 31^{n-1} + \dots$).
  • Усі наступні виклики повертають закешоване значення за $O(1)$.
  • Це робить String ідеальним і найчастіше використовуваним ключем для HashMap та HashSet.

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

Через незмінність рядків та String Pool рядковий об’єкт із паролем "SuperSecret123" залишається в оперативній пам’яті (Heap) на невизначений термін, доки збирач сміття не збере його і не перезапише пам’ять. У разі аварійного дампу пам’яті (heap dump) або інспекції через /proc/kcore пароль буде легко прочитаний у відкритому вигляді. Безпечний підхід: використовувати масив символів char[] або байтів byte[], який можна гарантовано обнулити відразу після використання:

char[] password = console.readPassword("Enter password: ");
try {
    authenticate(password);
} finally {
    Arrays.fill(password, '0'); // Фізичне стирання конфіденційних даних з RAM
}

🔴 Senior Level

Внутрішня будова: Compact Strings (Java 9+, JEP 254)

До Java 8 рядки представлялися масивом двобайтових символів:

// Java 8
private final char[] value; // UTF-16: 2 байти на кожен символ завжди

У Java 9+ введено стиснення рядків (Compact Strings), що скоротило споживання купи типового enterprise-застосунку на 15–30%:

// Java 9+
private final byte[] value; // 1 байт на символ для Latin-1, 2 байти для UTF-16
private final byte coder;   // 0 = LATIN1, 1 = UTF16
private int hash;           // Закешований геш-код (0 = не обчислений)
private boolean hashIsZero; // Для рядків, у яких реальний hashCode == 0 (Java 13+)

Незмінність дозволяє JVM гарантувати цілісність байтового масиву та прапорця кодування coder протягом усього життєвого циклу об’єкта.


Гарантії JMM §17.5 та лінива ініціалізація hash

Поле hash у String не є final і не є volatile. Як при цьому забезпечується потокобезпека?

  1. У JMM (§17.7) запис 32-бітного значення int атомарний на всіх JVM.
  2. Алгоритм обчислення гешу є чистою детермінованою функцією від незмінного масиву value.
  3. Навіть якщо два потоки паралельно викличуть hashCode() у нового об’єкта:
    • Обидва обчислять рівно одне й те саме 32-бітне число $H$.
    • Обидва запишуть це число в поле hash.
    • Це так звана нешкідлива гонитва даних (Benign Data Race). Жоден потік ніколи не побачить «зіпсоване» значення.

Апаратні векторні інтринсики (SIMD)

JIT-компілятор HotSpot C2 має спеціальні правила підстановки інтринсиків (intrinsics) для методів String:

  • equals() та compareTo() замінюються спеціалізованими асемблерними інструкціями AVX-2 / AVX-512 (на x86-64) та Neon (на ARM64).
  • JVM порівнює рядки 32- або 64-байтовими векторними регістрами за 1 машинний такт CPU замість посимвольного циклу.
  • Незмінність дає C2 компілятору право покладатися на незмінність меж та байтового вмісту масиву value, безпечно застосовуючи розгортання циклів (loop unrolling) та векторизацію.

Escape Analysis та усунення алокацій

У разі ланцюжкових викликів методів, що створюють нові рядки:

public String formatName(String first, String last) {
    return (first + " " + last).trim();
}

C2-компілятор виконує аналіз витоку об’єктів (Escape Analysis). Якщо проміжний об’єкт не виходить за межі стекового фрейму, HotSpot задіює механізм Scalar Replacement (скалярне заміщення), повністю виключаючи алокацію проміжних рядків у купі та розміщуючи їхні дані прямо в регістрах CPU.


Уніфікована дедуплікація рядків (JEP 192, JDK-8267186)

Починаючи з Java 8u20 для G1, а з Java 18+ — для всіх основних GC (G1, Parallel, Serial, ZGC) доступна опція -XX:+UseStringDeduplication:

  1. GC у фоновому режимі аналізує рядки, що пережили кілька збирань сміття (Tenured/Old Gen).
  2. Якщо виявлено різні об’єкти String з однаковим гешем та ідентичним байтовим масивом value, посилання value одного об’єкта перенаправляється на масив іншого.
  3. Дублюючий масив byte[] звільняється з пам’яті. Це можливо виключно завдяки незмінності масиву value.

4 Tricky Questions

1. Як працює лінива ініціалізація hash у String без volatile та синхронізації, і чому це допустимо за JMM?

Відповідь: Поле hash оголошено як private int hash;. Згідно зі специфікацією JMM (§17.7), операції читання та запису примітивних типів розміром до 32 біт (включно з int) суворо атомарні. Обчислення геш-коду детерміноване: воно залежить тільки від незмінного масиву byte[] value. Якщо два потоки викличуть hashCode() одночасно:

  1. Вони обидва паралельно обчислять одне й те саме число.
  2. Вони обидва запишуть його в hash.
  3. Читаючі потоки побачать або 0 (якщо запис ще не видно в їхньому кеші процесора), або підсумковий правильний геш. Якщо потік прочитав 0, він просто перерахує геш наново й отримає те саме число. Така гонитва називається benign race condition (нешкідлива гонитва) і дозволена специфікацією JMM для оптимізації продуктивності без витрат на бар’єри volatile.

2. Що станеться під час виклику new String("hello") порівняно з літералом "hello"? Чи буде дублюватися внутрішній масив byte[] у сучасних версіях Java?

Відповідь:

  • Літерал "hello" розміщується в String Constant Pool.
  • Виклик new String("hello") примусово створює новий окремий об’єкт String у звичайній купі (Heap), ігноруючи пряме використання посилання з пулу.
  • У сучасних версіях Java (починаючи з Java 9 Compact Strings): конструктор String(String original) перевіряє, чи є вихідний рядок компактним або спільним, але сам об’єкт-обгортка String завжди виділяється наново. Масив value у більшості сучасних реалізацій HotSpot або копіюється, або дедуплікується збирачем сміття, проте створення рядків через new String() є антипатерном, що даремно засмічує купу.

3. Яким чином мутабельність рядків могла б зруйнувати ізоляцію завантажувачів класів (ClassLoader)?

Відповідь: Завантажувачі класів у JVM викликають метод ClassLoader.loadClass(String name). Ім’я класу ("java.lang.SecurityManager", "com.app.User") передається у вигляді рядка. Якби String був мутабельним:

  1. Потік передає рядок name = "com.app.SafeClass" у ClassLoader.
  2. ClassLoader звіряє права безпеки та визначає, що клас безпечний.
  3. Паралельний зловмисний потік у цей самий момент змінює вміст рядка на "java.lang.System" або ім’я системного привілейованого класу.
  4. ClassLoader завантажує або перевизначає критичний системний клас, ламаючи sandbox-ізоляцію всієї віртуальної машини.

4. Навіщо в Java 13 у клас String було додано приватне поле hashIsZero?

Відповідь: До Java 13 для перевірки того, чи був обчислений геш-код, використовувалася умова if (hash == 0). Проте існують рядки, математичний геш-код яких дійсно дорівнює 0 (наприклад, порожній рядок "" або спеціально підібрані рядки на кшталт "DA" та "EB" з колізією в 0). Для таких рядків метод hashCode() щоразу бачив hash == 0, вважав, що геш ще не пораховано, і наново виконував повний цикл перерахунку за масивом за $O(N)$ при кожному виклику! У Java 13+ було додано булеве поле hashIsZero: якщо геш пораховано і він дорівнює нулю, виставляється hashIsZero = true, що гарантує складність $O(1)$ для абсолютно будь-яких рядків.


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

4 стовпи незмінності String

| Стовп | Чому це критично | Що зламається, якщо зробити String мутабельним | | :— | :— | :— | | String Pool | Економія пам’яті (Flyweight) | Зміна одного літерала змінить його в усіх модулях програми | | Security (TOCTOU) | Захист шляхів до файлів, сокетів, URL | Підміна шляху до ресурсу після перевірки прав доступу | | Thread Safety | Читання без блокувань та synchronized | Гонитви даних, необхідність synchronized у кожному зверненні | | HashMap / HashCode | Швидкий закешований пошук за $O(1)$ | Втрата ключів у Map у разі зміни гешу на місці |

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «Метод concat() або substring() модифікує вихідний об’єкт String» — груба помилка, завжди повертається новий об’єкт.
  • ❌ «String Pool розташований у PermGen» — застаріло з Java 7 (пул перенесено у звичайну купу Heap, PermGen ліквідовано в Java 8).
  • ❌ «Паролі безпечно передавати та зберігати в String» — ні, рядки осідають у Heap Dump; паролі зберігають у char[] і зануляють.
  • ❌ «Поле hash у String є volatile» — ні, воно звичайне int, потокобезпека досягається за рахунок атомарності запису int та ідемпотентності обчислень.

Пов’язані запитання