Чому клас String є незмінним
У Java клас java.lang.String спроєктований незмінним (Immutable) через фундаментальні архітектурні причини: 4. Кешування hashCode(): незмінний стан дозволяє обчислити геш-код рі...
🟢 Junior Level
30-секундна відповідь
У Java клас java.lang.String спроєктований незмінним (Immutable) через фундаментальні архітектурні причини:
- Економія пам’яті (String Pool): рядкові літерали з однаковим текстом розділяють один і той самий об’єкт у пам’яті (патерн Flyweight). Без незмінності зміна рядка в одному місці призвела б до непомітного псування даних в усіх інших частинах програми.
- Безпека (Security): рядки використовуються для передачі URL, шляхів до файлів, параметрів підключення до БД, мережевих сокетів та імен класів у
ClassLoader. Незмінність запобігає атакам підміни даних. - Потокобезпека (Thread Safety): рядки можна безпечно передавати між потоками без блокувань,
volatileта ключового словаsynchronized. - Кешування
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.
Як при цьому забезпечується потокобезпека?
- У JMM (§17.7) запис 32-бітного значення
intатомарний на всіх JVM. - Алгоритм обчислення гешу є чистою детермінованою функцією від незмінного масиву
value. - Навіть якщо два потоки паралельно викличуть
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:
- GC у фоновому режимі аналізує рядки, що пережили кілька збирань сміття (Tenured/Old Gen).
- Якщо виявлено різні об’єкти
Stringз однаковим гешем та ідентичним байтовим масивомvalue, посиланняvalueодного об’єкта перенаправляється на масив іншого. - Дублюючий масив
byte[]звільняється з пам’яті. Це можливо виключно завдяки незмінності масивуvalue.
4 Tricky Questions
1. Як працює лінива ініціалізація hash у String без volatile та синхронізації, і чому це допустимо за JMM?
Відповідь:
Поле hash оголошено як private int hash;. Згідно зі специфікацією JMM (§17.7), операції читання та запису примітивних типів розміром до 32 біт (включно з int) суворо атомарні.
Обчислення геш-коду детерміноване: воно залежить тільки від незмінного масиву byte[] value. Якщо два потоки викличуть hashCode() одночасно:
- Вони обидва паралельно обчислять одне й те саме число.
- Вони обидва запишуть його в
hash. - Читаючі потоки побачать або
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 був мутабельним:
- Потік передає рядок
name = "com.app.SafeClass"уClassLoader. ClassLoaderзвіряє права безпеки та визначає, що клас безпечний.- Паралельний зловмисний потік у цей самий момент змінює вміст рядка на
"java.lang.System"або ім’я системного привілейованого класу. 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та ідемпотентності обчислень.