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

Как узнать, сколько памяти занимает String

В Java нет оператора sizeof, как в C/C++, поэтому размер строки в памяти рассчитывается исходя из внутренней структуры объектов виртуальной машины JVM.


🟢 Junior Level

В Java нет оператора sizeof, как в C/C++, поэтому размер строки в памяти рассчитывается исходя из внутренней структуры объектов виртуальной машины JVM.

Строка String в памяти — это не один объект, а два связанных объекта в куче (Java Heap):

  1. Сам объект-обертка java.lang.String (содержит заголовок, ссылку на массив, флаг кодировки и хеш-код).
  2. Независимый массив байтов byte[] value (содержит заголовок массива, длину и физические данные символов).

Суть в 30 секундах

На стандартной 64-битной JVM с включенным сжатием ссылок (Compressed OOPs, объем кучи до 32 ГБ): \(\text{Общий размер} = \text{Объект String (24 байта)} + \text{Массив byte[] (16 байт заголовок + данные + выравнивание)}\)

  • Для латинских символов (ASCII / Latin-1) в Java 9+: 1 байт на символ.
  • Для кириллицы и эмодзи (UTF-16): 2 байта на символ.
// Простой расчет для строки "Hello" (5 символов Latin-1):
// 1. Объект String: 24 байта
// 2. Массив byte[5]: 16 байт (заголовок) + 5 байт (данные) = 21 байт -> округление до 24 байт
// ИТОГО: 24 + 24 = 48 байт в памяти кучи!

Для точного программного измерения используется библиотека JOL (Java Object Layout) от команды OpenJDK:

import org.openjdk.jol.info.GraphLayout;

String text = "Hello";
// Возвращает точный размер объекта и всех связанных данных в байтах:
long totalBytes = GraphLayout.parseInstance(text).totalSize(); // 48 байт

🟡 Middle Level

Разделение понятий: Shallow Size против Retained Size

  1. Shallow Size (поверхностный размер): Количество памяти, занимаемое только самим объектом java.lang.String без учета объектов, на которые он ссылается. В 64-битной JVM с Compressed OOPs shallow size всегда равен строго 24 байтам.
  2. Retained Size (удерживаемый размер / Deep Size): Память самого объекта плюс память всех объектов, которые будут удалены сборщиком мусора, если этот объект будет уничтожен (то есть String (24 байта) + массив byte[] value).

Точная раскладка полей в HotSpot (64-bit, Compressed OOPs)

Объект java.lang.String (24 байта):
├── Mark Word:               8 байт   (состояние блокировок, возраст GC, хеш)
├── Compressed Klass Pointer: 4 байта  (указатель на класс java.lang.String)
├── Ссылка на byte[] value:   4 байта  (Compressed OOP указатель)
├── byte coder:              1 байт   (0 = LATIN1, 1 = UTF16)
├── int hash:                4 байта  (кэшированный хеш-код)
├── byte hashIsZero (JDK 13+):1 байт   (флаг нулевого хеша)
└── Padding (выравнивание):   2 байта  (дополнение до кратности 8 байт)
ИТОГО SHALLOW SIZE: 24 байта

Массив byte[] value:
├── Mark Word:               8 байт
├── Compressed Klass Pointer: 4 байта
├── int length:              4 байта  (длина массива в байтах)
├── Данные:                  N байт   (1 байт/символ для Latin-1, 2 байта/символ для UTF-16)
└── Padding (выравнивание):   до кратности 8 байт
ИТОГО РАЗМЕР МАССИВА: (16 + N + 7) & ~7 байт

Таблица реальных размеров строк в Java 17/21

