Чому 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 зроблено незмінним
- String Pool (Пул рядків): Можливість повторного використання рядків заощаджує десятки мегабайтів RAM. Якби рядки були мутабельними, зміна рядка
s1 = "Hello"в одному модулі негайно зіпсувала б дані в усіх інших місцях застосунку, що посилаються на цей самий літерал. - Безпека (Security): Рядки використовуються як аргументи для підключення до баз даних (логіни/паролі), мережевих URL, імен завантажуваних класів (
ClassLoader) та шляхів до файлів на диску. Незмінність гарантує, що перевірені параметри не можуть бути підмінені іншим потоком. - Потокобезпека (Thread Safety): Незмінні об’єкти абсолютно потокобезпечні «з коробки» (Thread-safe by design). Рядок можна вільно читати із сотень паралельних потоків без будь-якої синхронізації та блокувань.
- Кешування хеш-коду (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 тощо)
}
finalклас: Забороняє створення підкласів (extends String). Жоден сторонній код не може перевизначити методи рядка та впровадити мутабельну поведінку.private final byte[] value: Посилання на масив символів не може бути переприсвоєне після виходу з конструктора, аprivateприховує прямий доступ до байтів.- Відсутність мутаторів: У класі
Stringнемає жодного методу, що змінює дані «за місцем» (in-place). - Захисне копіювання (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 категорично заборонено:
- Об’єкт
Stringнезмінний — його не можна примусово затерти нулями в пам’яті після перевірки автентичності. - Рядок із паролем перебуватиме в пам’яті купи (Heap) доти, доки його не збере Garbage Collector (а якщо рядок потрапив у пул або Old Gen — це можуть бути години або дні).
- Під час зняття аварійного дампу пам’яті (Core Dump / Heap Dump) зловмисник легко прочитає паролі відкритим текстом.
- 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».
Чек-лист ключових понять
- Реалізація:
final class,private final byte[] value, анотація@Stable, відсутність сетерів. - Безпека (TOCTOU): Захист від підміни мережевих адрес та шляхів до файлів між валідацією та використанням.
- Зберігання паролів: Паролі не можна зберігати у
String(неможливо затерти в купі), слід використовуватиchar[]. - Кешування хешу:
hashCode()обчислюється один раз. ПолеhashIsZeroзахищає від деградації при нульовому хеші. - Гарантії JMM: Специфікація final-полів гарантує потокобезпечну публікацію рядків без м’ютексів.
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ «Методи toUpperCase() або replace() змінюють сам рядок за місцем» (Вони завжди повертають новий об’єкт
String). - ❌ «String є потокобезпечним, тому що його методи оголошені як synchronized» (У String немає жодного synchronized-методу; він потокобезпечний завдяки незмінності).
- ❌ «Паролі краще зберігати у String, тому що String незмінний і надійний» (Це грубе порушення безпеки; паролі зберігають у
char[]для наступного занулення).