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

Що таке компактні рядки в Java 9+?

Кожен символ зберігався у 2 байтах (UTF-16), навіть для простих літер на кшталт "a", "b", "c". Рядок "Hello" займав 10 байт.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Compact Strings — це оптимізація, що з’явилася в Java 9, яка дозволяє рядкам займати вдвічі менше пам’яті, якщо вони містять лише прості латинські символи (англійські літери, цифри, базова пунктуація).

Як це працювало до Java 9: Кожен символ зберігався у 2 байтах (UTF-16), навіть для простих літер на кшталт “a”, “b”, “c”. Рядок “Hello” займав 10 байт.

Як працює в Java 9+:

  • Якщо рядок містить лише «прості» символи (Latin-1, U+0000–U+00FF) → 1 байт на символ
  • Якщо є складні символи (кирилиця, ієрогліфи, емодзі) → 2 байти на символ, як раніше
  • Java сама вирішує, який формат використовувати — вам не потрібно нічого змінювати в коді

Приклад:

String english = "Hello World";  // 5 байт (було б 10 в Java 8)
String russian = "Привіт Мир";    // 10 байт (UTF-16, як і раніше)

Проста аналогія: Уявіть валізу. Раніше Java завжди клала речі у велику валізу (2 байти), навіть якщо ви берете тільки шкарпетки. Тепер Java дивиться: якщо речей мало — бере маленьку валізу (1 байт), якщо багато — велику (2 байти).

Вам не потрібно нічого змінювати. Це працює повністю прозоро.


🟡 Middle Level

Як це реалізовано всередині

В Java 9 поле char[] value замінено на byte[] value + флаг coder:

public final class String {
    @Stable
    private final byte[] value;  // Раніше було char[]
    private final byte coder;    // 0 = Latin-1, 1 = UTF-16
}
  • coder = 0 (LATIN1): 1 байт на символ, діапазон U+0000–U+00FF
  • coder = 1 (UTF16): 2 байти на символ, всі Unicode символи

Автоматичне перемикання

String latin = "Hello";      // coder = LATIN1, 1 byte/char
String cyrillic = "Привіт";  // coder = UTF16, 2 bytes/char

// Конкатенація Latin-1 + Cyrillic → UTF-16 (весь рядок розширюється)
String mixed = latin + cyrillic; // coder = UTF16 для всього рядка

Таблиця типових помилок

Помилка Наслідки Рішення
Думати, що compact strings — це стиснення (як gzip) Очікування економії для будь-яких даних Це просто ефективніший формат зберігання, тільки для Latin-1
Очікування, що -XX:+CompactStrings потрібно вмикати Плутанина з флагами Ввімкнено за замовчуванням з Java 9. Флаг -XX:-CompactStrings вимикає
Очікування, що substring «знизить» coder Неефективне використання пам’яті JVM не «знижує» UTF-16 → Latin-1 автоматично

Порівняння пам’яті

Рядок Java 8 (char[]) Java 9+ (byte[]) Економія  
"Hello" 10 bytes 5 bytes 50%  
"Hello World" 22 bytes 11 bytes 50%  
"Привіт" 12 bytes 12 bytes 0%  
"Hello Привіт" 24 bytes 24 bytes 0%  
"" (порожній) ~40 bytes ~40 bytes 0% String object (~24) + порожній byte[] (~16)

Коли НЕ покладатися на compact strings

  • Текст кирилицею/китайською/японською — завжди UTF-16, економії немає
  • Змішаний текст (Latin + Cyrillic) — весь рядок стає UTF-16
  • Substrings від UTF-16 рядків — наслідують UTF-16 coder, навіть якщо підрядок містить тільки ASCII

🔴 Senior Level

Internal Implementation — JEP 254

JEP 254: Compact Strings (Java 9) змінив внутрішнє представлення String:

// Ключові методи з перевіркою coder
public int length() {
    return value.length >> coder; // LATIN1: >> 0 = без зсуву; UTF16: >> 1 = /2
}

public char charAt(int index) {
    if (isLatin1()) {
        return (char)(value[index] & 0xff);
    }
    return StringUTF16.getChar(value, index);
}