| Строка | Символов | Кодировка | Размер String | Размер byte[] | Итоговый Retained Size | | :— | :— | :— | :— | :— | :— | | "" (пустая строка) | 0 | Latin-1 | 24 байта | 16 байт (данных 0) | 40 байт | | "A" | 1 | Latin-1 | 24 байта | $16 + 1 \to$ 24 байта | 48 байт | | "Hello" | 5 | Latin-1 | 24 байта | $16 + 5 \to$ 24 байта | 48 байт | | "Привет" | 6 | UTF-16 | 24 байта | $16 + 12 \to$ 32 байта | 56 байт | | 100 символов Latin-1 | 100 | Latin-1 | 24 байта | $16 + 100 \to$ 120 байт | 144 байта | | 100 символов UTF-16 | 100 | UTF-16 | 24 байта | $16 + 200 \to$ 224 байта | 248 байт |


🔴 Senior Level

Проблема отключения Compressed OOPs при Heap > 32 GB

Флаг -XX:+UseCompressedOops включен по умолчанию, пока максимальный размер кучи -Xmx не превышает 32 ГБ:

  • В этом режиме JVM кодирует 64-битные адреса в 32-битные указатели со сдвигом на 3 бита (масштабирование до $2^{32} \times 8 = 32 \text{ ГБ}$).
  • Что происходит при -Xmx33g? Сжатие ссылок отключается. Все ссылки на объекты и Klass Pointers становятся 64-битными (8 байт вместо 4).
    • Shallow size объекта String увеличивается с 24 до 32 байт.
    • Заголовок массива byte[] увеличивается с 16 до 24 байт.
    • В приложении с 100 миллионами строк накладные расходы только на служебные заголовки возрастают на 1.6 гигабайта!
    • Эффект «32-гигабайтного обрыва»: JVM с кучей 33 ГБ часто вмещает меньше реальных бизнес-данных, чем JVM с кучей 31 ГБ.

Измерение в рантайме: Instrumentation API против JOL

Существует встроенный механизм Java Agent — java.lang.instrument.Instrumentation:

public class ObjectSizeFetcher {
    private static Instrumentation instrumentation;
    public static void premain(String args, Instrumentation inst) {
        instrumentation = inst;
    }
    public static long getShallowSize(Object o) {
        return instrumentation.getObjectSize(o);
    }
}

Критический нюанс: instrumentation.getObjectSize(str) возвращает только Shallow Size (24 байта) независимо от того, содержит строка 5 символов или 5 миллионов символов! Для глубокого измерения графа объектов без JOL разработчики строят рекурсивный обход полей через Reflection с отслеживанием посещенных ссылок (IdentityHashMap), чтобы исключить циклические ссылки.

Будущее: Project Lilliput и Compact Object Headers (Java 21+)

В рамках OpenJDK Project Lilliput ведутся работы по уменьшению заголовков всех Java-объектов:

  • В Java 21 доступен экспериментальный флаг -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders.
  • Он сжимает заголовок объекта с 12/16 байт до одного 64-битного слова (8 байт), объединяя Mark Word и сжатый ID класса.
  • Для строк это уменьшает размер инстанса String с 24 до 16 байт, а заголовок массива byte[] — с 16 до 8 байт. Экономия для микрострок составляет до 30–40% памяти.

4 Tricky Questions

1. Почему instrumentation.getObjectSize("Очень длинный текст на 10 000 символов") возвращает всего 24 байта?

Ответ: Метод Instrumentation.getObjectSize(Object obj) вычисляет исключительно поверхностный размер (Shallow Size) переданного экземпляра:

  • Он учитывает только заголовок переданного объекта и поля, непосредственно объявленные в классе (включая примитивы и сами 4-байтовые ссылки на другие объекты).
  • Он не переходит по ссылкам на связанные объекты кучи. Поле value в String — это ссылка. Сам объект String на 64-битной JVM с Compressed OOPs занимает ровно 24 байта независимо от длины текста. Массив символов byte[] — это отдельный объект в куче, память которого метод getObjectSize() игнорирует.

2. Сколько памяти в куче потребляет строковый литерал "" (пустая строка)?

