Що таке компактні рядки в Java 9+
Оптимізація увімкнена за замовчуванням і працює абсолютно прозоро для розробника.
🟢 Junior Level
Compact Strings (JEP 254) — це фундаментальна оптимізація внутрішнього зберігання рядків, впроваджена в Java 9, яка дозволяє рядкам займати вдвічі менше пам’яті, якщо вони складаються лише із символів латиниці та стандартних службових знаків.
Суть у 30 секундах
- До Java 9: Усі рядки всередині JVM зберігалися у вигляді масиву 16-бітних символів
char[] valueу кодуванні UTF-16. Кожен символ займав рівно 2 байти, навіть якщо це були звичайні латинські літери'a','b','c'або цифри, у яких старший байт завжди дорівнював нулю (0x00). - Починаючи з Java 9 (Java 11, 17, 21+): Масив
char[]замінили на масив байтівbyte[] valueта додали однобайтний прапорецьcoder:- Якщо всі символи рядка вкладаються в діапазон Latin-1 (
U+0000..U+00FF) $\to$ використовується 1 байт на символ (coder = 0). - Якщо в рядку з’являється хоча б один нелатинський символ (кирилиця, ієрогліфи, емодзі) $\to$ рядок перемикається на UTF-16 із витратою 2 байти на символ (
coder = 1).
- Якщо всі символи рядка вкладаються в діапазон Latin-1 (
// Приклади розподілу пам'яті в Java 9+:
String latin = "Hello World"; // 11 символів -> 11 байтів у масиві value (coder = 0)
String cyr = "Привіт Світ"; // 11 символів -> 22 байти у масиві value (coder = 1)
Оптимізація увімкнена за замовчуванням і працює абсолютно прозоро для розробника.
🟡 Middle Level
Внутрішня будова: структура класу String
У Java 9 вихідний код класу java.lang.String зазнав таких змін:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value; // Замість колишнього char[] value
private final byte coder; // Кодування: 0 (LATIN1) або 1 (UTF16)
private int hash; // Кешований хеш-код
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
}
Механіка роботи базових методів через бітові зсуви
Завдяки ступеню двійки (1 байт проти 2 байтів) операції обчислення довжини та індексів у HotSpot виконуються швидкими побітовими зсувами (>> та <<):
// Обчислення довжини рядка:
public int length() {
// Якщо coder == 0 (LATIN1): value.length >> 0 = value.length
// Якщо coder == 1 (UTF16): value.length >> 1 = value.length / 2
return value.length >> coder;
}
// Отримання символу за індексом:
public char charAt(int index) {
if (isLatin1()) {
return (char)(value[index] & 0xff); // 1 байт розширюється до char
}
return StringUTF16.getChar(value, index); // Склеюються 2 байти
}
«Роздування» рядка (String Inflation)
У класі String кодування задається однорідно для всього масиву. У межах одного рядка не можна зберігати частину символів у Latin-1, а частину в UTF-16.
Якщо до латинського рядка додається хоча б один не-Latin1 символ, відбувається роздування (Inflation):
String a = "User_"; // coder = LATIN1 (5 байтів)
String b = "Іван"; // coder = UTF16 (8 байтів)
String c = a + b; // ПІДСУМОК: coder = UTF16!
// Довжина = 9 символів -> розмір масиву value = 18 байтів!
// Префікс "User_" у рядку c тепер теж займає по 2 байти на кожен символ.
Порівняльна таблиця споживання пам’яті
| Дані / Рядок | Довжина в символах | Java 8 (char[]) | Java 9+ (byte[]) | Економія пам’яті масиву |
| :— | :— | :— | :— | :— |
| "Java" (ASCII) | 4 | 8 байтів | 4 байти | 50% |
| "HTTP/1.1 200 OK" | 15 | 30 байтів | 15 байтів | 50% |
| "UUID: 123e4567..." | 42 | 84 байти | 42 байти | 50% |
| "Інтерв'ю" (Кирилиця) | 8 | 16 байтів | 16 байтів | 0% |
| "User: Іван" (Змішаний) | 10 | 20 байтів | 20 байтів | 0% (увесь рядок UTF-16) |
🔴 Senior Level
Чому в Java 6 провалився прапорець -XX:+UseCompressedStrings
Спроба пакувати рядки здійснювалася ще в Java 6 Update 21 через експериментальний прапорець -XX:+UseCompressedStrings. Проте в Java 7 цей прапорець було повністю видалено.
Чому реалізація Java 6 зазнала невдачі?
- У Java 6 стиснення працювало лише для самого об’єкта
String, але класStringBuilderта всі внутрішні алгоритми продовжували працювати зchar[]. - Будь-яка конкатенація чи модифікація рядка вимагала циклічного розпакування
byte[]уchar[]та зворотного пакування, що спричиняло катастрофічну деградацію CPU (просідання пропускної здатності на 30–40%). - Чому все вдалося в Java 9 (JEP 254)?
Архітектори переписали всю екосистему JDK: класи
AbstractStringBuilder,StringBuilder,StringBuffer, інтерфейсCharSequenceта методи JVM були від самого початку переведені на роботу зbyte[] valueта полемcoder. Жодних зайвих проміжних розпакувань більше не відбувається.
Апаратні SIMD Intrinsics у HotSpot C2
Компілятор HotSpot C2 замінює методи класів StringLatin1 та StringUTF16 на нативні асемблерні векторні інструкції (SIMD: AVX-512, AVX-2 на x86-64, NEON на ARM64):
StringUTF16.compress: Векторна інструкція зчитує шістнадцять 16-бітних символів за машинний такт, паралельно перевіряє, що всі старші байти дорівнюють0x00, і стискає їх у шістнадцять 8-бітних байтів за одну процесорну команду.StringLatin1.inflate: Розпаковує масив байтів у масив 16-бітних слів, чергуючи байти з нулями з векторною швидкістю пам’яті.StringLatin1.equals: Порівнює рядки блоками по 32 або 64 байти за один такт процесора.
Значення анотації @Stable
Зверніть увагу на оголошення поля value у String.java:
@Stable
private final byte[] value;
У Java ключове слово final на масиві гарантує незмінність лише самого посилання на масив, але не його елементів!
Внутрішня анотація JVM @Stable надає компілятору C2 потужну оптимізаційну підказку: елементи масиву ніколи не мутують після ініціалізації.
Якщо посилання на рядок визнається константою (Constant Folding), JIT-компілятор підставляє значення елементів масиву безпосередньо в машинний код (Constant Inlining), оминаючи реальні звернення до оперативної пам’яті купи.
Вплив на Object Layout (Вирівнювання в купі)
У 64-бітній JVM з увімкненим Compressed OOPs:
- Заголовок об’єкта
String: Mark Word (8 байтів) + Klass Word (4 байти) = 12 байтів. - Посилання на масив
byte[] value: 4 байти. - Поле
byte coder: 1 байт. - Поле
int hash: 4 байти. - Поле
byte hashIsZero(у Java 13+): 1 байт. - Padding (вирівнювання до 8 байтів): 2 байти.
Разом сам інстанс
Stringзаймає рівно 24 байти — стільки ж, скільки займав у Java 7/8! Уся колосальна економія пам’яті купи (до 20–30% від усього Heap застосунку) досягається виключно завдяки скороченню розміру самого масивуbyte[]вдвічі.
4 Tricky Questions
1. Чим Compact Strings у Java 9 (JEP 254) принципово відрізняються від експериментального прапорця -XX:+UseCompressedStrings із Java 6?
Відповідь:
- У Java 6 стиснення було локальним «милицею» лише для класу
String. КласиStringBuilder,StringBufferта внутрішні буфери I/O, як і раніше, використовувалиchar[]. При кожній конкатенації відбувалося постійне розпакування й запакування даних, що критично сповільнювало CPU. - У Java 9 весь ланцюжок роботи з рядками (
AbstractStringBuilder,StringBuilder,StringBuffer, строкові фабрики та HotSpot Intrinsics) було цілком переведено наbyte[]із прапорцемcoder. Пакування та розпакування даних відбуваються нативно з використанням векторних SIMD-інструкцій CPU, що усунуло деградацію продуктивності.
2. Скільки байтів пам’яті в купі займе рядок "Hello 123" у Java 8 та в Java 17 (на 64-бітній JVM з Compressed OOPs)?
Відповідь: Довжина рядка 9 символів (чистий ASCII / Latin-1).
- У Java 8:
- Об’єкт
String: 24 байти. - Масив
char[9]: заголовок масиву (16 байтів) + $9 \times 2$ байти = 34 байти $\to$ вирівнювання до 8 байтів = 40 байтів. - Разом у Java 8: 64 байти.
- Об’єкт
- У Java 17:
- Об’єкт
String: 24 байти. - Масив
byte[9](coder = LATIN1): заголовок масиву (16 байтів) + $9 \times 1$ байт = 25 байтів $\to$ вирівнювання до 8 байтів = 32 байти. - Разом у Java 17: 56 байтів. Економія становить 8 байтів на рядок (~12.5% від сумарного розміру дрібного об’єкта з усіма заголовками, а для рядків довжиною 100+ символів економія прагне до 50%).
- Об’єкт
3. Чи знижує метод substring() кодування з UTF-16 до Latin-1, якщо вирізати латинський підрядок із кириличного рядка?
Відповідь:
Ні, не знижує.
У реалізації OpenJDK метод substring() успадковує прапорець coder від вихідного рядка:
// Вихідний рядок UTF-16:
String source = "Code: 12345 (Код)"; // coder = UTF16
String sub = source.substring(0, 10); // "Code: 12345"
Хоча підрядок "Code: 12345" складається виключно з латинських символів і цифр, його внутрішнє поле coder залишиться рівним UTF16, і кожен символ продовжить займати по 2 байти.
JVM свідомо не сканує масив при кожному виклику substring(), оскільки перевірка всіх символів на відповідність Latin-1 коштувала б додаткового часу CPU $O(N)$.
4. Чи можна примусово вимкнути Compact Strings у сучасних версіях Java і навіщо це може знадобитися?
Відповідь:
Так, вимкнути можна за допомогою JVM-прапорця:
-XX:-CompactStrings
При вказанні цього прапорця JVM завжди використовує кодування UTF16 (coder = 1) для всіх рядків без винятку, повертаючись до семантики Java 8.
Навіщо це може знадобитися?
Єдиний гіпотетичний сценарій — вузькоспеціалізовані системи (наприклад, у Китаї, Японії чи Кореї), що обробляють 100% текстів національними мовами без латиниці. У такому разі жоден рядок не може стати Latin-1, а вимкнення прапорця дозволяє процесору зекономити пару тактів на перевірці умов if (coder == LATIN1) у методах String. У будь-яких реальних вебсервісах вимикати цей прапорець категорично не рекомендується.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«Compact Strings (JEP 254 у Java 9+) — це прозора оптимізація зберігання рядків, що замінила
char[]на масивbyte[] valueта прапорецьcoder(0 для Latin-1, 1 для UTF-16). Якщо рядок містить лише латинські символи (доU+00FF), кожен символ займає 1 байт замість колишніх 2, що дає 50% економії пам’яті на масивах рядків. Включення кирилиці чи емодзі перемикає рядок у режим UTF-16 (2 байти на символ). У типових Enterprise-застосунках із великою кількістю JSON, логів та HTTP-заголовків це заощаджує 20–30% усієї пам’яті купи без зміни коду».
Ключові тези для Senior-інтерв’ю
- Представлення:
byte[] value+byte coder(0 = Latin-1, 1 = UTF-16). - Арифметика зсувів: Довжина рядка обчислюється як
value.length >> coder. - Рядкове роздування (Inflation): Додавання хоча б одного символу $> 255$ переводить увесь рядок в UTF-16.
- SIMD & Intrinsics: Стиснення, роздування та порівняння байтів виконуються векторними інструкціями CPU (AVX-512).
- Прапорець JVM: Увімкнено за замовчуванням; вимикається через
-XX:-CompactStrings. - Анотація
@Stable: Дозволяє C2-компілятору вбудовувати байти незмінних рядків як машинні константи.
Червоні прапорці (Чого говорити категорично не можна)
- ❌ Називати Compact Strings «стисненням на кшталт GZIP або ZIP» (це не компресія даних, а вибір між 8-бітним і 16-бітним типами).
- ❌ Стверджувати, що кириличні рядки стискаються (кирилиця вимагає UTF-16, розмір залишається попереднім).
- ❌ Думати, що для роботи Compact Strings потрібно передавати спеціальні прапорці в
java(увімкнено за замовчуванням із Java 9). - ❌ Вважати, що
substring()автоматично знижує UTF-16 назад до Latin-1.