public boolean equals(Object anObject) {
    if (this == anObject) return true;
    if (anObject instanceof String another) {
        if (coder == another.coder) // Спочатку перевіряємо coder!
            return isLatin1()
                ? StringLatin1.equals(value, another.value)
                : StringUTF16.equals(value, another.value);
        }
    }
    return false; // Різні coder → завідомо не рівні
}

Кожен метод String тепер перевіряє coder і делегує роботу в StringLatin1 або StringUTF16. Ці делегати використовують intrinsic-методи JVM — JIT компілятор генерує SIMD-оптимізований код для побайтового порівняння.

Trade-offs

Плюси:

  • Економія пам’яті: 40–50% для типових Enterprise-додатків (JSON keys, log levels, HTTP headers — все ASCII)
  • GC pressure: менше об’єктів у Heap → рідше GC паузи
  • Cache locality: компактні дані → більше поміщається в L1/L2 кеш CPU → швидша обробка
  • Безкоштовна оптимізація — zero code change

Мінуси:

  • Невеликий overhead на перевірку coder в кожному методі (JIT зазвичай елімінує через constant folding)
  • Конкатенація Latin-1 + UTF-16 → UTF-16 (розширення всього рядка)
  • Довелося переписати всі intrinsic-оптимізації для двох форматів
  • Reflection-код, що працював з char[], зламався

Edge Cases (мінімум 3)

1. Coder mismatch при конкатенації:

String latin = "Hello";     // Latin-1
String cyrillic = "Мир";    // UTF-16
String result = latin + " " + cyrillic; // Весь рядок → UTF-16
// Навіть "Hello " розширюється до UTF-16 в результаті
// Втрата: 6 байт → 12 байт для Latin-1 частини

2. Substring не «знижує» coder:

String mixed = "Hello Мир";    // UTF-16 (містить кирилицю)
String sub = mixed.substring(0, 5); // "Hello" — все ще UTF-16!
// JVM не «знижує» до Latin-1 автоматично
// sub займає 10 байт замість можливих 5

3. Reflection і byte[]:

// В Java 9+ не можна просто так отримати value через рефлексію
// byte[] value замість char[] — зламало старий reflection-код
// Module system (Java 9+) додатково обмежує доступ до internal полів
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true); // Requires --add-opens java.base/java.lang

4. Coder при створенні через конструктор:

// new String(byte[], Charset) визначає coder за вмістом
byte[] ascii = {72, 101, 108, 108, 111}; // "Hello"
String s = new String(ascii, StandardCharsets.UTF_8); // coder = LATIN1

byte[] cyrillicBytes = {(byte)0xD0, (byte)0x9F}; // "П" в UTF-8
String s2 = new String(cyrillicBytes, StandardCharsets.UTF_8); // coder = UTF16

Продуктивність

Операція Java 8 (char[]) Java 9+ Compact Improvement
Memory “Hello” 22 bytes 11 bytes -50%
Memory “Hello World!” 34 bytes 18 bytes -47%
charAt() ~1ns ~1ns (intrinsic) Same
equals() (Latin-1) ~2ns ~1.5ns (SIMD on byte[]) -25%
equals() (UTF-16) ~2ns ~2ns Same
GC throughput Baseline +10–15% Better

// Приблизні значення (JMH). Реальні залежать від CPU і JVM.

Пам’ять (64-bit JVM, CompressedOops):

  • Latin-1 String: 24 bytes (object header) + 16 + N bytes (byte[]) ≈ 40 + N bytes
  • UTF-16 String: 24 bytes + 16 + 2N bytes ≈ 40 + 2N bytes
  • Для 1M рядків «Hello»: економія ~5MB у Heap

Thread Safety

String залишається повністю thread-safe. Поле coderfinal, масив value@Stable (JVM-аннотація, що гарантує незмінність після конструювання). Жодних race conditions при читанні з кількох потоків.

Production War Story

