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

Що таке компактні рядки в 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).
// Приклади розподілу пам'яті в 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 зазнала невдачі?

  1. У Java 6 стиснення працювало лише для самого об’єкта String, але клас StringBuilder та всі внутрішні алгоритми продовжували працювати з char[].
  2. Будь-яка конкатенація чи модифікація рядка вимагала циклічного розпакування byte[] у char[] та зворотного пакування, що спричиняло катастрофічну деградацію CPU (просідання пропускної здатності на 30–40%).
  3. Чому все вдалося в 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-інтерв’ю

  1. Представлення: byte[] value + byte coder (0 = Latin-1, 1 = UTF-16).
  2. Арифметика зсувів: Довжина рядка обчислюється як value.length >> coder.
  3. Рядкове роздування (Inflation): Додавання хоча б одного символу $> 255$ переводить увесь рядок в UTF-16.
  4. SIMD & Intrinsics: Стиснення, роздування та порівняння байтів виконуються векторними інструкціями CPU (AVX-512).
  5. Прапорець JVM: Увімкнено за замовчуванням; вимикається через -XX:-CompactStrings.
  6. Анотація @Stable: Дозволяє C2-компілятору вбудовувати байти незмінних рядків як машинні константи.

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

  • ❌ Називати Compact Strings «стисненням на кшталт GZIP або ZIP» (це не компресія даних, а вибір між 8-бітним і 16-бітним типами).
  • ❌ Стверджувати, що кириличні рядки стискаються (кирилиця вимагає UTF-16, розмір залишається попереднім).
  • ❌ Думати, що для роботи Compact Strings потрібно передавати спеціальні прапорці в java (увімкнено за замовчуванням із Java 9).
  • ❌ Вважати, що substring() автоматично знижує UTF-16 назад до Latin-1.

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