🔤 Раздел 12 · Вопрос #19

Что такое compact strings в 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 rus   = "Привет Мир";  // 10 символов -> 20 байт в массиве 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.

Связанные вопросы