Сценарій: Міграція Java 8 → 17 в мікросервісі (4GB Heap, Spring Boot, JSON API).

  • До: Heap usage 75% (3GB), Full GC кожні 20 хвилин, p99 latency = 15ms
  • Після: Heap usage 55% (2.2GB), Full GC кожні 45 хвилин, p99 latency = 10ms
  • JSON keys ("id", "name", "type", "status") — всі Latin-1 → 50% економії
  • Без жодного рядка коду — transparent upgrade

Сценарій 2: Highload парсер (1M рядків/сек, логи):

  • Рядки: JSON keys — всі Latin-1
  • Економія: ~200MB/sec алокацій → ~100MB/sec
  • Young GC duration знизилася на 30%
  • Проблема: Підрядки від UTF-16 рядків наслідували UTF-16 coder → неочікуване споживання пам’яті для «простих» рядків. Fix: явне створення нових рядків через конструктор.

Monitoring

# Перевірити, що Compact Strings увімкнено
java -XX:+PrintFlagsFinal -version | grep CompactStrings
# bool CompactStrings = true  {product}

# JOL — реальний розмір
# String s = "Hello";
# GraphLayout.parseInstance(s).toPrintable()
# Java 9+: value = byte[5]  (5 bytes)
# Java 8:  value = char[5]   (10 bytes)

# GC логи — помітите зниження heap usage
java -Xlog:gc*:file=gc.log ...

Best Practices для Highload

  • Compact Strings увімкнено за замовчуванням — нічого робити не потрібно
  • Для максимального профіту: зберігайте дані в Latin-1 коли можливо (ASCII keys, enum-значення, статуси)
  • Не намагайтеся вручну «знижувати» coder — JIT справляється краще
  • -XX:-CompactStrings для вимкнення (але навіщо?)
  • Профіт найбільш помітний у додатках з великою кількістю рядків: web, JSON parsing, logging, HTTP headers
  • При міграції Java 8 → 9+: перевірте reflection-код, що працював з char[] value
  • Для ultra-low-latency: компактні рядки покращують cache locality → менше L1/L2 cache miss

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

Обов’язково знати:

  • Compact Strings (JEP 254, Java 9+) — оптимізація: Latin-1 (1 байт/символ) замість UTF-16 (2 байти)
  • byte[] value + byte coder замість char[] value (Java 8 і раніше)
  • coder = 0 → Latin-1 (U+0000–U+00FF), coder = 1 → UTF-16
  • Ввімкнено за замовчуванням, флаг -XX:-CompactStrings вимикає
  • Конкатенація Latin-1 + UTF-16 → результат UTF-16 (весь рядок розширюється)
  • Економія: 40-50% пам’яті для типових Enterprise-додатків (JSON keys, HTTP headers — все ASCII)
  • Substring від UTF-16 рядка наслідує UTF-16 coder, навіть якщо підрядок — тільки ASCII

Часті уточнюючі запитання:

  • Яка економія пам’яті від Compact Strings? — 40-50% для Latin-1 рядків. У типовому web-додатку 70% рядків — Latin-1, загальне зниження Heap на 20-30%.
  • Що буде при конкатенації Latin-1 + Cyrillic? — Весь рядок стане UTF-16. Latin-1 частина розшириться до 2 байт/символ.
  • Чи знижує substring() coder з UTF-16 на Latin-1? — Ні. Підрядок від UTF-16 рядка залишається UTF-16, навіть якщо містить тільки ASCII.
  • Чи потрібно вмикати Compact Strings флагом? — Ні, ввімкнено за замовчуванням з Java 9. -XX:-CompactStrings — вимикає.

Червоні прапорці (НЕ говорити):

  • ❌ “Compact Strings — це стиснення як gzip” — це просто ефективніший формат зберігання
  • ❌ “Потрібно вмикати -XX:+CompactStrings” — вже ввімкнено за замовчуванням
  • ❌ “Compact Strings працюють для кирилиці” — кирилиця = UTF-16, економії немає
  • ❌ “Java автоматично знижує UTF-16 → Latin-1” — не знижує, тільки підвищує

Пов’язані теми:

  • [[17. Що таке кодування String]]
  • [[20. Як дізнатися, скільки пам’яті займає String]]
  • [[13. Що робить метод substring() і як він працював до Java 7]]
  • [[22. Що таке дедуплікація рядків в G1 GC]]