Ответ: Пустая строка "" потребляет ровно 40 байт (при 64-bit JVM и Compressed OOPs):

  1. Инстанс java.lang.String: 12 байт (заголовок) + 4 байта (ссылка на массив) + 1 байт (coder) + 4 байта (hash) + 1 байт (hashIsZero) + 2 байта (padding) = 24 байта.
  2. Массив byte[0]: 12 байт (заголовок) + 4 байта (поле length = 0) + 0 байт данных = 16 байт. Суммарно: $24 + 16 = \mathbf{40 \text{ байт}}$.

3. Почему при увеличении памяти сервера с -Xmx31g до -Xmx33g приложение может упасть с OutOfMemoryError быстрее?

Ответ: Потому что при переходе границы в ~32 ГБ JVM отключает сжатие ссылок (Compressed OOPs, -XX:+UseCompressedOops становится false):

  • Указатели на объекты и указатели на классы увеличиваются с 4 до 8 байт.
  • Минимальное выравнивание и размер заголовков всех объектов возрастают. Инстанс String увеличивается с 24 до 32 байт, а заголовок каждого массива — с 16 до 24 байт.
  • В типичном Java-приложении строки и коллекции составляют до 70% всех объектов. Увеличение служебного оверхеда на 30–50% «съедает» добавленные 2 ГБ памяти и приводит к тому, что эффективная полезная емкость кучи при 33 ГБ становится меньше, чем была при 31 ГБ.

4. Если в приложении создано 100 000 переменных String s = "admin", сколько памяти кучи они суммарно займут?

Ответ: Они займут ровно 48 байт на всю программу (плюс память под сами переменные на стеках потоков). Поскольку "admin" — это строковый литерал, JVM помещает его в String Pool. Все 100 000 переменных будут ссылаться на один-единственный физический экземпляр String в памяти кучи (24 байта) и один массив byte[5] (24 байта). Если же создавать строки через new String("admin"), то будет создано 100 000 изолированных объектов String, что займет $100\,000 \times 24 \text{ байта} \approx 2.4 \text{ МБ}$ кучи.


🎯 Шпаргалка для интервью

30-секундный ответ

«В Java нет оператора sizeof. Строка состоит из двух объектов в куче: инстанса String (Shallow Size = 24 байта при Compressed OOPs) и массива byte[] value (16 байт заголовок + данные + выравнивание до 8 байт). Символ латиницы в Java 9+ занимает 1 байт, кириллицы — 2 байта. Пустая строка занимает 40 байт, "Hello" — 48 байт. Точно измерить размер строки со всеми зависимостями (Retained Size) позволяет библиотека OpenJDK JOL: GraphLayout.parseInstance(str).totalSize()».

Ключевые тезисы для Senior-интервью

  1. Shallow vs Retained: Instrumentation.getObjectSize() меряет только shallow (24 байта для любого String); JOL меряет полный граф с массивом byte[].
  2. Формула расчета: $\text{Size} = 24 + \text{align8}(16 + \text{len} \times (1 \ll \text{coder}))$.
  3. Compressed OOPs (32 GB Cliff): При -Xmx > 32g указатели становятся 64-битными, размер заголовков и ссылок возрастает, раздувая память на 30–40%.
  4. Project Lilliput: Сжатие заголовков объектов до 8 байт в будущих версиях Java сэкономит до 40% накладных расходов памяти строк.
  5. String Pool эффект: Миллион одинаковых литералов занимают память всего одного объекта.

Красные флаги (Чего говорить категорически нельзя)

  • ❌ Утверждать: «В Java есть оператор sizeof».
  • ❌ Считать размер строки как просто «количество букв $\times$ 2 байта», начисто забывая про заголовки объектов String и byte[] (~40 байт оверхеда).
  • ❌ Полагать, что Instrumentation.getObjectSize() считает размер строки вместе с массивом символов.
  • ❌ Забывать про выравнивание памяти (Padding до 8 байт).

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